initial commit at a basic partial working state

This commit is contained in:
emilis 2025-06-23 09:48:28 +01:00
commit 4ba77630c8
No known key found for this signature in database
67 changed files with 13788 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
/werewolves/dist/
/target/
.vscode
build-and-send.fish
werewolves-saves/

2876
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

8
Cargo.toml Normal file
View File

@ -0,0 +1,8 @@
[workspace]
resolver = "3"
members = [
"werewolves",
"werewolves-macros",
"werewolves-proto",
"werewolves-server",
]

View File

@ -0,0 +1,13 @@
[package]
name = "werewolves-macros"
version = "0.1.0"
edition = "2024"
[lib]
proc-macro = true
[dependencies]
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full", "extra-traits"] }
convert_case = { version = "0.8" }

View File

@ -0,0 +1,321 @@
use core::hash::Hash;
use std::collections::HashMap;
use convert_case::{Case, Casing};
use quote::{ToTokens, quote, quote_spanned};
use syn::{parenthesized, parse::Parse, spanned::Spanned};
use crate::hashlist::HashList;
#[derive(Debug)]
pub struct ChecksAs {
name: syn::Ident,
checks_as: HashList<ChecksAsArg, syn::Variant>,
total_fields: usize,
}
// impl<V> HashList<ChecksAsArg, V> {
// pub fn add_spanned(&mut self, key: ChecksAsArg, value: V) {
// if let Some(orig_key) = self.0.keys().find(|k| **k == key).cloned() {
// let new_span = key
// .span()
// .join(orig_key.span())
// .unwrap_or(key.span().located_at(orig_key.span()));
// let mut vals = self.0.remove(&key).unwrap();
// vals.push(value);
// self.0.insert(key.with_span(new_span), vals);
// return;
// }
// self.0.insert(key, vec![value]);
// }
// }
#[derive(Debug, Clone)]
enum ChecksAsArg {
AsSelf(proc_macro2::Span),
Name(syn::Ident),
Path(syn::Path),
}
impl PartialEq for ChecksAsArg {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::AsSelf(_), Self::AsSelf(_)) => true,
(Self::Name(l0), Self::Name(r0)) => l0 == r0,
(Self::Path(l0), Self::Path(r0)) => l0 == r0,
_ => false,
}
}
}
impl Eq for ChecksAsArg {}
impl Hash for ChecksAsArg {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
#[derive(Hash)]
enum ChecksAsArgHash<'a> {
AsSelf,
Name(&'a syn::Ident),
Path(&'a syn::Path),
}
core::mem::discriminant(&match self {
ChecksAsArg::AsSelf(_) => ChecksAsArgHash::AsSelf,
ChecksAsArg::Name(ident) => ChecksAsArgHash::Name(ident),
ChecksAsArg::Path(path) => ChecksAsArgHash::Path(path),
})
.hash(state);
}
}
impl ChecksAsArg {
fn span(&self) -> proc_macro2::Span {
match self {
ChecksAsArg::AsSelf(span) => *span,
ChecksAsArg::Name(ident) => ident.span(),
ChecksAsArg::Path(path) => path.span(),
}
}
fn with_span(&self, span: proc_macro2::Span) -> Self {
match self {
ChecksAsArg::AsSelf(_) => ChecksAsArg::AsSelf(span),
ChecksAsArg::Name(ident) => {
ChecksAsArg::Name(syn::Ident::new(ident.to_string().as_str(), span))
}
ChecksAsArg::Path(path) => ChecksAsArg::Path(syn::Path {
leading_colon: path.leading_colon,
segments: path
.segments
.iter()
.cloned()
.map(|c| syn::PathSegment {
ident: syn::Ident::new(c.ident.to_string().as_str(), span),
arguments: c.arguments,
})
.collect(),
}),
}
}
}
enum PathOrName {
Path(syn::Path),
Name(syn::LitStr),
}
impl Parse for PathOrName {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
if input.peek(syn::LitStr) {
Ok(Self::Name(input.parse()?))
} else {
Ok(Self::Path(input.parse()?))
}
}
}
impl ChecksAsArg {
fn from_attr(attr: &syn::Attribute) -> syn::Result<Self> {
match &attr.meta {
syn::Meta::Path(path) => {
path.require_ident()?;
Ok(Self::AsSelf(path.span()))
}
syn::Meta::NameValue(v) => Err(syn::Error::new(
attr.meta.span(),
format!("meta_name_value: {v:?}").as_str(),
)),
syn::Meta::List(list) => match list.parse_args::<PathOrName>()? {
PathOrName::Path(path) => Ok(Self::Path(path)),
PathOrName::Name(lit_str) => Ok(Self::Name(syn::Ident::new(
lit_str.value().as_str(),
lit_str.span(),
))),
},
}
}
fn filter(attr: &&syn::Attribute) -> bool {
attr.path()
.get_ident()
.map(|id| id.to_string().as_str() == CHECKS_AS_PATH)
.unwrap_or_default()
}
}
const CHECKS_AS_PATH: &str = "checks";
impl ChecksAs {
pub fn parse(input: syn::DeriveInput) -> Result<Self, syn::Error> {
let name = input.ident;
let mut checks_as = HashList::new();
let total_fields;
match &input.data {
syn::Data::Enum(data) => {
total_fields = data.variants.len();
for field in data.variants.iter() {
for attr in field
.attrs
.iter()
.filter(ChecksAsArg::filter)
.map(ChecksAsArg::from_attr)
{
let attr = attr?;
let mut field = field.clone();
field.ident.set_span(attr.span());
checks_as.add(attr, field);
}
}
}
_ => todo!(),
};
Ok(Self {
name,
checks_as,
total_fields,
})
}
}
fn fields_args(field: &syn::Variant) -> proc_macro2::TokenStream {
match &field.fields {
syn::Fields::Named(f) => {
let named = f.named.iter().map(|f| f.ident.clone());
quote! { {#(#named: _),*} }
}
syn::Fields::Unnamed(f) => {
let f = f.unnamed.iter().map(|_| quote! {_});
quote! {
(#(#f),*)
}
}
syn::Fields::Unit => quote! {},
}
}
impl ToTokens for ChecksAs {
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
// panic!("{self:?}");
let name = self.name.clone();
let (path, non_path) = self
.checks_as
.clone()
.decompose()
.into_iter()
.partition::<Vec<_>, _>(|(c, _)| matches!(c, ChecksAsArg::Path(_)));
for (check, fields) in non_path {
match check {
ChecksAsArg::AsSelf(_) => {
let fields = fields.into_iter().map(|f| {
let fn_name = syn::Ident::new(
format!("is_{}", f.ident.to_string().to_case(Case::Snake)).as_str(),
f.ident.span(),
);
let ident = f.ident.clone();
let args = fields_args(&f);
quote_spanned! { f.span() =>
pub const fn #fn_name(&self) -> bool {
matches!(self, Self::#ident #args)
}
}
});
tokens.extend(quote! {
impl #name {
#(#fields)*
}
});
}
ChecksAsArg::Name(ident) => {
let fields = fields.into_iter().map(|f| {
let args = fields_args(&f);
let ident = f.ident.clone();
quote_spanned! {f.span() =>
#name::#ident #args
}
});
tokens.extend(quote! {
impl #name {
pub const fn #ident(&self) -> bool {
matches!(self, #(#fields)|*)
}
}
});
}
ChecksAsArg::Path(_) => unreachable!(),
}
}
let mut by_path_start = HashList::new();
let mut tmp = path.iter();
while let Some((ChecksAsArg::Path(path), fields)) = tmp.next() {
let start = path.segments.iter().next().unwrap().clone();
by_path_start.add(start, (path.clone(), fields));
}
let by_path = by_path_start.decompose();
for (start, paths) in by_path {
let mut unique_count = HashMap::new();
let mut total_count = 0usize;
paths.iter().for_each(|(_, variants)| {
variants.iter().for_each(|v| {
unique_count.insert(v, ());
total_count += 1;
})
});
let unique_variants = unique_count.keys().count();
if unique_variants != total_count {
panic!("some fields have multiple ChecksAs values for the same type")
}
// nowadays everywhere i look i see patterns
let patterns = paths.into_iter().map(|(val, variants)| {
let idents = variants.iter().map(|v| {
let args = fields_args(v);
let ident = v.ident.clone();
quote! {
#ident #args
}
});
quote! {
#(#name::#idents)|* => #val,
}
});
let fn_name = syn::Ident::new(
start.ident.to_string().to_case(Case::Snake).as_str(),
start.span(),
);
let (ret, match_statement) = if unique_variants != self.total_fields {
(
quote! {Option<#start>},
quote! {
Some(match self {
#(#patterns)*
_ => return None,
})
},
)
} else {
(
quote! {#start},
quote! {
match self {
#(#patterns)*
}
},
)
};
tokens.extend(quote! {
impl #name {
pub const fn #fn_name(&self) -> #ret {
#match_statement
}
}
});
}
}
}

View File

@ -0,0 +1,36 @@
use std::collections::HashMap;
use std::hash::Hash;
pub struct HashList<K: Eq + Hash, V>(HashMap<K, Vec<V>>);
impl<K: Eq + Hash + core::fmt::Debug, V: core::fmt::Debug> core::fmt::Debug for HashList<K, V> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("HashList").field(&self.0).finish()
}
}
impl<K: Eq + Hash + Clone, V: Clone> Clone for HashList<K, V> {
fn clone(&self) -> Self {
Self(self.0.clone())
}
}
impl<K: Eq + Hash, V> HashList<K, V> {
pub fn new() -> Self {
Self(HashMap::new())
}
pub fn add(&mut self, key: K, value: V) {
match self.0.get_mut(&key) {
Some(values) => values.push(value),
None => {
self.0.insert(key, vec![value]);
}
}
}
pub fn decompose(&mut self) -> Box<[(K, Vec<V>)]> {
let mut body = HashMap::new();
core::mem::swap(&mut self.0, &mut body);
body.into_iter().collect()
}
}

View File

@ -0,0 +1,545 @@
use core::error::Error;
use std::{
io,
path::{Path, PathBuf},
};
use convert_case::Casing;
use proc_macro2::Span;
use quote::{ToTokens, quote};
use syn::{braced, bracketed, parenthesized, parse::Parse, parse_macro_input};
mod checks;
pub(crate) mod hashlist;
mod targets;
struct IncludePath {
dir_path: PathBuf,
modules: Vec<syn::Ident>,
}
fn display_err(span: Span, err: impl Error) -> syn::Error {
syn::Error::new(span, err.to_string().as_str())
}
fn read_modules(span: Span, path: &Path) -> syn::Result<Vec<syn::Ident>> {
let read_dir = std::fs::read_dir(path).map_err(|err| display_err(span, err))?;
Ok(read_dir
.map(|file| file.map_err(|err| display_err(span, err)))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.filter(|file| file.file_type().map(|ft| ft.is_file()).unwrap_or_default())
.filter_map(|file| {
file.file_name()
.to_string_lossy()
.strip_suffix(".rs")
.map(|f| f.to_string())
})
.map(|module_name| syn::Ident::new(&module_name, span))
.collect::<Vec<_>>())
}
impl Parse for IncludePath {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let directory = input.parse::<syn::LitStr>()?;
let dir_path = std::env::current_dir()
.unwrap()
.join(directory.value())
.to_path_buf();
let modules = read_modules(directory.span(), &dir_path)?;
Ok(Self { modules, dir_path })
}
}
impl IncludePath {
fn posts_tokens(&self) -> proc_macro2::TokenStream {
let mod_decl = self.modules.iter().map(|module| {
let path = self.dir_path.join(format!("{module}.rs"));
let path = path.to_str().unwrap();
quote! {
mod #module {
include!(#path);
}}
});
let posts = self.modules.iter().map(|module| {
quote! {
#module::POST.into_post()
}
});
quote! {
#(#mod_decl)*
pub const POSTS: &[Post] = &[#(#posts),*];
}
}
fn normal_tokens(&self) -> proc_macro2::TokenStream {
let mod_decl = self.modules.iter().map(|module| {
quote! {mod #module;}
});
let pub_use = self.modules.iter().map(|module| {
quote! {
#module::*
}
});
quote! {
#(#mod_decl)*
pub use {#(#pub_use),*};
}
}
}
enum IncludeOutput {
Normal(IncludePath),
Posts(IncludePath),
}
impl ToTokens for IncludeOutput {
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
tokens.extend(match self {
IncludeOutput::Normal(include_path) => include_path.normal_tokens(),
IncludeOutput::Posts(include_path) => include_path.posts_tokens(),
});
}
}
struct FileWithPath {
// bytes: Vec<u8>,
include: proc_macro2::TokenStream,
rel_path: String,
}
impl FileWithPath {
fn from_path(path: impl AsRef<Path>, origin_path: impl AsRef<Path>) -> Result<Self, io::Error> {
let abs_path = path
.as_ref()
.canonicalize()
.ok()
.and_then(|p| p.to_str().map(|t| t.to_string()))
.ok_or(io::Error::new(io::ErrorKind::InvalidData, "invalid path"))?;
let include = quote! {
include_bytes!(#abs_path)
};
let origin_abs = origin_path.as_ref().canonicalize().and_then(|p| {
p.to_str()
.ok_or(io::Error::new(io::ErrorKind::InvalidData, "invalid path"))
.map(|t| t.to_string())
})?;
let rel_path = abs_path.replace(&origin_abs, "");
Ok(Self { include, rel_path })
}
}
fn find_matching_files_recursive<P, F, D>(
p: P,
include_in_rerun: F,
directory_filter: D,
out: &mut Vec<FileWithPath>,
origin_path: &PathBuf,
) -> Result<(), io::Error>
where
P: AsRef<Path>,
F: Fn(&str) -> bool + Copy,
D: Fn(&str) -> bool + Copy,
{
for item in std::fs::read_dir(p.as_ref())? {
let item = item?;
if item.file_type()?.is_dir() {
if directory_filter(item.file_name().to_str().ok_or(io::Error::new(
io::ErrorKind::InvalidData,
"file has no name",
))?) {
find_matching_files_recursive(
item.path(),
include_in_rerun,
directory_filter,
out,
origin_path,
)?;
}
continue;
}
if let Some(file_name) = item.file_name().to_str() {
if include_in_rerun(file_name) {
out.push(FileWithPath::from_path(item.path(), origin_path)?);
}
}
}
Ok(())
}
struct IncludeDist {
name: syn::Ident,
files: Vec<FileWithPath>,
}
impl IncludeDist {
fn read_dist_path(
name: syn::Ident,
path_span: Span,
path: &Path,
origin_path: &PathBuf,
) -> syn::Result<Self> {
let mut files = Vec::new();
find_matching_files_recursive(path, |_| true, |_| true, &mut files, origin_path)
.map_err(|err| syn::Error::new(path_span, err.to_string()))?;
Ok(Self { name, files })
}
}
impl Parse for IncludeDist {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let name = input.parse::<syn::Ident>()?;
input.parse::<syn::Token![,]>()?;
let directory = input.parse::<syn::LitStr>()?;
let dir_path = std::env::current_dir()
.unwrap()
.join(directory.value())
.to_path_buf();
Self::read_dist_path(name, directory.span(), &dir_path, &dir_path)
}
}
impl ToTokens for IncludeDist {
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
// std::collections::HashMap
let array_contents = self.files.iter().map(|file| {
let FileWithPath { include, rel_path } = file;
quote! {
(#rel_path, #include)
}
});
let name = &self.name;
tokens.extend(quote! {
pub const #name: &[(&'static str, &'static [u8])] = &[#(#array_contents),*];
});
}
}
struct StaticLinks {
collect_into_const: Option<syn::Ident>,
relative_to: PathBuf,
files: Vec<(String, PathBuf)>,
}
impl Parse for StaticLinks {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let collect_into_const = if input.peek(syn::Ident) {
let out = Some(input.parse()?);
input.parse::<syn::Token![=>]>()?;
out
} else {
None
};
const EXPECTED: &str = "expected 'relative to'";
let directory = input.parse::<syn::LitStr>()?;
{
let rel = input
.parse::<syn::Ident>()
.map_err(|err| syn::Error::new(err.span(), EXPECTED))?;
if rel != "relative" {
return Err(syn::Error::new(rel.span(), EXPECTED));
}
}
{
let to = input
.parse::<syn::Ident>()
.map_err(|err| syn::Error::new(err.span(), EXPECTED))?;
if to != "to" {
return Err(syn::Error::new(to.span(), EXPECTED));
}
}
let current_dir = std::env::current_dir().unwrap();
let relative_to = current_dir
.join(input.parse::<syn::LitStr>()?.value())
.canonicalize()
.expect("cannonicalize relative to path");
let span = directory.span();
let path_dir = current_dir
.join(directory.value())
.canonicalize()
.expect("canonicalize base path")
.to_path_buf();
let read_dir = std::fs::read_dir(&path_dir).map_err(|err| display_err(span, err))?;
let files = read_dir
.map(|file| file.map_err(|err| display_err(span, err)))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.filter(|file| file.file_type().map(|ft| ft.is_file()).unwrap_or_default())
.map(|file| {
(
file.file_name().to_str().expect("bad filename").to_string(),
file.path(),
)
})
.collect::<Vec<_>>();
Ok(StaticLinks {
collect_into_const,
relative_to,
files,
})
}
}
impl ToTokens for StaticLinks {
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
let base_path = self.relative_to.to_str().expect("base path to_string");
let ident_with_decl = self.files.iter().map(|(file, path)| {
let file_ident = syn::Ident::new(
file.replace(['.', ' ', '-'], "_").to_uppercase().as_str(),
Span::call_site(),
);
let filepath = path
.to_str()
.expect("path to string")
.replace(base_path, "");
let decl = quote! {
pub const #file_ident: &'static str = #filepath;
};
(file_ident, decl)
});
let files = ident_with_decl.clone().map(|(_, q)| q);
tokens.extend(quote! {
#(#files)*
});
if let Some(collect_into_const) = &self.collect_into_const {
let idents = ident_with_decl.map(|(ident, _)| ident);
tokens.extend(quote! {
pub const #collect_into_const: &[&str] = &[#(#idents),*];
});
}
}
}
impl StaticLinks {
fn into_demand_metadata(self) -> StaticLinksDemandMetadata {
let StaticLinks {
collect_into_const,
relative_to,
files,
} = self;
StaticLinksDemandMetadata {
collect_into_const,
relative_to,
files,
}
}
}
struct StaticLinksDemandMetadata {
collect_into_const: Option<syn::Ident>,
relative_to: PathBuf,
files: Vec<(String, PathBuf)>,
}
impl ToTokens for StaticLinksDemandMetadata {
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
let base_path = self.relative_to.to_str().expect("base path to_string");
let ident_with_decl = self.files.iter().map(|(file, path)| {
let file_ident = syn::Ident::new(
file.replace(['.', ' ', '-'], "_").to_uppercase().as_str(),
Span::call_site(),
);
let filepath = path
.to_str()
.expect("path to string")
.replace(base_path, "");
let decl = quote! {
pub const #file_ident: &'static str = #filepath;
};
(file_ident, filepath, decl)
});
let files = ident_with_decl.clone().map(|(_, _, q)| q);
tokens.extend(quote! {
#(#files)*
});
if let Some(collect_into_const) = &self.collect_into_const {
let idents = ident_with_decl.clone().map(|(ident, _, _)| ident);
tokens.extend(quote! {
pub const #collect_into_const: &[&str] = &[#(#idents),*];
});
}
let (paths_and_snake_idents, members): (Vec<_>, Vec<_>) = ident_with_decl
.clone()
.map(|(i, filepath, _)| {
let snake_ident = syn::Ident::new(
i.to_string().to_case(convert_case::Case::Snake).as_str(),
i.span(),
);
let member = quote! {
pub #snake_ident: Metadata,
};
((filepath, snake_ident), member)
})
.unzip();
let find_by_key = paths_and_snake_idents.iter().map(|(path, ident)| {
quote! {
#path => &self.#ident
}
});
tokens.extend(quote! {
pub struct MetadataMap {
#(#members)*
}
impl MetadataMap {
pub fn find_by_key(&self, key: &str) -> Option<&Metadata> {
Some(match key {
#(#find_by_key,)*
_ => return None,
})
}
}
});
}
}
#[proc_macro]
pub fn include_dist(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let incl_dist = parse_macro_input!(input as IncludeDist);
quote! {#incl_dist}.into()
}
#[proc_macro]
pub fn posts(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let pubuse = IncludeOutput::Posts(parse_macro_input!(input as IncludePath));
quote! {#pubuse}.into()
}
#[proc_macro]
pub fn include_path(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let incl_path = IncludeOutput::Normal(parse_macro_input!(input as IncludePath));
quote! {#incl_path}.into()
}
#[proc_macro]
pub fn static_links(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let static_links = parse_macro_input!(input as StaticLinks);
quote! {#static_links}.into()
}
#[proc_macro]
pub fn static_links_demand_metadata(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let demand_metadata = parse_macro_input!(input as StaticLinks).into_demand_metadata();
quote! {#demand_metadata}.into()
}
fn fields_args(field: &syn::Variant) -> proc_macro2::TokenStream {
match &field.fields {
syn::Fields::Named(f) => {
let named = f.named.iter().map(|f| f.ident.clone());
quote! { {#(#named: _),*} }
}
syn::Fields::Unnamed(f) => {
let f = f.unnamed.iter().map(|_| quote! {_});
quote! {
(#(#f),*)
}
}
syn::Fields::Unit => quote! {},
}
}
#[proc_macro_derive(Titles)]
pub fn villager_roles(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let villager_roles: syn::DeriveInput = parse_macro_input!(input);
let name = syn::Ident::new(
format!("{}Title", villager_roles.ident).as_str(),
villager_roles.ident.span(),
);
let original_name = &villager_roles.ident;
let variants = match &villager_roles.data {
syn::Data::Enum(data_enum) => data_enum.variants.iter().collect::<Vec<_>>(),
_ => todo!(),
};
let display_match = variants.iter().map(|v| {
let name_str = v.ident.to_string();
let name = v.ident.clone();
quote! {
Self::#name => f.write_str(#name_str),
}
});
let title_match = variants.iter().map(|v| {
let args = fields_args(v);
let field_name = v.ident.clone();
quote! {
#original_name::#field_name #args => #name::#field_name,
}
});
let names_count = variants.len();
let names_const_val = variants
.iter()
.map(|v| {
let name = v.ident.clone();
quote! {Self::#name}
})
.collect::<Vec<_>>();
let enum_variant_decl = variants.iter().map(|v| {
let ident = v.ident.clone();
let attrs = v.attrs.iter();
quote! {
#(#attrs)*
#ident
}
});
quote! {
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize, Eq, Hash, werewolves_macros::ChecksAs)]
pub enum #name {
#(#enum_variant_decl,)*
}
impl #original_name {
pub const fn title(&self) -> #name {
match self {
#(#title_match)*
}
}
}
impl #name {
pub const ALL: [Self; #names_count] = [#(#names_const_val,)*];
}
impl core::fmt::Display for #name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
#(#display_match)*
}
}
}
}
.into()
}
#[proc_macro_derive(ChecksAs, attributes(checks))]
pub fn checks_as(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let checks_as = checks::ChecksAs::parse(parse_macro_input!(input)).unwrap();
quote! {#checks_as}.into()
}
#[proc_macro_derive(Extract, attributes(extract))]
pub fn extract(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let checks_as = targets::Targets::parse(parse_macro_input!(input)).unwrap();
quote! {#checks_as}.into()
}

View File

@ -0,0 +1,305 @@
use std::collections::HashMap;
use convert_case::{Case, Casing};
use proc_macro2::TokenStream;
use quote::{ToTokens, quote, quote_spanned};
use syn::spanned::Spanned;
use crate::hashlist::HashList;
const TARGETS_PATH: &str = "extract";
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum VariantFieldIndex {
Ident(syn::Ident),
Numeric(syn::LitInt),
None,
}
impl VariantFieldIndex {
const fn is_numeric(&self) -> bool {
match self {
Self::Numeric(_) => true,
_ => false,
}
}
}
impl syn::parse::Parse for VariantFieldIndex {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
if input.is_empty() {
Ok(VariantFieldIndex::None)
} else if input.peek(syn::LitInt) {
Ok(VariantFieldIndex::Numeric(input.parse()?))
} else {
Ok(VariantFieldIndex::Ident(input.parse()?))
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct VariantAttr {
index: VariantFieldIndex,
alias: Option<syn::Ident>,
}
impl syn::parse::Parse for VariantAttr {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let index = if input.peek(syn::token::As) {
VariantFieldIndex::None
} else {
input.parse::<VariantFieldIndex>()?
};
if !input.peek(syn::token::As) {
return Ok(Self { index, alias: None });
}
input.parse::<syn::Token![as]>()?;
Ok(Self {
index,
alias: Some(input.parse()?),
})
}
}
#[derive(Debug, Clone)]
struct TargetVariant {
attr: VariantAttr,
access: TokenStream,
ty: syn::Type,
variant: syn::Variant,
}
impl TargetVariant {
fn name(&self) -> syn::Ident {
if let Some(alias) = self.attr.alias.as_ref() {
return alias.clone();
}
match &self.attr.index {
VariantFieldIndex::Ident(ident) => ident.clone(),
VariantFieldIndex::Numeric(_) | VariantFieldIndex::None => self.variant.ident.clone(),
}
}
}
#[derive(Debug)]
pub struct Targets {
name: syn::Ident,
targets: Vec<TargetVariant>,
total_fields: usize,
}
impl Targets {
pub fn parse(input: syn::DeriveInput) -> Result<Self, syn::Error> {
let name = input.ident;
let mut targets: Vec<TargetVariant> = Vec::new();
let total_fields;
match &input.data {
syn::Data::Enum(data) => {
total_fields = data.variants.len();
for field in data.variants.iter() {
let field_ident = &field.ident;
for attr in field.attrs.iter().filter(|attr| {
attr.path()
.get_ident()
.map(|id| id.to_string().as_str() == TARGETS_PATH)
.unwrap_or_default()
}) {
let attr_span = attr.span();
let attr = attr.parse_args::<VariantAttr>()?;
let (access, ty) = match &field.fields {
syn::Fields::Named(fields) => match &attr.index {
VariantFieldIndex::Ident(ident) => {
let mut matching_val = None;
let mut accesses = Vec::new();
for field in fields.named.iter() {
let matching = field
.ident
.as_ref()
.map(|i| i.to_string() == ident.to_string())
.unwrap_or_default();
if matching_val.is_some() && matching {
panic!("duplicate?")
}
let ident = field.ident.clone().unwrap();
if matching {
matching_val = Some((ident.clone(), field.ty.clone()));
accesses.push(quote! {
#ident
});
} else {
accesses.push(quote! {
#ident: _
});
}
}
let (mut matching_ident, matching_ty) = matching_val.ok_or(
syn::Error::new(ident.span(), "no such variant field"),
)?;
matching_ident.set_span(ident.span());
(
quote! {
#name::#field_ident { #(#accesses),* } => &#matching_ident,
},
matching_ty,
)
}
VariantFieldIndex::Numeric(num) => {
return Err(syn::Error::new(
num.span(),
"cannot used numeric index for a variant with named fields",
));
}
VariantFieldIndex::None => {
if fields.named.len() == 1 {
let field = fields.named.iter().next().unwrap();
let field_ident = field.ident.as_ref().unwrap();
(
quote! {
#name::#field_ident { #field_ident } => &#field_ident,
},
field.ty.clone(),
)
} else {
return Err(syn::Error::new(
fields.named.span(),
"unnamed field index with more than one field",
));
}
}
},
syn::Fields::Unnamed(fields) => {
if let VariantFieldIndex::Numeric(num) = &attr.index {
let num = num.base10_parse::<u8>()? as usize;
let field = fields
.unnamed
.iter()
.nth(num)
.ok_or(syn::Error::new(
attr_span,
"field index out of range",
))?
.clone();
let left = (0..num).map(|_| quote! {_});
let right = (num..fields.unnamed.len()).map(|_| quote! {_});
(
quote! {
#name::#field_ident(#(#left),*, val, #(#right),*) => &val,
},
field.ty.clone(),
)
} else if fields.unnamed.len() == 1 {
(
quote! {
#name::#field_ident(val) => &val,
},
fields.unnamed.iter().next().unwrap().ty.clone(),
)
} else {
return Err(syn::Error::new(
fields.span(),
"unnamed fields without numeric index",
));
}
}
syn::Fields::Unit => {
return Err(syn::Error::new(
field.span(),
"target cannot be a unit field",
));
}
};
let target = TargetVariant {
attr,
ty,
access,
variant: field.clone(),
};
// if targets.iter().any(|t| t.name() == target.name()) {
// return Err(syn::Error::new(attr_span, "duplicate target field"));
// }
// target.variant.ident.set_span(attr_span);
targets.push(target);
}
}
}
_ => todo!(),
};
Ok(Self {
name,
targets,
total_fields,
})
}
}
impl ToTokens for Targets {
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
// this is so horrid and haphazard but idc rn
// commenting to make the criticizing voice stfu
let mut by_target = {
let mut out = HashList::new();
for target in self.targets.iter() {
out.add(target.name().to_string(), target.clone());
}
out
};
let fns = by_target.decompose().into_iter().map(|(_, targets)| {
if targets.is_empty() {
return quote! {};
}
let all_variants = targets.len() == self.total_fields;
let fn_ret = {
let ty = targets.first().unwrap().ty.clone();
if all_variants {
quote! {&#ty}
} else {
quote! {Option<&#ty>}
}
};
let fn_name = {
let name = targets.first().unwrap().name();
syn::Ident::new(name.to_string().to_case(Case::Snake).as_str(), name.span())
};
let raw_accesses = targets.iter().map(|t| {
let access = &t.access;
quote_spanned! { t.attr.alias.as_ref().unwrap().span() =>
#access
}
});
let accesses = if all_variants {
quote! {
match self {
#(#raw_accesses)*
}
}
} else {
quote! {
Some(match self {
#(#raw_accesses)*
_ => return None,
})
}
};
quote! {
pub const fn #fn_name(&self) -> #fn_ret {
#accesses
}
}
});
let name = &self.name;
tokens.extend(quote! {
impl #name {
#(#fns)*
}
});
}
}

View File

@ -0,0 +1,17 @@
[package]
name = "werewolves-proto"
version = "0.1.0"
edition = "2024"
[dependencies]
thiserror = { version = "2" }
log = { version = "0.4" }
serde_json = { version = "1.0" }
serde = { version = "1.0", features = ["derive"] }
uuid = { version = "1.17", features = ["v4", "serde"] }
rand = { version = "0.9" }
werewolves-macros = { path = "../werewolves-macros" }
[dev-dependencies]
pretty_assertions = { version = "1" }

View File

@ -0,0 +1,66 @@
use core::{fmt::Debug, num::NonZeroU8};
use serde::{Deserialize, Serialize};
use crate::{game::DateTime, player::CharacterId};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DiedTo {
Execution {
day: NonZeroU8,
},
MapleWolf {
source: CharacterId,
night: NonZeroU8,
starves_if_fails: bool,
},
MapleWolfStarved {
night: NonZeroU8,
},
Militia {
killer: CharacterId,
night: NonZeroU8,
},
Wolfpack {
night: NonZeroU8,
},
AlphaWolf {
killer: CharacterId,
night: NonZeroU8,
},
Shapeshift {
into: CharacterId,
night: NonZeroU8,
},
Hunter {
killer: CharacterId,
night: NonZeroU8,
},
Guardian {
killer: CharacterId,
night: NonZeroU8,
},
}
impl DiedTo {
pub const fn date_time(&self) -> DateTime {
match self {
DiedTo::Execution { day } => DateTime::Day { number: *day },
DiedTo::Guardian { killer: _, night }
| DiedTo::MapleWolf {
source: _,
night,
starves_if_fails: _,
}
| DiedTo::MapleWolfStarved { night }
| DiedTo::Militia { killer: _, night }
| DiedTo::Wolfpack { night }
| DiedTo::AlphaWolf { killer: _, night }
| DiedTo::Shapeshift { into: _, night }
| DiedTo::Hunter { killer: _, night } => DateTime::Night {
number: night.get(),
},
}
}
}

View File

@ -0,0 +1,74 @@
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{player::CharacterId, role::RoleTitle};
#[derive(Debug, Clone, PartialEq, Error, Serialize, Deserialize)]
pub enum GameError {
#[error("too many roles. have {players} players, but {roles} roles (incl wolves)")]
TooManyRoles { players: u8, roles: u8 },
#[error("wolves range must start at 1")]
NoWolves,
#[error("message invalid for game state")]
InvalidMessageForGameState,
#[error("no executions during night time")]
NoExecutionsAtNight,
#[error("no-trial not allowed")]
NoTrialNotAllowed,
#[error("chracter is already dead")]
CharacterAlreadyDead,
#[error("no matching character found")]
NoMatchingCharacterFound,
#[error("character not in joined player pool")]
CharacterNotInJoinedPlayers,
#[error("{0}")]
GenericError(String),
#[error("invalid cause of death")]
InvalidCauseOfDeath,
#[error("invalid target")]
InvalidTarget,
#[error("timed out")]
TimedOut,
#[error("host channel closed")]
HostChannelClosed,
#[error("not all players connected")]
NotAllPlayersConnected,
#[error("too few players: got {got} but the settings require at least {need}")]
TooFewPlayers { got: u8, need: u8 },
#[error("it's already daytime")]
AlreadyDaytime,
#[error("it's not the end of the night yet")]
NotEndOfNight,
#[error("it's not day yet")]
NotDayYet,
#[error("it's not night")]
NotNight,
#[error("invalid role, expected {expected:?} got {got:?}")]
InvalidRole { expected: RoleTitle, got: RoleTitle },
#[error("villagers cannot be added to settings")]
CantAddVillagerToSettings,
#[error("no mentor for an apprentice to be an apprentice to :(")]
NoApprenticeMentor,
#[error("BUG: cannot find character in village, but they should be there")]
CannotFindTargetButShouldBeThere,
#[error("inactive game object")]
InactiveGameObject,
#[error("socket error: {0}")]
SocketError(String),
#[error("this night is over")]
NightOver,
#[error("no night actions")]
NoNightActions,
#[error("still awaiting response")]
AwaitingResponse,
#[error("current state already has a response")]
NightNeedsNext,
#[error("night zero actions can only be obtained on night zero")]
NotNightZero,
#[error("wolves intro in progress")]
WolvesIntroInProgress,
#[error("a game is still ongoing")]
GameOngoing,
#[error("needs a role reveal")]
NeedRoleReveal,
}

View File

@ -0,0 +1,298 @@
mod night;
mod settings;
mod village;
use core::{
fmt::Debug,
num::NonZeroU8,
ops::{Deref, Range, RangeBounds},
};
use rand::{Rng, seq::SliceRandom};
use serde::{Deserialize, Serialize};
use crate::{
error::GameError,
game::night::Night,
message::{
CharacterState, Identification,
host::{HostDayMessage, HostGameMessage, HostNightMessage, ServerToHostMessage},
},
player::CharacterId,
};
pub use {settings::GameSettings, village::Village};
type Result<T> = core::result::Result<T, GameError>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Game {
previous: Vec<GameState>,
state: GameState,
}
impl Game {
pub fn new(players: &[Identification], settings: GameSettings) -> Result<Self> {
Ok(Self {
previous: Vec::new(),
state: GameState::Night {
night: Night::new(Village::new(players, settings)?)?,
},
})
}
pub const fn village(&self) -> &Village {
match &self.state {
GameState::Day { village, marked: _ } => village,
GameState::Night { night } => night.village(),
}
}
pub fn process(&mut self, message: HostGameMessage) -> Result<ServerToHostMessage> {
match (&mut self.state, message) {
(GameState::Night { night }, HostGameMessage::Night(HostNightMessage::Next)) => {
night.next()?;
self.process(HostGameMessage::GetState)
}
(
GameState::Day { village: _, marked },
HostGameMessage::Day(HostDayMessage::MarkForExecution(target)),
) => {
match marked
.iter()
.enumerate()
.find_map(|(idx, mark)| (mark == &target).then_some(idx))
{
Some(idx) => {
marked.swap_remove(idx);
}
None => marked.push(target),
}
self.process(HostGameMessage::GetState)
}
(GameState::Day { village, marked }, HostGameMessage::Day(HostDayMessage::Execute)) => {
if let Some(outcome) = village.execute(marked)? {
return Ok(ServerToHostMessage::GameOver(outcome));
}
let night = Night::new(village.clone())?;
self.previous.push(self.state.clone());
self.state = GameState::Night { night };
self.process(HostGameMessage::GetState)
}
(GameState::Day { village, marked }, HostGameMessage::GetState) => {
if let Some(outcome) = village.is_game_over() {
return Ok(ServerToHostMessage::GameOver(outcome));
}
Ok(ServerToHostMessage::Daytime {
marked: marked.clone().into_boxed_slice(),
characters: village
.characters()
.into_iter()
.map(|c| CharacterState {
player_id: c.player_id().clone(),
character_id: c.character_id().clone(),
public_identity: c.public_identity().clone(),
role: c.role().title(),
died_to: c.died_to().cloned(),
})
.collect(),
day: match village.date_time() {
DateTime::Day { number } => number,
DateTime::Night { number: _ } => unreachable!(),
},
})
}
(GameState::Night { night }, HostGameMessage::GetState) => {
if let Some(res) = night.current_result() {
let char = night.current_character().unwrap();
return Ok(ServerToHostMessage::ActionResult(
char.public_identity().clone(),
res.clone(),
));
}
if let Some(prompt) = night.current_prompt() {
let char = night.current_character().unwrap();
return Ok(ServerToHostMessage::ActionPrompt(
char.public_identity().clone(),
prompt.clone(),
));
}
match night.next() {
Ok(_) => self.process(HostGameMessage::GetState),
Err(GameError::NightOver) => {
let village = night.collect_completed()?;
self.previous.push(self.state.clone());
self.state = GameState::Day {
village,
marked: Vec::new(),
};
self.process(HostGameMessage::GetState)
}
Err(err) => Err(err),
}
}
(
GameState::Night { night },
HostGameMessage::Night(HostNightMessage::ActionResponse(resp)),
) => match night.received_response(resp.clone()) {
Ok(res) => Ok(ServerToHostMessage::ActionResult(
night.current_character().unwrap().public_identity().clone(),
res,
)),
Err(GameError::NightNeedsNext) => match night.next() {
Ok(_) => self.process(HostGameMessage::Night(
HostNightMessage::ActionResponse(resp),
)),
Err(GameError::NightOver) => {
// since the block handling HostGameMessage::GetState for night
// already manages the NightOver state, just invoke it
self.process(HostGameMessage::GetState)
}
Err(err) => Err(err),
},
Err(err) => Err(err),
},
(GameState::Night { night: _ }, HostGameMessage::Day(_))
| (
GameState::Day {
village: _,
marked: _,
},
HostGameMessage::Night(_),
) => Err(GameError::InvalidMessageForGameState),
}
}
pub fn game_over(&self) -> Option<GameOver> {
self.state.game_over()
}
pub fn game_state(&self) -> &GameState {
&self.state
}
pub fn previous_game_states(&self) -> &[GameState] {
&self.previous
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GameState {
Day {
village: Village,
marked: Vec<CharacterId>,
},
Night {
night: Night,
},
}
impl GameState {
pub fn game_over(&self) -> Option<GameOver> {
match self {
GameState::Day { village, marked: _ } => village.is_game_over(),
GameState::Night { night: _ } => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum GameOver {
VillageWins,
WolvesWin,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pool<T>
where
T: Debug + Clone,
{
pub pool: Vec<T>,
pub range: Range<u8>,
}
impl<T> Pool<T>
where
T: Debug + Clone,
{
pub const fn new(pool: Vec<T>, range: Range<u8>) -> Self {
Self { pool, range }
}
pub fn collapse(mut self, rng: &mut impl Rng, max: u8) -> Vec<T> {
let range = match self.range.end_bound() {
core::ops::Bound::Included(end) => {
if max < *end {
self.range.start..max + 1
} else {
self.range.clone()
}
}
core::ops::Bound::Excluded(end) => {
if max <= *end {
self.range.start..max + 1
} else {
self.range.clone()
}
}
core::ops::Bound::Unbounded => self.range.start..max + 1,
};
let count = rng.random_range(range);
self.pool.shuffle(rng);
self.pool.truncate(count as _);
self.pool
}
}
impl<T> Deref for Pool<T>
where
T: Debug + Clone,
{
type Target = [T];
fn deref(&self) -> &Self::Target {
&self.pool
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Maybe {
Yes,
No,
Maybe,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum DateTime {
Day { number: NonZeroU8 },
Night { number: u8 },
}
impl Default for DateTime {
fn default() -> Self {
DateTime::Day {
number: NonZeroU8::new(1).unwrap(),
}
}
}
impl DateTime {
pub const fn is_day(&self) -> bool {
matches!(self, DateTime::Day { number: _ })
}
pub const fn is_night(&self) -> bool {
matches!(self, DateTime::Night { number: _ })
}
pub const fn next(self) -> Self {
match self {
DateTime::Day { number } => DateTime::Night {
number: number.get(),
},
DateTime::Night { number } => DateTime::Day {
number: NonZeroU8::new(number + 1).unwrap(),
},
}
}
}

View File

@ -0,0 +1,964 @@
use core::{num::NonZeroU8, ops::Not};
use std::collections::VecDeque;
use serde::{Deserialize, Serialize};
use werewolves_macros::Extract;
use super::Result;
use crate::{
diedto::DiedTo,
error::GameError,
game::{DateTime, Village},
message::night::{ActionPrompt, ActionResponse, ActionResult},
player::{Character, CharacterId, Protection},
role::{PreviousGuardianAction, Role, RoleBlock, RoleTitle},
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Night {
village: Village,
night: u8,
action_queue: VecDeque<(ActionPrompt, Character)>,
changes: Vec<NightChange>,
night_state: NightState,
}
#[derive(Debug, Clone, Serialize, Deserialize, Extract)]
pub enum NightChange {
RoleChange(CharacterId, RoleTitle),
HunterTarget {
source: CharacterId,
target: CharacterId,
},
Kill {
target: CharacterId,
died_to: DiedTo,
},
RoleBlock {
source: CharacterId,
target: CharacterId,
block_type: RoleBlock,
},
Shapeshift {
source: CharacterId,
},
Protection {
target: CharacterId,
protection: Protection,
},
}
struct ResponseOutcome {
pub result: ActionResult,
pub change: Option<NightChange>,
pub unless: Option<Unless>,
}
enum Unless {
TargetBlocked(CharacterId),
TargetsBlocked(CharacterId, CharacterId),
}
impl From<Unless> for ActionResult {
fn from(value: Unless) -> Self {
match value {
Unless::TargetBlocked(_) | Unless::TargetsBlocked(_, _) => ActionResult::RoleBlocked,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(clippy::large_enum_variant)]
enum NightState {
Active {
current_prompt: ActionPrompt,
current_char: CharacterId,
current_result: Option<ActionResult>,
},
Complete,
}
impl Night {
pub fn new(village: Village) -> Result<Self> {
let night = match village.date_time() {
DateTime::Day { number: _ } => return Err(GameError::NotNight),
DateTime::Night { number } => number,
};
let mut action_queue = village
.characters()
.into_iter()
.map(|c| c.night_action_prompt(&village).map(|prompt| (prompt, c)))
.collect::<Result<Box<[_]>>>()?
.into_iter()
.filter_map(|(prompt, char)| prompt.map(|p| (p, char)))
.collect::<Vec<_>>();
action_queue.sort_by(|(left_prompt, _), (right_prompt, _)| {
left_prompt
.partial_cmp(right_prompt)
.unwrap_or(core::cmp::Ordering::Equal)
});
let mut action_queue = VecDeque::from(action_queue);
let (current_prompt, current_char) = if night == 0 {
(
ActionPrompt::WolvesIntro {
wolves: village
.living_wolf_pack_players()
.into_iter()
.map(|w| (w.target(), w.role().title()))
.collect(),
},
village
.living_wolf_pack_players()
.into_iter()
.next()
.unwrap()
.character_id()
.clone(),
)
} else {
action_queue
.pop_front()
.map(|(p, c)| (p, c.character_id().clone()))
.ok_or(GameError::NoNightActions)?
};
let night_state = NightState::Active {
current_char,
current_prompt,
current_result: None,
};
let mut changes = Vec::new();
if let Some(night_nz) = NonZeroU8::new(night) {
// TODO: prob should be an end-of-night thing
changes = village
.dead_characters()
.into_iter()
.filter_map(|c| c.died_to().map(|d| (c, d)))
.filter_map(|(c, d)| match c.role() {
Role::Hunter { target } => target.clone().map(|t| (c, t, d)),
_ => None,
})
.filter_map(|(c, t, d)| match d.date_time() {
DateTime::Day { number } => (number.get() == night).then_some((c, t)),
DateTime::Night { number: _ } => None,
})
.map(|(c, target)| NightChange::Kill {
target,
died_to: DiedTo::Hunter {
killer: c.character_id().clone(),
night: night_nz,
},
})
.collect();
}
Ok(Self {
night,
changes,
village,
night_state,
action_queue,
})
}
pub fn collect_completed(&self) -> Result<Village> {
if !matches!(self.night_state, NightState::Complete) {
return Err(GameError::NotEndOfNight);
}
let mut new_village = self.village.clone();
let changes = ChangesLookup(&self.changes);
for change in self.changes.iter() {
match change {
NightChange::RoleChange(character_id, role_title) => new_village
.character_by_id_mut(character_id)
.unwrap()
.role_change(*role_title, DateTime::Night { number: self.night })?,
NightChange::HunterTarget { source, target } => {
if let Role::Hunter { target: t } =
new_village.character_by_id_mut(source).unwrap().role_mut()
{
t.replace(target.clone());
}
if changes.killed(source).is_some()
&& changes.protected(source).is_none()
&& changes.protected(target).is_none()
{
new_village
.character_by_id_mut(target)
.unwrap()
.kill(DiedTo::Hunter {
killer: source.clone(),
night: NonZeroU8::new(self.night).unwrap(),
})
}
}
NightChange::Kill { target, died_to } => {
if let DiedTo::MapleWolf {
source,
night,
starves_if_fails: true,
} = died_to
&& changes.protected(target).is_some()
{
// kill maple first, then act as if they get their kill attempt
new_village
.character_by_id_mut(source)
.unwrap()
.kill(DiedTo::MapleWolfStarved { night: *night });
}
if let Some(prot) = changes.protected(target) {
match prot {
Protection::Guardian {
source,
guarding: true,
} => {
let kill_source = match died_to {
DiedTo::MapleWolfStarved { night } => {
new_village
.character_by_id_mut(target)
.unwrap()
.kill(DiedTo::MapleWolfStarved { night: *night });
continue;
}
DiedTo::Execution { day: _ } => unreachable!(),
DiedTo::MapleWolf {
source,
night: _,
starves_if_fails: _,
}
| DiedTo::Militia {
killer: source,
night: _,
}
| DiedTo::AlphaWolf {
killer: source,
night: _,
}
| DiedTo::Hunter {
killer: source,
night: _,
} => source.clone(),
DiedTo::Wolfpack { night: _ } => {
if let Some(wolf_to_kill) = new_village
.living_wolf_pack_players()
.into_iter()
.find(|w| matches!(w.role(), Role::Werewolf))
.map(|w| w.character_id().clone())
.or_else(|| {
new_village
.living_wolf_pack_players()
.into_iter()
.next()
.map(|w| w.character_id().clone())
})
{
wolf_to_kill
} else {
// No wolves? Game over?
continue;
}
}
DiedTo::Shapeshift { into: _, night: _ } => target.clone(),
DiedTo::Guardian {
killer: _,
night: _,
} => continue,
};
new_village.character_by_id_mut(&kill_source).unwrap().kill(
DiedTo::Guardian {
killer: source.clone(),
night: NonZeroU8::new(self.night).unwrap(),
},
);
new_village.character_by_id_mut(source).unwrap().kill(
DiedTo::Wolfpack {
night: NonZeroU8::new(self.night).unwrap(),
},
);
continue;
}
Protection::Guardian {
source: _,
guarding: false,
} => continue,
Protection::Protector { source: _ } => continue,
}
}
new_village
.character_by_id_mut(target)
.unwrap()
.kill(DiedTo::Wolfpack {
night: NonZeroU8::new(self.night).unwrap(),
});
}
NightChange::Shapeshift { source } => {
// TODO: shapeshift should probably notify immediately after it happens
if let Some(target) = changes.wolf_pack_kill_target()
&& changes.protected(target).is_none()
{
let ss = new_village.character_by_id_mut(source).unwrap();
match ss.role_mut() {
Role::Shapeshifter { shifted_into } => {
*shifted_into = Some(target.clone())
}
_ => unreachable!(),
}
ss.kill(DiedTo::Shapeshift {
into: target.clone(),
night: NonZeroU8::new(self.night).unwrap(),
});
let target = new_village.find_by_character_id_mut(target).unwrap();
target
.role_change(
RoleTitle::Werewolf,
DateTime::Night { number: self.night },
)
.unwrap();
}
}
NightChange::RoleBlock {
source: _,
target: _,
block_type: _,
}
| NightChange::Protection {
target: _,
protection: _,
} => {}
}
}
new_village.to_day()?;
Ok(new_village)
}
pub fn received_response(&mut self, resp: ActionResponse) -> Result<ActionResult> {
if let (
NightState::Active {
current_prompt: ActionPrompt::WolvesIntro { wolves: _ },
current_char: _,
current_result,
},
ActionResponse::WolvesIntroAck,
) = (&mut self.night_state, &resp)
{
*current_result = Some(ActionResult::WolvesIntroDone);
return Ok(ActionResult::WolvesIntroDone);
}
match self.received_response_with_role_blocks(resp) {
Ok((result, Some(change))) => {
match &mut self.night_state {
NightState::Active {
current_prompt: _,
current_char: _,
current_result,
} => current_result.replace(result.clone()),
NightState::Complete => return Err(GameError::NightOver),
};
self.changes.push(change);
Ok(result)
}
Ok((result, None)) => {
match &mut self.night_state {
NightState::Active {
current_prompt: _,
current_char: _,
current_result,
} => {
current_result.replace(result.clone());
}
NightState::Complete => return Err(GameError::NightOver),
};
Ok(result)
}
Err(err) => Err(err),
}
}
fn received_response_with_role_blocks(
&self,
resp: ActionResponse,
) -> Result<(ActionResult, Option<NightChange>)> {
match self.received_response_inner(resp) {
Ok(ResponseOutcome {
result,
change,
unless: Some(Unless::TargetBlocked(unless_blocked)),
}) => {
if self.changes.iter().any(|c| match c {
NightChange::RoleBlock {
source: _,
target,
block_type: _,
} => target == &unless_blocked,
_ => false,
}) {
Ok((ActionResult::RoleBlocked, None))
} else {
Ok((result, change))
}
}
Ok(ResponseOutcome {
result,
change,
unless: Some(Unless::TargetsBlocked(unless_blocked1, unless_blocked2)),
}) => {
if self.changes.iter().any(|c| match c {
NightChange::RoleBlock {
source: _,
target,
block_type: _,
} => target == &unless_blocked1 || target == &unless_blocked2,
_ => false,
}) {
Ok((ActionResult::RoleBlocked, None))
} else {
Ok((result, change))
}
}
Ok(ResponseOutcome {
result,
change,
unless: None,
}) => Ok((result, change)),
Err(err) => Err(err),
}
}
fn received_response_inner(&self, resp: ActionResponse) -> Result<ResponseOutcome> {
let (current_prompt, current_char) = match &self.night_state {
NightState::Active {
current_prompt: _,
current_char: _,
current_result: Some(_),
} => return Err(GameError::NightNeedsNext),
NightState::Active {
current_prompt,
current_char,
current_result: None,
} => (current_prompt, current_char),
NightState::Complete => return Err(GameError::NightOver),
};
match (current_prompt, resp) {
(ActionPrompt::RoleChange { new_role }, ActionResponse::RoleChangeAck) => {
Ok(ResponseOutcome {
result: ActionResult::GoBackToSleep,
change: Some(NightChange::RoleChange(current_char.clone(), *new_role)),
unless: None,
})
}
(ActionPrompt::Seer { living_players }, ActionResponse::Seer(target)) => {
if !living_players.iter().any(|p| p.character_id == target) {
return Err(GameError::InvalidTarget);
}
Ok(ResponseOutcome {
result: ActionResult::Seer(
self.village
.character_by_id(&target)
.unwrap()
.role()
.alignment(),
),
change: None,
unless: Some(Unless::TargetBlocked(target)),
})
}
(
ActionPrompt::Arcanist { living_players },
ActionResponse::Arcanist(target1, target2),
) => {
if !(living_players.iter().any(|p| p.character_id == target1)
&& living_players.iter().any(|p| p.character_id == target2))
{
return Err(GameError::InvalidTarget);
}
let target1_align = self
.village
.character_by_id(&target1)
.unwrap()
.role()
.alignment();
let target2_align = self
.village
.character_by_id(&target2)
.unwrap()
.role()
.alignment();
Ok(ResponseOutcome {
result: ActionResult::Arcanist {
same: target1_align == target2_align,
},
change: None,
unless: Some(Unless::TargetsBlocked(target1, target2)),
})
}
(ActionPrompt::Gravedigger { dead_players }, ActionResponse::Gravedigger(target)) => {
if !dead_players.iter().any(|p| p.character_id == target) {
return Err(GameError::InvalidTarget);
}
let target_role = self.village.character_by_id(&target).unwrap().role();
Ok(ResponseOutcome {
result: ActionResult::GraveDigger(
matches!(
target_role,
Role::Shapeshifter {
shifted_into: Some(_)
}
)
.not()
.then(|| target_role.title()),
),
change: None,
unless: Some(Unless::TargetBlocked(target)),
})
}
(
ActionPrompt::Hunter {
current_target: _,
living_players,
},
ActionResponse::Hunter(target),
) => {
if !living_players.iter().any(|p| p.character_id == target) {
return Err(GameError::InvalidTarget);
}
Ok(ResponseOutcome {
result: ActionResult::GoBackToSleep,
change: Some(NightChange::HunterTarget {
target: target.clone(),
source: current_char.clone(),
}),
unless: Some(Unless::TargetBlocked(target)),
})
}
(ActionPrompt::Militia { living_players }, ActionResponse::Militia(Some(target))) => {
if !living_players.iter().any(|p| p.character_id == target) {
return Err(GameError::InvalidTarget);
}
let night = if let Some(night) = NonZeroU8::new(self.night) {
night
} else {
return Ok(ResponseOutcome {
result: ActionResult::GoBackToSleep,
change: None,
unless: None,
});
};
Ok(ResponseOutcome {
result: ActionResult::GoBackToSleep,
change: Some(NightChange::Kill {
target: target.clone(),
died_to: DiedTo::Militia {
night,
killer: current_char.clone(),
},
}),
unless: Some(Unless::TargetBlocked(target)),
})
}
(ActionPrompt::Militia { living_players: _ }, ActionResponse::Militia(None)) => {
Ok(ResponseOutcome {
result: ActionResult::GoBackToSleep,
change: None,
unless: None,
})
}
(
ActionPrompt::MapleWolf {
kill_or_die,
living_players,
},
ActionResponse::MapleWolf(Some(target)),
) => {
if !living_players.iter().any(|p| p.character_id == target) {
return Err(GameError::InvalidTarget);
}
let night = if let Some(night) = NonZeroU8::new(self.night) {
night
} else {
return Ok(ResponseOutcome {
result: ActionResult::GoBackToSleep,
change: None,
unless: None,
});
};
Ok(ResponseOutcome {
result: ActionResult::GoBackToSleep,
change: Some(NightChange::Kill {
target: target.clone(),
died_to: DiedTo::MapleWolf {
night,
source: current_char.clone(),
starves_if_fails: *kill_or_die,
},
}),
unless: Some(Unless::TargetBlocked(target)),
})
}
(
ActionPrompt::MapleWolf {
kill_or_die: true,
living_players: _,
},
ActionResponse::MapleWolf(None),
) => {
let night = if let Some(night) = NonZeroU8::new(self.night) {
night
} else {
return Ok(ResponseOutcome {
result: ActionResult::GoBackToSleep,
change: None,
unless: None,
});
};
Ok(ResponseOutcome {
result: ActionResult::GoBackToSleep,
change: Some(NightChange::Kill {
target: current_char.clone(),
died_to: DiedTo::MapleWolfStarved { night },
}),
unless: None,
})
}
(
ActionPrompt::MapleWolf {
kill_or_die: false,
living_players: _,
},
ActionResponse::MapleWolf(None),
) => Ok(ResponseOutcome {
result: ActionResult::GoBackToSleep,
change: None,
unless: None,
}),
(
ActionPrompt::Guardian {
previous: Some(previous),
living_players,
},
ActionResponse::Guardian(target),
) => {
if !living_players.iter().any(|p| p.character_id == target) {
return Err(GameError::InvalidTarget);
}
let guarding = match previous {
PreviousGuardianAction::Protect(prev_target) => {
prev_target.character_id == target
}
PreviousGuardianAction::Guard(prev_target) => {
if prev_target.character_id == target {
return Err(GameError::InvalidTarget);
} else {
false
}
}
};
Ok(ResponseOutcome {
result: ActionResult::GoBackToSleep,
change: Some(NightChange::Protection {
target: target.clone(),
protection: Protection::Guardian {
source: current_char.clone(),
guarding,
},
}),
unless: Some(Unless::TargetBlocked(target)),
})
}
(
ActionPrompt::Guardian {
previous: None,
living_players,
},
ActionResponse::Guardian(target),
) => {
if !living_players.iter().any(|p| p.character_id == target) {
return Err(GameError::InvalidTarget);
}
Ok(ResponseOutcome {
result: ActionResult::GoBackToSleep,
change: Some(NightChange::Protection {
target: target.clone(),
protection: Protection::Guardian {
source: current_char.clone(),
guarding: false,
},
}),
unless: Some(Unless::TargetBlocked(target)),
})
}
(
ActionPrompt::WolfPackKill { living_villagers },
ActionResponse::WolfPackKillVote(target),
) => {
let night = match NonZeroU8::new(self.night) {
Some(night) => night,
None => {
return Ok(ResponseOutcome {
result: ActionResult::GoBackToSleep,
change: None,
unless: None,
});
}
};
if !living_villagers.iter().any(|p| p.character_id == target) {
return Err(GameError::InvalidTarget);
}
Ok(ResponseOutcome {
result: ActionResult::GoBackToSleep,
change: Some(NightChange::Kill {
target: target.clone(),
died_to: DiedTo::Wolfpack { night },
}),
unless: Some(Unless::TargetBlocked(target)),
})
}
(ActionPrompt::Shapeshifter, ActionResponse::Shapeshifter(true)) => {
Ok(ResponseOutcome {
result: ActionResult::GoBackToSleep,
change: Some(NightChange::Shapeshift {
source: current_char.clone(),
}),
unless: None,
})
}
(
ActionPrompt::AlphaWolf {
living_villagers: _,
},
ActionResponse::AlphaWolf(None),
)
| (ActionPrompt::Shapeshifter, ActionResponse::Shapeshifter(false)) => {
Ok(ResponseOutcome {
result: ActionResult::GoBackToSleep,
change: None,
unless: None,
})
}
(
ActionPrompt::AlphaWolf { living_villagers },
ActionResponse::AlphaWolf(Some(target)),
) => {
let night = match NonZeroU8::new(self.night) {
Some(night) => night,
None => {
return Ok(ResponseOutcome {
result: ActionResult::GoBackToSleep,
change: None,
unless: None,
});
}
};
if !living_villagers.iter().any(|p| p.character_id == target) {
return Err(GameError::InvalidTarget);
}
Ok(ResponseOutcome {
result: ActionResult::GoBackToSleep,
change: Some(NightChange::Kill {
target: target.clone(),
died_to: DiedTo::AlphaWolf {
killer: current_char.clone(),
night,
},
}),
unless: Some(Unless::TargetBlocked(target)),
})
}
(ActionPrompt::DireWolf { living_players }, ActionResponse::Direwolf(target)) => {
if !living_players.iter().any(|p| p.character_id == target) {
return Err(GameError::InvalidTarget);
}
Ok(ResponseOutcome {
result: ActionResult::GoBackToSleep,
change: Some(NightChange::RoleBlock {
source: current_char.clone(),
target,
block_type: RoleBlock::Direwolf,
}),
unless: None,
})
}
(ActionPrompt::Protector { targets }, ActionResponse::Protector(target)) => {
if !targets.iter().any(|p| p.character_id == target) {
return Err(GameError::InvalidTarget);
}
Ok(ResponseOutcome {
result: ActionResult::GoBackToSleep,
change: Some(NightChange::Protection {
target: target.clone(),
protection: Protection::Protector {
source: current_char.clone(),
},
}),
unless: Some(Unless::TargetBlocked(target)),
})
}
// For other responses that are invalid -- this allows the match to error
// if a new prompt is added
(ActionPrompt::RoleChange { new_role: _ }, _)
| (ActionPrompt::Seer { living_players: _ }, _)
| (ActionPrompt::Protector { targets: _ }, _)
| (ActionPrompt::Arcanist { living_players: _ }, _)
| (ActionPrompt::Gravedigger { dead_players: _ }, _)
| (
ActionPrompt::Hunter {
current_target: _,
living_players: _,
},
_,
)
| (ActionPrompt::Militia { living_players: _ }, _)
| (
ActionPrompt::MapleWolf {
kill_or_die: _,
living_players: _,
},
_,
)
| (
ActionPrompt::Guardian {
previous: _,
living_players: _,
},
_,
)
| (
ActionPrompt::WolfPackKill {
living_villagers: _,
},
_,
)
| (ActionPrompt::Shapeshifter, _)
| (
ActionPrompt::AlphaWolf {
living_villagers: _,
},
_,
)
| (ActionPrompt::DireWolf { living_players: _ }, _) => {
Err(GameError::InvalidMessageForGameState)
}
(ActionPrompt::WolvesIntro { wolves: _ }, _) => {
Err(GameError::InvalidMessageForGameState)
}
}
}
pub const fn village(&self) -> &Village {
&self.village
}
pub const fn current_result(&self) -> Option<&ActionResult> {
match &self.night_state {
NightState::Active {
current_prompt: _,
current_char: _,
current_result,
} => current_result.as_ref(),
NightState::Complete => None,
}
}
pub const fn current_prompt(&self) -> Option<&ActionPrompt> {
match &self.night_state {
NightState::Active {
current_prompt,
current_char: _,
current_result: _,
} => Some(current_prompt),
NightState::Complete => None,
}
}
pub const fn current_character_id(&self) -> Option<&CharacterId> {
match &self.night_state {
NightState::Active {
current_prompt: _,
current_char,
current_result: _,
} => Some(current_char),
NightState::Complete => None,
}
}
pub fn current_character(&self) -> Option<&Character> {
self.current_character_id()
.and_then(|id| self.village.character_by_id(id))
}
pub const fn complete(&self) -> bool {
matches!(self.night_state, NightState::Complete)
}
pub fn next(&mut self) -> Result<()> {
match &self.night_state {
NightState::Active {
current_prompt: _,
current_char: _,
current_result: Some(_),
} => {}
NightState::Active {
current_prompt: _,
current_char: _,
current_result: None,
} => return Err(GameError::AwaitingResponse),
NightState::Complete => return Err(GameError::NightOver),
}
if let Some((prompt, character)) = self.action_queue.pop_front() {
self.night_state = NightState::Active {
current_prompt: prompt,
current_char: character.character_id().clone(),
current_result: None,
};
} else {
self.night_state = NightState::Complete;
}
Ok(())
}
pub const fn changes(&self) -> &[NightChange] {
self.changes.as_slice()
}
}
struct ChangesLookup<'a>(&'a [NightChange]);
impl<'a> ChangesLookup<'a> {
pub fn killed(&self, target: &CharacterId) -> Option<&'a DiedTo> {
self.0.iter().find_map(|c| match c {
NightChange::Kill { target: t, died_to } => (t == target).then_some(died_to),
_ => None,
})
}
pub fn protected(&self, target: &CharacterId) -> Option<&'a Protection> {
self.0.iter().find_map(|c| match c {
NightChange::Protection {
target: t,
protection,
} => (t == target).then_some(protection),
_ => None,
})
}
pub fn wolf_pack_kill_target(&self) -> Option<&'a CharacterId> {
self.0.iter().find_map(|c| match c {
NightChange::Kill {
target,
died_to: DiedTo::Wolfpack { night: _ },
} => Some(target),
_ => None,
})
}
}

View File

@ -0,0 +1,127 @@
use super::Result;
use core::num::NonZeroU8;
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::{error::GameError, role::RoleTitle};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GameSettings {
roles: HashMap<RoleTitle, NonZeroU8>,
}
impl Default for GameSettings {
fn default() -> Self {
Self {
roles: [
(RoleTitle::Werewolf, NonZeroU8::new(1).unwrap()),
(RoleTitle::Seer, NonZeroU8::new(1).unwrap()),
// (RoleTitle::Militia, NonZeroU8::new(1).unwrap()),
// (RoleTitle::Guardian, NonZeroU8::new(1).unwrap()),
(RoleTitle::Apprentice, NonZeroU8::new(1).unwrap()),
]
.into_iter()
.collect(),
}
}
}
impl GameSettings {
pub fn spread(&self) -> Box<[RoleTitle]> {
self.roles
.iter()
.flat_map(|(r, c)| [*r].repeat(c.get() as _))
.collect()
}
pub fn wolves_count(&self) -> usize {
self.roles
.iter()
.filter_map(|(r, c)| {
if r.wolf() {
Some(c.get() as usize)
} else {
None
}
})
.sum()
}
pub fn village_roles_count(&self) -> usize {
self.roles
.iter()
.filter_map(|(r, c)| {
if !r.wolf() {
Some(c.get() as usize)
} else {
None
}
})
.sum()
}
pub fn roles(&self) -> Box<[(RoleTitle, NonZeroU8)]> {
self.roles.iter().map(|(r, c)| (*r, *c)).collect()
}
pub fn villagers_needed_for_player_count(&self, players: usize) -> Result<usize> {
let min = self.min_players_needed();
if min > players {
return Err(GameError::TooFewPlayers {
got: players as _,
need: min as _,
});
}
Ok(players - self.roles.values().map(|c| c.get() as usize).sum::<usize>())
}
pub fn check(&self) -> Result<()> {
if self.wolves_count() == 0 {
return Err(GameError::NoWolves);
}
if self
.roles
.iter()
.any(|(r, _)| matches!(r, RoleTitle::Apprentice))
&& self.roles.iter().filter(|(r, _)| r.is_mentor()).count() == 0
{
return Err(GameError::NoApprenticeMentor);
}
Ok(())
}
pub fn min_players_needed(&self) -> usize {
let (wolves, villagers) = (self.wolves_count(), self.village_roles_count());
if wolves > villagers {
wolves + 1 + wolves
} else if wolves < villagers {
wolves + villagers
} else {
wolves + villagers + 1
}
}
pub fn add(&mut self, role: RoleTitle) -> Result<()> {
if role == RoleTitle::Villager {
return Err(GameError::CantAddVillagerToSettings);
}
match self.roles.get_mut(&role) {
Some(count) => *count = NonZeroU8::new(count.get() + 1).unwrap(),
None => {
self.roles.insert(role, NonZeroU8::new(1).unwrap());
}
}
Ok(())
}
pub fn sub(&mut self, role: RoleTitle) {
if let Some(count) = self.roles.get_mut(&role)
&& count.get() != 1
{
*count = NonZeroU8::new(count.get() - 1).unwrap();
} else {
self.roles.remove(&role);
}
}
}

View File

@ -0,0 +1,248 @@
use core::num::NonZeroU8;
use rand::{Rng, seq::SliceRandom};
use serde::{Deserialize, Serialize};
use super::Result;
use crate::{
error::GameError,
game::{DateTime, GameOver, GameSettings},
message::{Identification, Target},
player::{Character, CharacterId, PlayerId},
role::{Role, RoleTitle},
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Village {
characters: Vec<Character>,
date_time: DateTime,
}
impl Village {
pub fn new(players: &[Identification], settings: GameSettings) -> Result<Self> {
if settings.min_players_needed() > players.len() {
return Err(GameError::TooManyRoles {
players: players.len() as u8,
roles: settings.min_players_needed() as u8,
});
}
settings.check()?;
let roles_spread = settings.spread();
let potential_apprentice_havers = roles_spread
.iter()
.filter(|r| r.is_mentor())
.map(|r| r.title_to_role_excl_apprentice())
.collect::<Box<[_]>>();
let mut roles = roles_spread
.into_iter()
.chain(
(0..settings.villagers_needed_for_player_count(players.len())?)
.map(|_| RoleTitle::Villager),
)
.map(|title| match title {
RoleTitle::Apprentice => Role::Apprentice(Box::new(
potential_apprentice_havers
[rand::rng().random_range(0..potential_apprentice_havers.len())]
.clone(),
)),
_ => title.title_to_role_excl_apprentice(),
})
.collect::<Box<[_]>>();
assert_eq!(players.len(), roles.len());
roles.shuffle(&mut rand::rng());
Ok(Self {
characters: players
.iter()
.cloned()
.zip(roles)
.map(|(player, role)| Character::new(player, role))
.collect(),
date_time: DateTime::Night { number: 0 },
})
}
pub const fn date_time(&self) -> DateTime {
self.date_time
}
pub fn find_by_character_id(&self, character_id: &CharacterId) -> Option<&Character> {
self.characters
.iter()
.find(|c| c.character_id() == character_id)
}
pub fn find_by_character_id_mut(
&mut self,
character_id: &CharacterId,
) -> Option<&mut Character> {
self.characters
.iter_mut()
.find(|c| c.character_id() == character_id)
}
fn wolves_count(&self) -> usize {
self.characters.iter().filter(|c| c.is_wolf()).count()
}
fn villager_count(&self) -> usize {
self.characters.iter().filter(|c| c.is_village()).count()
}
pub fn is_game_over(&self) -> Option<GameOver> {
let wolves = self.wolves_count();
let villagers = self.villager_count();
if wolves == 0 {
return Some(GameOver::VillageWins);
}
if wolves >= villagers {
return Some(GameOver::WolvesWin);
}
None
}
pub fn execute(&mut self, characters: &[CharacterId]) -> Result<Option<GameOver>> {
let day = match self.date_time {
DateTime::Day { number } => number,
DateTime::Night { number: _ } => return Err(GameError::NoExecutionsAtNight),
};
if characters.is_empty() {
return Err(GameError::NoTrialNotAllowed);
}
let targets = self
.characters
.iter_mut()
.filter(|c| characters.contains(c.character_id()))
.collect::<Box<[_]>>();
if targets.len() != characters.len() {
return Err(GameError::CannotFindTargetButShouldBeThere);
}
for t in targets {
t.execute(day)?;
}
self.date_time = self.date_time.next();
Ok(self.is_game_over())
}
pub fn to_day(&mut self) -> Result<DateTime> {
if self.date_time.is_day() {
return Err(GameError::AlreadyDaytime);
}
self.date_time = self.date_time.next();
Ok(self.date_time)
}
pub fn living_wolf_pack_players(&self) -> Box<[Character]> {
self.characters
.iter()
.filter(|c| c.role().wolf() && c.alive())
.cloned()
.collect()
}
pub fn living_players(&self) -> Box<[Target]> {
self.characters
.iter()
.filter(|c| c.alive())
.map(Character::target)
.collect()
}
pub fn target_by_id(&self, character_id: &CharacterId) -> Option<Target> {
self.character_by_id(character_id).map(Character::target)
}
pub fn living_villagers(&self) -> Box<[Target]> {
self.characters
.iter()
.filter(|c| c.alive() && c.is_village())
.map(Character::target)
.collect()
}
pub fn living_players_excluding(&self, exclude: &CharacterId) -> Box<[Target]> {
self.characters
.iter()
.filter(|c| c.alive() && c.character_id() != exclude)
.map(Character::target)
.collect()
}
pub fn dead_targets(&self) -> Box<[Target]> {
self.characters
.iter()
.filter(|c| !c.alive())
.map(Character::target)
.collect()
}
pub fn dead_characters(&self) -> Box<[&Character]> {
self.characters.iter().filter(|c| !c.alive()).collect()
}
pub fn living_characters_by_role(&self, role: RoleTitle) -> Box<[Character]> {
self.characters
.iter()
.filter(|c| c.role().title() == role)
.cloned()
.collect()
}
pub fn characters(&self) -> Box<[Character]> {
self.characters.iter().cloned().collect()
}
pub fn character_by_id_mut(&mut self, character_id: &CharacterId) -> Option<&mut Character> {
self.characters
.iter_mut()
.find(|c| c.character_id() == character_id)
}
pub fn character_by_id(&self, character_id: &CharacterId) -> Option<&Character> {
self.characters
.iter()
.find(|c| c.character_id() == character_id)
}
pub fn character_by_player_id(&self, player_id: &PlayerId) -> Option<&Character> {
self.characters.iter().find(|c| c.player_id() == player_id)
}
}
impl RoleTitle {
pub fn title_to_role_excl_apprentice(self) -> Role {
match self {
RoleTitle::Villager => Role::Villager,
RoleTitle::Scapegoat => Role::Scapegoat,
RoleTitle::Seer => Role::Seer,
RoleTitle::Arcanist => Role::Arcanist,
RoleTitle::Elder => Role::Elder {
knows_on_night: NonZeroU8::new(rand::rng().random_range(1u8..3)).unwrap(),
},
RoleTitle::Werewolf => Role::Werewolf,
RoleTitle::AlphaWolf => Role::AlphaWolf { killed: None },
RoleTitle::DireWolf => Role::DireWolf,
RoleTitle::Shapeshifter => Role::Shapeshifter { shifted_into: None },
RoleTitle::Apprentice => panic!("title_to_role_excl_apprentice got an apprentice role"),
RoleTitle::Protector => Role::Protector {
last_protected: None,
},
RoleTitle::Gravedigger => Role::Gravedigger,
RoleTitle::Hunter => Role::Hunter { target: None },
RoleTitle::Militia => Role::Militia { targeted: None },
RoleTitle::MapleWolf => Role::MapleWolf {
last_kill_on_night: 0,
},
RoleTitle::Guardian => Role::Guardian {
last_protected: None,
},
}
}
}

View File

@ -0,0 +1,30 @@
#![allow(clippy::new_without_default)]
use error::GameError;
use serde::{Deserialize, Serialize};
use thiserror::Error;
// pub mod action;
pub mod diedto;
pub mod error;
pub mod game;
pub mod message;
pub mod modifier;
pub mod nonzero;
pub mod player;
pub mod role;
#[derive(Debug, Error, Clone, Serialize, Deserialize)]
pub enum MessageError {
#[error("{0}")]
GameError(#[from] GameError),
}
pub(crate) trait MustBeInVillage<T> {
fn must_be_in_village(self) -> Result<T, GameError>;
}
impl<T> MustBeInVillage<T> for Option<T> {
fn must_be_in_village(self) -> Result<T, GameError> {
self.ok_or(GameError::CannotFindTargetButShouldBeThere)
}
}

View File

@ -0,0 +1,79 @@
pub mod host;
mod ident;
pub mod night;
use core::{fmt::Display, num::NonZeroU8};
pub use ident::*;
use night::{ActionPrompt, ActionResponse, ActionResult, RoleChange};
use serde::{Deserialize, Serialize};
use crate::{
error::GameError,
game::GameOver,
player::{Character, CharacterId},
role::RoleTitle,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ClientMessage {
Hello,
Goodbye,
GetState,
RoleAck,
UpdateSelf(UpdateSelf),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum UpdateSelf {
Name(String),
Number(NonZeroU8),
Pronouns(Option<String>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DayCharacter {
pub character_id: CharacterId,
pub name: String,
pub alive: bool,
}
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
pub struct Target {
pub character_id: CharacterId,
pub public: PublicIdentity,
}
impl Display for Target {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Target {
character_id,
public,
} = self;
write!(f, "{public} [(c){character_id}]")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ServerMessage {
Disconnect,
LobbyInfo {
joined: bool,
players: Box<[PublicIdentity]>,
},
GameInProgress,
GameStart {
role: RoleTitle,
},
InvalidMessageForGameState,
NoSuchTarget,
GameOver(GameOver),
Update(PlayerUpdate),
Sleep,
Reset,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PlayerUpdate {
Number(NonZeroU8),
}

View File

@ -0,0 +1,74 @@
use core::num::NonZeroU8;
use serde::{Deserialize, Serialize};
use crate::{
error::GameError,
game::{GameOver, GameSettings},
message::{
PublicIdentity, Target,
night::{ActionPrompt, ActionResponse, ActionResult},
},
player::{CharacterId, PlayerId},
};
use super::{CharacterState, PlayerState};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum HostMessage {
GetState,
Lobby(HostLobbyMessage),
InGame(HostGameMessage),
ForceRoleAckFor(CharacterId),
NewLobby,
Echo(ServerToHostMessage),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum HostGameMessage {
Day(HostDayMessage),
Night(HostNightMessage),
GetState,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum HostNightMessage {
ActionResponse(ActionResponse),
Next,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum HostDayMessage {
Execute,
MarkForExecution(CharacterId),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum HostLobbyMessage {
GetState,
Kick(PlayerId),
GetGameSettings,
SetGameSettings(GameSettings),
Start,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ServerToHostMessage {
Disconnect,
Daytime {
characters: Box<[CharacterState]>,
marked: Box<[CharacterId]>,
day: NonZeroU8,
},
ActionPrompt(PublicIdentity, ActionPrompt),
ActionResult(PublicIdentity, ActionResult),
Lobby(Box<[PlayerState]>),
GameSettings(GameSettings),
Error(GameError),
GameOver(GameOver),
WaitingForRoleRevealAcks {
ackd: Box<[Target]>,
waiting: Box<[Target]>,
},
CoverOfDarkness,
}

View File

@ -0,0 +1,75 @@
use core::{fmt::Display, num::NonZeroU8};
use serde::{Deserialize, Serialize};
use crate::{
diedto::DiedTo,
player::{CharacterId, PlayerId},
role::RoleTitle,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Identification {
pub player_id: PlayerId,
pub public: PublicIdentity,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PublicIdentity {
pub name: String,
pub pronouns: Option<String>,
pub number: NonZeroU8,
}
impl Default for PublicIdentity {
fn default() -> Self {
Self {
name: Default::default(),
pronouns: Default::default(),
number: NonZeroU8::new(1).unwrap(),
}
}
}
impl Display for PublicIdentity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let PublicIdentity {
name,
pronouns,
number,
} = self;
let pronouns = pronouns
.as_ref()
.map(|p| format!(" ({p})"))
.unwrap_or_default();
write!(f, "[{number}] {name}{pronouns}")
}
}
impl core::hash::Hash for Identification {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.player_id.hash(state);
}
}
impl Display for Identification {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Identification { player_id, public } = self;
write!(f, "{public} [{player_id}]")
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PlayerState {
pub identification: Identification,
pub connected: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CharacterState {
pub player_id: PlayerId,
pub character_id: CharacterId,
pub public_identity: PublicIdentity,
pub role: RoleTitle,
pub died_to: Option<DiedTo>,
}

View File

@ -0,0 +1,133 @@
use serde::{Deserialize, Serialize};
use werewolves_macros::ChecksAs;
use crate::{
player::CharacterId,
role::{Alignment, PreviousGuardianAction, Role, RoleTitle},
};
use super::Target;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ActionPrompt {
WolvesIntro {
wolves: Box<[(Target, RoleTitle)]>,
},
RoleChange {
new_role: RoleTitle,
},
Seer {
living_players: Box<[Target]>,
},
Protector {
targets: Box<[Target]>,
},
Arcanist {
living_players: Box<[Target]>,
},
Gravedigger {
dead_players: Box<[Target]>,
},
Hunter {
current_target: Option<Target>,
living_players: Box<[Target]>,
},
Militia {
living_players: Box<[Target]>,
},
MapleWolf {
kill_or_die: bool,
living_players: Box<[Target]>,
},
Guardian {
previous: Option<PreviousGuardianAction>,
living_players: Box<[Target]>,
},
WolfPackKill {
living_villagers: Box<[Target]>,
},
Shapeshifter,
AlphaWolf {
living_villagers: Box<[Target]>,
},
DireWolf {
living_players: Box<[Target]>,
},
}
impl PartialOrd for ActionPrompt {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
fn ordering_num(prompt: &ActionPrompt) -> u8 {
match prompt {
ActionPrompt::WolvesIntro { wolves: _ } => 0,
ActionPrompt::Guardian {
living_players: _,
previous: _,
}
| ActionPrompt::Protector { targets: _ } => 1,
ActionPrompt::WolfPackKill {
living_villagers: _,
} => 2,
ActionPrompt::Shapeshifter => 3,
ActionPrompt::AlphaWolf {
living_villagers: _,
} => 4,
ActionPrompt::DireWolf { living_players: _ } => 5,
ActionPrompt::Seer { living_players: _ }
| ActionPrompt::Arcanist { living_players: _ }
| ActionPrompt::Gravedigger { dead_players: _ }
| ActionPrompt::Hunter {
current_target: _,
living_players: _,
}
| ActionPrompt::Militia { living_players: _ }
| ActionPrompt::MapleWolf {
kill_or_die: _,
living_players: _,
}
| ActionPrompt::RoleChange { new_role: _ } => 0xFF,
}
}
ordering_num(self).partial_cmp(&ordering_num(other))
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Deserialize, ChecksAs)]
pub enum ActionResponse {
Seer(CharacterId),
Arcanist(CharacterId, CharacterId),
Gravedigger(CharacterId),
Hunter(CharacterId),
Militia(Option<CharacterId>),
MapleWolf(Option<CharacterId>),
Guardian(CharacterId),
WolfPackKillVote(CharacterId),
#[checks]
Shapeshifter(bool),
AlphaWolf(Option<CharacterId>),
Direwolf(CharacterId),
Protector(CharacterId),
#[checks]
RoleChangeAck,
WolvesIntroAck,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ActionResult {
RoleBlocked,
Seer(Alignment),
Arcanist { same: bool },
GraveDigger(Option<RoleTitle>),
WolvesMustBeUnanimous,
WaitForOthersToVote,
GoBackToSleep,
RoleRevealDone,
WolvesIntroDone,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RoleChange {
Elder(Role),
Apprentice(Role),
Shapeshift(Role),
}

View File

@ -0,0 +1,7 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Modifier {
Drunk,
Insane,
}

View File

@ -0,0 +1,18 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[repr(transparent)]
pub struct NonZeroSub100U8(u8);
impl NonZeroSub100U8 {
pub const fn new(val: u8) -> Option<Self> {
if val == 0 || val > 99 {
None
} else {
Some(Self(val))
}
}
pub const fn get(&self) -> u8 {
self.0
}
}

View File

@ -0,0 +1,301 @@
use core::{fmt::Display, num::NonZeroU8};
use serde::{Deserialize, Serialize};
use crate::{
diedto::DiedTo,
error::GameError,
game::{DateTime, Village},
message::{Identification, PublicIdentity, Target, night::ActionPrompt},
modifier::Modifier,
role::{MAPLE_WOLF_ABSTAIN_LIMIT, PreviousGuardianAction, Role, RoleTitle},
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct PlayerId(uuid::Uuid);
impl PlayerId {
pub fn new() -> Self {
Self(uuid::Uuid::new_v4())
}
pub const fn from_u128(v: u128) -> Self {
Self(uuid::Uuid::from_u128(v))
}
}
impl Display for PlayerId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct CharacterId(uuid::Uuid);
impl CharacterId {
pub fn new() -> Self {
Self(uuid::Uuid::new_v4())
}
}
impl Display for CharacterId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Player {
id: PlayerId,
name: String,
}
impl Player {
pub fn new(name: String) -> Self {
Self {
id: PlayerId::new(),
name,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Protection {
Guardian { source: CharacterId, guarding: bool },
Protector { source: CharacterId },
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum KillOutcome {
Killed,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Character {
player_id: PlayerId,
character_id: CharacterId,
public: PublicIdentity,
role: Role,
modifier: Option<Modifier>,
died_to: Option<DiedTo>,
role_changes: Vec<RoleChange>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoleChange {
role: Role,
new_role: RoleTitle,
changed_on_night: u8,
}
impl Character {
pub fn new(Identification { player_id, public }: Identification, role: Role) -> Self {
Self {
role,
public,
player_id,
character_id: CharacterId::new(),
modifier: None,
died_to: None,
role_changes: Vec::new(),
}
}
pub fn target(&self) -> Target {
Target {
character_id: self.character_id.clone(),
public: self.public.clone(),
}
}
pub const fn public_identity(&self) -> &PublicIdentity {
&self.public
}
pub fn name(&self) -> &str {
&self.public.name
}
pub const fn number(&self) -> NonZeroU8 {
self.public.number
}
pub const fn pronouns(&self) -> Option<&str> {
match self.public.pronouns.as_ref() {
Some(p) => Some(p.as_str()),
None => None,
}
}
pub fn died_to(&self) -> Option<&DiedTo> {
self.died_to.as_ref()
}
pub fn kill(&mut self, died_to: DiedTo) {
match &self.died_to {
Some(_) => {}
None => self.died_to = Some(died_to),
}
}
pub const fn alive(&self) -> bool {
self.died_to.is_none()
}
pub fn execute(&mut self, day: NonZeroU8) -> Result<(), GameError> {
if self.died_to.is_some() {
return Err(GameError::CharacterAlreadyDead);
}
self.died_to = Some(DiedTo::Execution { day });
Ok(())
}
pub const fn character_id(&self) -> &CharacterId {
&self.character_id
}
pub const fn player_id(&self) -> &PlayerId {
&self.player_id
}
pub const fn role(&self) -> &Role {
&self.role
}
pub const fn role_mut(&mut self) -> &mut Role {
&mut self.role
}
pub fn role_change(&mut self, new_role: RoleTitle, at: DateTime) -> Result<(), GameError> {
let mut role = new_role.title_to_role_excl_apprentice();
core::mem::swap(&mut role, &mut self.role);
self.role_changes.push(RoleChange {
role,
new_role,
changed_on_night: match at {
DateTime::Day { number: _ } => return Err(GameError::NotNight),
DateTime::Night { number } => number,
},
});
Ok(())
}
pub const fn is_wolf(&self) -> bool {
self.role.wolf()
}
pub const fn is_village(&self) -> bool {
!self.is_wolf()
}
pub fn night_action_prompt(
&self,
village: &Village,
) -> Result<Option<ActionPrompt>, GameError> {
if !self.alive() || !self.role.wakes(village) {
return Ok(None);
}
let night = match village.date_time() {
DateTime::Day { number: _ } => return Err(GameError::NotNight),
DateTime::Night { number } => number,
};
Ok(Some(match &self.role {
Role::Shapeshifter {
shifted_into: Some(_),
}
| Role::AlphaWolf { killed: Some(_) }
| Role::Militia { targeted: Some(_) }
| Role::Scapegoat
| Role::Villager => return Ok(None),
Role::Seer => ActionPrompt::Seer {
living_players: village.living_players_excluding(&self.character_id),
},
Role::Arcanist => ActionPrompt::Arcanist {
living_players: village.living_players_excluding(&self.character_id),
},
Role::Protector {
last_protected: Some(last_protected),
} => ActionPrompt::Protector {
targets: village.living_players_excluding(last_protected),
},
Role::Protector {
last_protected: None,
} => ActionPrompt::Protector {
targets: village.living_players_excluding(&self.character_id),
},
Role::Apprentice(role) => {
let current_night = match village.date_time() {
DateTime::Day { number: _ } => return Ok(None),
DateTime::Night { number } => number,
};
return Ok(village
.characters()
.into_iter()
.filter(|c| c.role().title() == role.title())
.filter_map(|char| char.died_to)
.any(|died_to| match died_to.date_time() {
DateTime::Day { number } => number.get() + 1 >= current_night,
DateTime::Night { number } => number + 1 >= current_night,
})
.then(|| ActionPrompt::RoleChange {
new_role: role.title(),
}));
}
Role::Elder { knows_on_night } => {
let current_night = match village.date_time() {
DateTime::Day { number: _ } => return Ok(None),
DateTime::Night { number } => number,
};
return Ok((current_night == knows_on_night.get()).then_some({
ActionPrompt::RoleChange {
new_role: RoleTitle::Elder,
}
}));
}
Role::Militia { targeted: None } => ActionPrompt::Militia {
living_players: village.living_players_excluding(&self.character_id),
},
Role::Werewolf => ActionPrompt::WolfPackKill {
living_villagers: village.living_players(),
},
Role::AlphaWolf { killed: None } => ActionPrompt::AlphaWolf {
living_villagers: village.living_players_excluding(&self.character_id),
},
Role::DireWolf => ActionPrompt::DireWolf {
living_players: village.living_players(),
},
Role::Shapeshifter { shifted_into: None } => ActionPrompt::Shapeshifter,
Role::Gravedigger => ActionPrompt::Gravedigger {
dead_players: village.dead_targets(),
},
Role::Hunter { target } => ActionPrompt::Hunter {
current_target: target.as_ref().and_then(|t| village.target_by_id(t)),
living_players: village.living_players_excluding(&self.character_id),
},
Role::MapleWolf { last_kill_on_night } => ActionPrompt::MapleWolf {
kill_or_die: last_kill_on_night + MAPLE_WOLF_ABSTAIN_LIMIT.get() == night,
living_players: village.living_players_excluding(&self.character_id),
},
Role::Guardian {
last_protected: Some(PreviousGuardianAction::Guard(prev_target)),
} => ActionPrompt::Guardian {
previous: Some(PreviousGuardianAction::Guard(prev_target.clone())),
living_players: village.living_players_excluding(&prev_target.character_id),
},
Role::Guardian {
last_protected: Some(PreviousGuardianAction::Protect(prev_target)),
} => ActionPrompt::Guardian {
previous: Some(PreviousGuardianAction::Protect(prev_target.clone())),
living_players: village.living_players(),
},
Role::Guardian {
last_protected: None,
} => ActionPrompt::Guardian {
previous: None,
living_players: village.living_players(),
},
}))
}
}

View File

@ -0,0 +1,183 @@
use core::num::NonZeroU8;
use serde::{Deserialize, Serialize};
use werewolves_macros::{ChecksAs, Titles};
use crate::{
game::{DateTime, Village},
message::Target,
player::CharacterId,
};
#[derive(Debug, Clone, Serialize, Deserialize, ChecksAs, Titles)]
pub enum Role {
#[checks(Alignment::Village)]
Villager,
#[checks(Alignment::Wolves)]
#[checks("killer")]
#[checks("powerful")]
Scapegoat,
#[checks(Alignment::Village)]
#[checks("powerful")]
#[checks("is_mentor")]
Seer,
#[checks(Alignment::Village)]
#[checks("powerful")]
#[checks("is_mentor")]
Arcanist,
#[checks(Alignment::Village)]
#[checks("powerful")]
#[checks("is_mentor")]
Gravedigger,
#[checks(Alignment::Village)]
#[checks("killer")]
#[checks("powerful")]
#[checks("is_mentor")]
#[checks]
Hunter { target: Option<CharacterId> },
#[checks(Alignment::Village)]
#[checks("killer")]
#[checks("powerful")]
#[checks("is_mentor")]
Militia { targeted: Option<CharacterId> },
#[checks(Alignment::Wolves)]
#[checks("killer")]
#[checks("powerful")]
#[checks("is_mentor")]
MapleWolf { last_kill_on_night: u8 },
#[checks(Alignment::Village)]
#[checks("powerful")]
#[checks("killer")]
#[checks("is_mentor")]
Guardian {
last_protected: Option<PreviousGuardianAction>,
},
#[checks(Alignment::Village)]
#[checks("powerful")]
#[checks("is_mentor")]
Protector { last_protected: Option<CharacterId> },
#[checks(Alignment::Village)]
#[checks("powerful")]
Apprentice(Box<Role>),
#[checks(Alignment::Village)]
#[checks("powerful")]
#[checks("is_mentor")]
Elder { knows_on_night: NonZeroU8 },
#[checks(Alignment::Wolves)]
#[checks("killer")]
#[checks("powerful")]
#[checks("wolf")]
Werewolf,
#[checks(Alignment::Wolves)]
#[checks("killer")]
#[checks("powerful")]
#[checks("wolf")]
AlphaWolf { killed: Option<CharacterId> },
#[checks(Alignment::Village)]
#[checks("killer")]
#[checks("powerful")]
#[checks("wolf")]
DireWolf,
#[checks(Alignment::Wolves)]
#[checks("killer")]
#[checks("powerful")]
#[checks("wolf")]
Shapeshifter { shifted_into: Option<CharacterId> },
}
impl Role {
/// [RoleTitle] as shown to the player on role assignment
pub const fn initial_shown_role(&self) -> RoleTitle {
match self {
Role::Apprentice(_) | Role::Elder { knows_on_night: _ } => RoleTitle::Villager,
_ => self.title(),
}
}
pub fn wakes(&self, village: &Village) -> bool {
let night_zero = match village.date_time() {
DateTime::Day { number: _ } => return false,
DateTime::Night { number } => number == 0,
};
if night_zero {
return match self {
Role::DireWolf | Role::Arcanist | Role::Seer => true,
Role::Shapeshifter { shifted_into: _ }
| Role::Werewolf
| Role::AlphaWolf { killed: _ }
| Role::Elder { knows_on_night: _ }
| Role::Gravedigger
| Role::Hunter { target: _ }
| Role::Militia { targeted: _ }
| Role::MapleWolf {
last_kill_on_night: _,
}
| Role::Guardian { last_protected: _ }
| Role::Apprentice(_)
| Role::Villager
| Role::Scapegoat
| Role::Protector { last_protected: _ } => false,
};
}
match self {
Role::AlphaWolf { killed: Some(_) }
| Role::Werewolf
| Role::Scapegoat
| Role::Militia { targeted: Some(_) }
| Role::Villager => false,
Role::Shapeshifter { shifted_into: _ }
| Role::DireWolf
| Role::AlphaWolf { killed: None }
| Role::Arcanist
| Role::Protector { last_protected: _ }
| Role::Gravedigger
| Role::Hunter { target: _ }
| Role::Militia { targeted: None }
| Role::MapleWolf {
last_kill_on_night: _,
}
| Role::Guardian { last_protected: _ }
| Role::Seer => true,
Role::Apprentice(role) => village
.characters()
.iter()
.any(|c| c.role().title() == role.title()),
Role::Elder { knows_on_night } => match village.date_time() {
DateTime::Night { number } => number == knows_on_night.get(),
_ => false,
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum Alignment {
Village,
Wolves,
}
#[derive(Debug, Clone, Serialize, Deserialize, ChecksAs)]
pub enum ArcanistCheck {
#[checks]
Same,
#[checks]
Different,
}
pub const MAPLE_WOLF_ABSTAIN_LIMIT: NonZeroU8 = NonZeroU8::new(3).unwrap();
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RoleBlock {
Direwolf,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum PreviousGuardianAction {
Protect(Target),
Guard(Target),
}

View File

@ -0,0 +1,29 @@
[package]
name = "werewolves-server"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = { version = "0.8", features = ["ws"] }
tokio = { version = "1.44", features = ["full"] }
log = { version = "0.4" }
pretty_env_logger = { version = "0.5" }
# env_logger = { version = "0.11" }
futures = "0.3.31"
anyhow = { version = "1" }
werewolves-proto = { path = "../werewolves-proto" }
werewolves-macros = { path = "../werewolves-macros" }
mime-sniffer = { version = "0.1" }
chrono = { version = "0.4" }
atom_syndication = { version = "0.12" }
axum-extra = { version = "0.10", features = ["typed-header"] }
rand = { version = "0.9" }
serde_json = { version = "1.0" }
serde = { version = "1.0", features = ["derive"] }
thiserror = { version = "2" }
ciborium = { version = "0.2", optional = true }
colored = { version = "3.0" }
[features]
# default = ["cbor"]
cbor = ["dep:ciborium"]

View File

@ -0,0 +1,19 @@
[Unit]
Description=blog
After=network.target
[Service]
Type=simple
User=blog
Group=blog
WorkingDirectory=/home/blog
Environment=RUST_LOG=info
Environment=PORT=3024
ExecStart=/home/blog/blog-server
Restart=always
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,257 @@
use core::net::SocketAddr;
use crate::{
AppState, XForwardedFor,
connection::{ConnectionId, JoinedPlayer},
runner::IdentifiedClientMessage,
};
use axum::{
extract::{
ConnectInfo, State, WebSocketUpgrade,
ws::{self, Message, WebSocket},
},
response::IntoResponse,
};
use axum_extra::{TypedHeader, headers};
use colored::Colorize;
use tokio::sync::broadcast::{Receiver, Sender};
use werewolves_proto::message::{ClientMessage, Identification, ServerMessage, UpdateSelf};
pub async fn handler(
ws: WebSocketUpgrade,
user_agent: Option<TypedHeader<headers::UserAgent>>,
x_forwarded_for: Option<TypedHeader<XForwardedFor>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(state): State<AppState>,
) -> impl IntoResponse {
let who = x_forwarded_for
.map(|x| x.to_string())
.unwrap_or_else(|| addr.to_string())
.italic();
log::info!(
"{who}{} connected.",
user_agent
.map(|agent| format!(" (User-Agent: {})", agent.as_str()))
.unwrap_or_default(),
);
let player_list = state.joined_players;
// finalize the upgrade process by returning upgrade callback.
// we can customize the callback by sending additional info such as address.
ws.on_upgrade(move |mut socket| async move {
let ident = match get_identification(&mut socket, &who).await {
Ok(ident) => ident,
Err(err) => {
log::warn!("identification failed for {who}: {err}");
return;
}
};
log::info!("connected {who} as {ident}");
let connection_id = ConnectionId::new(ident.player_id.clone());
let recv = {
let (send, recv) = tokio::sync::broadcast::channel(100);
player_list
.insert_or_replace(
ident.player_id.clone(),
JoinedPlayer::new(
send,
recv,
connection_id.clone(),
ident.public.name.clone(),
ident.public.number.clone(),
ident.public.pronouns.clone(),
),
)
.await
};
Client::new(
ident.clone(),
connection_id.clone(),
socket,
who.to_string(),
state.send,
recv,
)
.run()
.await;
log::info!("ending connection with {who}");
player_list.disconnect(&connection_id).await;
})
}
async fn get_identification(
socket: &mut WebSocket,
who: &str,
) -> Result<Identification, anyhow::Error> {
loop {
let msg_bytes = &socket
.recv()
.await
.ok_or(anyhow::anyhow!(
"connection from {who} closed before identification"
))?
.map_err(|err| anyhow::anyhow!("connection error from {who} at identification: {err}"))?
.into_data();
#[cfg(not(feature = "cbor"))]
let res = serde_json::from_slice::<Identification>(msg_bytes);
#[cfg(feature = "cbor")]
let res = ciborium::from_reader::<Identification, &[u8]>(msg_bytes);
match res {
Ok(id) => return Ok(id),
Err(err) => {
log::error!("invalid identification message from {who}: {err}");
continue;
}
}
}
}
#[allow(unused)]
enum MessagePayload {
Bytes(axum::body::Bytes),
Utf8(ws::Utf8Bytes),
}
struct Client {
ident: Identification,
connection_id: ConnectionId,
socket: WebSocket,
who: String,
sender: Sender<IdentifiedClientMessage>,
receiver: Receiver<ServerMessage>,
message_history: Vec<ServerMessage>,
}
impl Client {
fn new(
ident: Identification,
connection_id: ConnectionId,
socket: WebSocket,
who: String,
sender: Sender<IdentifiedClientMessage>,
receiver: Receiver<ServerMessage>,
) -> Self {
Self {
ident,
connection_id,
socket,
who,
sender,
receiver,
message_history: Vec::new(),
}
}
#[cfg(feature = "cbor")]
fn decode_message(msg: MessagePayload) -> Result<ClientMessage, anyhow::Error> {
match msg {
MessagePayload::Bytes(bytes) => Ok(ciborium::from_reader(bytes.iter().as_slice())?),
MessagePayload::Utf8(_) => Err(anyhow::anyhow!(
"cbor doesn't use utf8 strings for messages"
)),
}
}
#[cfg(not(feature = "cbor"))]
fn decode_message(msg: MessagePayload) -> Result<ClientMessage, anyhow::Error> {
match msg {
MessagePayload::Bytes(bytes) => Ok(serde_json::from_slice(&bytes)?),
MessagePayload::Utf8(text) => Ok(serde_json::from_str(&text)?),
}
}
async fn on_recv(
&mut self,
msg: Result<Message, axum::Error>,
conn_id: ConnectionId,
) -> Result<(), anyhow::Error> {
use crate::LogError;
let msg = match msg {
Ok(msg) => msg,
Err(err) => return Err(err.into()),
};
let message: ClientMessage = match msg {
Message::Binary(bytes) => Self::decode_message(MessagePayload::Bytes(bytes))?,
Message::Text(text) => Self::decode_message(MessagePayload::Utf8(text))?,
Message::Ping(ping) => {
self.socket.send(Message::Pong(ping)).await.log_debug();
return Ok(());
}
Message::Pong(_) => return Ok(()),
Message::Close(Some(close_frame)) => {
log::debug!("sent close frame: {close_frame:?}");
return Ok(());
}
Message::Close(None) => {
log::debug!("host closed connection");
return Ok(());
}
};
if let ClientMessage::UpdateSelf(update) = &message {
match update {
UpdateSelf::Name(name) => self.ident.public.name = name.clone(),
UpdateSelf::Number(num) => self.ident.public.number = *num,
UpdateSelf::Pronouns(pronouns) => self.ident.public.pronouns = pronouns.clone(),
}
}
self.sender.send(IdentifiedClientMessage {
message,
identity: self.ident.clone(),
})?;
Ok(())
}
async fn handle_message(&mut self, message: ServerMessage) -> Result<(), anyhow::Error> {
self.socket
.send({
#[cfg(not(feature = "cbor"))]
{
ws::Message::Text(serde_json::to_string(&message)?.into())
}
#[cfg(feature = "cbor")]
ws::Message::Binary({
let mut v = Vec::new();
ciborium::into_writer(&message, &mut v)?;
v.into()
})
})
.await?;
self.message_history.push(message);
Ok(())
}
async fn run(mut self) {
loop {
if let Err(err) = tokio::select! {
msg = self.socket.recv() => {
match msg {
Some(msg) => self.on_recv(msg, self.connection_id.clone()).await,
None => {
return;
},
}
},
r = self.receiver.recv() => {
match r {
Ok(msg) => {
self.handle_message(msg).await
}
Err(err) => {
log::warn!("[{}] recv error: {err}", self.connection_id.player_id());
return;
}
}
}
} {
log::error!("[{}][{}] {err}", self.connection_id.player_id(), self.who);
return;
}
}
}
}

View File

@ -0,0 +1,42 @@
use tokio::sync::{broadcast::Sender, mpsc::Receiver};
use werewolves_proto::{
error::GameError,
message::host::{HostMessage, ServerToHostMessage},
};
pub struct HostComms {
send: Sender<ServerToHostMessage>,
recv: Receiver<HostMessage>,
}
impl HostComms {
pub const fn new(
host_send: Sender<ServerToHostMessage>,
host_recv: Receiver<HostMessage>,
) -> Self {
Self {
send: host_send,
recv: host_recv,
}
}
#[cfg(debug_assertions)]
pub async fn recv(&mut self) -> Option<HostMessage> {
match self.recv.recv().await {
Some(msg) => Some(msg),
None => None,
}
}
#[cfg(not(debug_assertions))]
pub async fn recv(&mut self) -> Option<HostMessage> {
self.recv.recv().await
}
pub fn send(&mut self, message: ServerToHostMessage) -> Result<(), GameError> {
self.send
.send(message)
.map_err(|err| GameError::GenericError(err.to_string()))?;
Ok(())
}
}

View File

@ -0,0 +1,47 @@
use tokio::sync::broadcast::Receiver;
use werewolves_proto::{error::GameError, player::PlayerId};
use crate::{communication::Comms, runner::Message};
use super::{HostComms, player::PlayerIdComms};
pub struct LobbyComms {
comms: Comms,
connect_recv: Receiver<(PlayerId, bool)>,
}
impl LobbyComms {
pub fn new(comms: Comms, connect_recv: Receiver<(PlayerId, bool)>) -> Self {
Self {
comms,
connect_recv,
}
}
pub fn into_inner(self) -> (Comms, Receiver<(PlayerId, bool)>) {
(self.comms, self.connect_recv)
}
pub const fn player(&mut self) -> &mut PlayerIdComms {
self.comms.player()
}
pub const fn host(&mut self) -> &mut HostComms {
self.comms.host()
}
pub async fn next_message(&mut self) -> Result<Message, GameError> {
tokio::select! {
r = self.comms.message() => {
r
}
r = self.connect_recv.recv() => {
match r {
Ok((player_id, true)) => Ok(Message::Connect(player_id)),
Ok((player_id, false)) => Ok(Message::Disconnect(player_id)),
Err(err) => Err(GameError::GenericError(err.to_string())),
}
}
}
}
}

View File

@ -0,0 +1,44 @@
use werewolves_proto::error::GameError;
use crate::{
communication::{host::HostComms, player::PlayerIdComms},
runner::Message,
};
pub mod host;
pub mod lobby;
pub mod player;
pub struct Comms {
host: HostComms,
player: PlayerIdComms,
}
impl Comms {
pub const fn new(host: HostComms, player: PlayerIdComms) -> Self {
Self { host, player }
}
pub const fn host(&mut self) -> &mut HostComms {
&mut self.host
}
pub const fn player(&mut self) -> &mut PlayerIdComms {
&mut self.player
}
pub async fn message(&mut self) -> Result<Message, GameError> {
tokio::select! {
msg = self.host.recv() => {
match msg {
Some(msg) => Ok(Message::Host(msg)),
None => Err(GameError::HostChannelClosed),
}
}
Ok(msg) = self.player.recv() => {
Ok(Message::Client(msg))
}
}
}
}

View File

@ -0,0 +1,50 @@
use core::time::Duration;
use std::collections::HashMap;
use colored::Colorize;
use tokio::{
sync::broadcast::{Receiver, Sender},
time::Instant,
};
use werewolves_proto::{
error::GameError,
message::{ClientMessage, ServerMessage, Target, night::ActionResponse},
player::{Character, CharacterId, PlayerId},
};
use crate::{connection::JoinedPlayers, runner::IdentifiedClientMessage};
pub struct PlayerIdComms {
joined_players: JoinedPlayers,
message_recv: Receiver<IdentifiedClientMessage>,
connect_recv: tokio::sync::broadcast::Receiver<(PlayerId, bool)>,
}
impl PlayerIdComms {
pub fn new(
joined_players: JoinedPlayers,
message_recv: Receiver<IdentifiedClientMessage>,
connect_recv: tokio::sync::broadcast::Receiver<(PlayerId, bool)>,
) -> Self {
Self {
joined_players,
message_recv,
connect_recv,
}
}
pub async fn recv(&mut self) -> Result<IdentifiedClientMessage, GameError> {
match self
.message_recv
.recv()
.await
.map_err(|err| GameError::GenericError(err.to_string()))
{
Ok(msg) => {
log::debug!("got message: {}", format!("{msg:?}").dimmed());
Ok(msg)
}
Err(err) => Err(err),
}
}
}

View File

@ -0,0 +1,231 @@
use core::num::NonZeroU8;
use std::{collections::HashMap, sync::Arc};
use colored::Colorize;
use tokio::{
sync::{
Mutex,
broadcast::{Receiver, Sender},
},
time::Instant,
};
use werewolves_proto::{
error::GameError,
message::{PublicIdentity, ServerMessage},
player::PlayerId,
};
use crate::LogError;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ConnectionId(PlayerId, Instant);
impl ConnectionId {
pub fn new(player_id: PlayerId) -> Self {
Self(player_id, Instant::now())
}
pub const fn player_id(&self) -> &PlayerId {
&self.0
}
pub const fn connect_time(&self) -> &Instant {
&self.1
}
}
#[derive(Debug)]
pub struct JoinedPlayer {
sender: Sender<ServerMessage>,
receiver: Receiver<ServerMessage>,
active_connection: ConnectionId,
in_game: bool,
pub name: String,
pub number: NonZeroU8,
pub pronouns: Option<String>,
}
impl JoinedPlayer {
pub const fn new(
sender: Sender<ServerMessage>,
receiver: Receiver<ServerMessage>,
active_connection: ConnectionId,
name: String,
number: NonZeroU8,
pronouns: Option<String>,
) -> Self {
Self {
name,
number,
sender,
pronouns,
receiver,
active_connection,
in_game: false,
}
}
pub fn resubscribe_reciever(&self) -> Receiver<ServerMessage> {
self.receiver.resubscribe()
}
pub fn sender(&self) -> Sender<ServerMessage> {
self.sender.clone()
}
}
#[derive(Debug, Clone)]
pub struct JoinedPlayers {
players: Arc<Mutex<HashMap<PlayerId, JoinedPlayer>>>,
connect_state_sender: Sender<(PlayerId, bool)>,
}
impl JoinedPlayers {
pub fn new(connect_state_sender: Sender<(PlayerId, bool)>) -> Self {
Self {
connect_state_sender,
players: Arc::new(Mutex::new(HashMap::new())),
}
}
pub async fn send_all_lobby(&self, in_lobby: Box<[PublicIdentity]>, in_lobby_ids: &[PlayerId]) {
let players: tokio::sync::MutexGuard<'_, HashMap<PlayerId, JoinedPlayer>> =
self.players.lock().await;
let senders = players
.iter()
.map(|(pid, p)| (pid.clone(), p.sender.clone()))
.collect::<Box<[_]>>();
core::mem::drop(players);
for (pid, send) in senders {
send.send(ServerMessage::LobbyInfo {
joined: in_lobby_ids.contains(&pid),
players: in_lobby.clone(),
})
.log_debug();
}
}
pub async fn is_connected(&self, player_id: &PlayerId) -> bool {
self.players.lock().await.contains_key(player_id)
}
pub async fn update(&self, player_id: &PlayerId, f: impl FnOnce(&mut JoinedPlayer)) {
if let Some(p) = self
.players
.lock()
.await
.iter_mut()
.find_map(|(pid, p)| (player_id == pid).then_some(p))
{
f(p)
}
}
pub async fn get_name(&self, player_id: &PlayerId) -> Option<String> {
self.players.lock().await.iter().find_map(|(pid, p)| {
if pid == player_id {
Some(p.name.clone())
} else {
None
}
})
}
/// Disconnect the player
///
/// Will not disconnect if the player is currently in a game, allowing them to reconnect
pub async fn disconnect(&self, connection: &ConnectionId) -> Option<JoinedPlayer> {
let mut map = self.players.lock().await;
self.connect_state_sender
.send((connection.0.clone(), false))
.log_warn();
if map
.get(connection.player_id())
.map(|p| p.active_connection == *connection && !p.in_game)
.unwrap_or_default()
{
return map.remove(connection.player_id());
}
None
}
pub async fn start_game_with(&self, players: &[PlayerId]) -> Result<InGameToken, GameError> {
let mut map = self.players.lock().await;
if !players.iter().all(|p| map.contains_key(p)) {
return Err(GameError::NotAllPlayersConnected);
}
for player in players {
unsafe { map.get_mut(player).unwrap_unchecked() }.in_game = true;
}
Ok(InGameToken::new(
self.clone(),
players.iter().cloned().collect(),
))
}
pub async fn release_from_game(&self, players: &[PlayerId]) {
self.players
.lock()
.await
.iter_mut()
.filter(|(p, _)| players.contains(*p))
.for_each(|(_, p)| p.in_game = false)
}
pub async fn get_sender(&self, player_id: &PlayerId) -> Option<Sender<ServerMessage>> {
self.players
.lock()
.await
.get(player_id)
.map(|c| c.sender.clone())
}
pub async fn insert_or_replace(
&self,
player_id: PlayerId,
player: JoinedPlayer,
) -> Receiver<ServerMessage> {
let mut map = self.players.lock().await;
if let Some(old) = map.insert(player_id.clone(), player) {
let old_map_entry = unsafe { map.get_mut(&player_id).unwrap_unchecked() };
old_map_entry.receiver = old.resubscribe_reciever();
old.receiver
} else {
self.connect_state_sender
.send((player_id.clone(), true))
.log_warn();
unsafe { map.get(&player_id).unwrap_unchecked() }
.receiver
.resubscribe()
}
}
}
pub struct InGameToken {
joined_players: JoinedPlayers,
players_in_game: Option<Box<[PlayerId]>>,
}
impl InGameToken {
const fn new(joined_players: JoinedPlayers, players_in_game: Box<[PlayerId]>) -> Self {
Self {
joined_players,
players_in_game: Some(players_in_game),
}
}
}
impl Drop for InGameToken {
fn drop(&mut self) {
let joined_players = self.joined_players.clone();
if let Some(players) = self.players_in_game.take() {
tokio::spawn(async move {
let players_in_game = players;
joined_players.release_from_game(&players_in_game).await;
});
}
}
}

View File

@ -0,0 +1,324 @@
use core::ops::Not;
use crate::{
LogError,
communication::{Comms, lobby::LobbyComms},
connection::{InGameToken, JoinedPlayers},
lobby::{Lobby, PlayerIdSender},
runner::{IdentifiedClientMessage, Message},
};
use tokio::{sync::broadcast::Receiver, time::Instant};
use werewolves_proto::{
error::GameError,
game::{Game, GameOver, Village},
message::{
ClientMessage, Identification, ServerMessage,
host::{HostGameMessage, HostMessage, HostNightMessage, ServerToHostMessage},
},
player::{Character, PlayerId},
};
type Result<T> = core::result::Result<T, GameError>;
pub struct GameRunner {
game: Game,
comms: Comms,
connect_recv: Receiver<(PlayerId, bool)>,
player_sender: PlayerIdSender,
roles_revealed: bool,
joined_players: JoinedPlayers,
_release_token: InGameToken,
cover_of_darkness: bool,
}
impl GameRunner {
pub const fn new(
game: Game,
comms: Comms,
player_sender: PlayerIdSender,
connect_recv: Receiver<(PlayerId, bool)>,
joined_players: JoinedPlayers,
release_token: InGameToken,
) -> Self {
Self {
game,
comms,
connect_recv,
player_sender,
joined_players,
roles_revealed: false,
_release_token: release_token,
cover_of_darkness: true,
}
}
pub const fn comms(&mut self) -> &mut Comms {
&mut self.comms
}
pub fn into_lobby(self) -> Lobby {
Lobby::new(
self.joined_players,
LobbyComms::new(self.comms, self.connect_recv),
)
}
pub const fn proto_game(&self) -> &Game {
&self.game
}
pub async fn role_reveal(&mut self) {
for char in self.game.village().characters() {
if let Err(err) = self.player_sender.send_if_present(
char.player_id(),
ServerMessage::GameStart {
role: char.role().initial_shown_role(),
},
) {
log::warn!(
"failed sending role info to [{}]({}): {err}",
char.player_id(),
char.name()
)
}
}
let mut acks = self
.game
.village()
.characters()
.into_iter()
.map(|c| (c, false))
.collect::<Box<[_]>>();
let update_host = |acks: &[(Character, bool)], comms: &mut Comms| {
comms
.host()
.send(ServerToHostMessage::WaitingForRoleRevealAcks {
ackd: acks
.iter()
.filter_map(|(a, ackd)| ackd.then_some(a.target()))
.collect(),
waiting: acks
.iter()
.filter_map(|(a, ackd)| ackd.not().then_some(a.target()))
.collect(),
})
.log_err();
};
(update_host)(&acks, &mut self.comms);
let notify_of_role = |player_id: &PlayerId, village: &Village, sender: &PlayerIdSender| {
if let Some(char) = village.character_by_player_id(player_id) {
sender
.send_if_present(
player_id,
ServerMessage::GameStart {
role: char.role().initial_shown_role(),
},
)
.log_debug();
}
};
let mut last_err_log = tokio::time::Instant::now() - tokio::time::Duration::from_secs(60);
while acks.iter().any(|(_, ackd)| !*ackd) {
let msg = match self.comms.message().await {
Ok(msg) => msg,
Err(err) => {
if (tokio::time::Instant::now() - last_err_log).as_secs() >= 30 {
log::error!("recv during role_reveal: {err}");
last_err_log = tokio::time::Instant::now();
}
continue;
}
};
match msg {
Message::Host(HostMessage::ForceRoleAckFor(char_id)) => {
if let Some((c, ackd)) =
acks.iter_mut().find(|(c, _)| c.character_id() == &char_id)
{
*ackd = true;
(notify_of_role)(c.player_id(), self.game.village(), &self.player_sender);
}
(update_host)(&acks, &mut self.comms);
}
Message::Host(_) => {
(update_host)(&acks, &mut self.comms);
}
Message::Client(IdentifiedClientMessage {
identity:
Identification {
player_id,
public: _,
},
message: ClientMessage::RoleAck,
}) => {
if let Some((_, ackd)) =
acks.iter_mut().find(|(t, _)| t.player_id() == &player_id)
{
*ackd = true;
self.player_sender
.send_if_present(&player_id, ServerMessage::Sleep)
.log_debug();
}
(update_host)(&acks, &mut self.comms);
}
Message::Client(IdentifiedClientMessage {
identity:
Identification {
player_id,
public: _,
},
message: _,
})
| Message::Connect(player_id) => {
(notify_of_role)(&player_id, self.game.village(), &self.player_sender)
}
Message::Disconnect(_) => {}
}
}
self.roles_revealed = true;
}
pub async fn next(&mut self) -> Option<GameOver> {
let msg = self.comms.host().recv().await.expect("host channel closed");
match self.host_message(msg) {
Ok(resp) => {
self.comms.host().send(resp).log_warn();
}
Err(err) => {
self.comms
.host()
.send(ServerToHostMessage::Error(err))
.log_warn();
}
}
self.game.game_over()
}
pub fn host_message(&mut self, message: HostMessage) -> Result<ServerToHostMessage> {
if !self.roles_revealed {
return Err(GameError::NeedRoleReveal);
}
if self.cover_of_darkness {
match &message {
HostMessage::GetState | HostMessage::InGame(HostGameMessage::GetState) => {
return Ok(ServerToHostMessage::CoverOfDarkness);
}
HostMessage::InGame(HostGameMessage::Night(HostNightMessage::Next)) => {
self.cover_of_darkness = false;
return self.host_message(HostMessage::GetState);
}
_ => return Err(GameError::InvalidMessageForGameState),
};
}
match message {
HostMessage::GetState => self.game.process(HostGameMessage::GetState),
HostMessage::InGame(msg) => self.game.process(msg),
HostMessage::Lobby(_) | HostMessage::NewLobby | HostMessage::ForceRoleAckFor(_) => {
Err(GameError::InvalidMessageForGameState)
}
HostMessage::Echo(echo) => Ok(echo),
}
}
}
pub struct GameEnd {
game: Option<GameRunner>,
result: GameOver,
last_error_log: Instant,
}
impl GameEnd {
pub fn new(game: GameRunner, result: GameOver) -> Self {
Self {
result,
game: Some(game),
last_error_log: Instant::now() - core::time::Duration::from_secs(60),
}
}
const fn game(&mut self) -> Result<&mut GameRunner> {
match self.game.as_mut() {
Some(game) => Ok(game),
None => Err(GameError::InactiveGameObject),
}
}
pub fn end_screen(&mut self) -> Result<()> {
let result = self.result;
for char in self.game()?.game.village().characters() {
self.game()?
.player_sender
.send_if_present(char.player_id(), ServerMessage::GameOver(result))
.log_debug();
}
self.game()?
.comms
.host()
.send(ServerToHostMessage::GameOver(result))
.log_warn();
Ok(())
}
pub async fn next(&mut self) -> Option<Lobby> {
let msg = match self.game().unwrap().comms.message().await {
Ok(msg) => msg,
Err(err) => {
if (Instant::now() - self.last_error_log).as_secs() >= 30 {
log::error!("getting message: {err}");
self.last_error_log = Instant::now();
}
return None;
}
};
match msg {
Message::Host(HostMessage::Echo(msg)) => {
self.game().unwrap().comms.host().send(msg).log_debug();
}
Message::Host(HostMessage::GetState) => {
let result = self.result;
self.game()
.unwrap()
.comms
.host()
.send(ServerToHostMessage::GameOver(result))
.log_debug()
}
Message::Host(HostMessage::NewLobby) => {
self.game()
.unwrap()
.comms
.host()
.send(ServerToHostMessage::Lobby(Box::new([])))
.log_debug();
let lobby = self.game.take().unwrap().into_lobby();
return Some(lobby);
}
Message::Host(_) => self
.game()
.unwrap()
.comms
.host()
.send(ServerToHostMessage::Error(
GameError::InvalidMessageForGameState,
))
.log_debug(),
Message::Client(IdentifiedClientMessage {
identity,
message: _,
}) => {
let result = self.result;
self.game()
.unwrap()
.player_sender
.send_if_present(&identity.player_id, ServerMessage::GameOver(result))
.log_debug();
}
Message::Connect(_) | Message::Disconnect(_) => {}
}
None
}
}

View File

@ -0,0 +1,149 @@
use core::net::SocketAddr;
use axum::{
extract::{
ConnectInfo, State, WebSocketUpgrade,
ws::{self, Message, WebSocket},
},
response::IntoResponse,
};
use axum_extra::{TypedHeader, headers};
use colored::Colorize;
use tokio::sync::{broadcast::Receiver, mpsc::Sender};
use werewolves_proto::message::host::{HostMessage, ServerToHostMessage};
use crate::{AppState, LogError, XForwardedFor};
pub async fn handler(
ws: WebSocketUpgrade,
user_agent: Option<TypedHeader<headers::UserAgent>>,
x_forwarded_for: Option<TypedHeader<XForwardedFor>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(state): State<AppState>,
) -> impl IntoResponse {
let who = x_forwarded_for
.map(|x| x.to_string())
.unwrap_or_else(|| addr.to_string());
log::info!(
"{who}{} connected.",
user_agent
.map(|agent| format!(" (User-Agent: {})", agent.as_str()))
.unwrap_or_default(),
);
// finalize the upgrade process by returning upgrade callback.
// we can customize the callback by sending additional info such as address.
ws.on_upgrade(move |socket| async move {
Host::new(
socket,
state.host_send.clone(),
state.host_recv.resubscribe(),
)
.run()
.await
})
}
struct Host {
socket: WebSocket,
host_send: Sender<HostMessage>,
server_recv: Receiver<ServerToHostMessage>,
}
impl Host {
pub fn new(
socket: WebSocket,
host_send: Sender<HostMessage>,
server_recv: Receiver<ServerToHostMessage>,
) -> Self {
Self {
host_send,
server_recv,
socket,
}
}
async fn on_recv(
&mut self,
msg: Option<Result<Message, axum::Error>>,
) -> Result<(), anyhow::Error> {
#[cfg(not(feature = "cbor"))]
let msg: HostMessage = serde_json::from_slice(
&match msg {
Some(Ok(msg)) => msg,
Some(Err(err)) => return Err(err.into()),
None => {
log::warn!("[host] no message");
return Ok(());
}
}
.into_data(),
)?;
#[cfg(feature = "cbor")]
let msg: HostMessage = {
let bytes = match msg {
Some(Ok(msg)) => msg.into_data(),
Some(Err(err)) => return Err(err.into()),
None => {
log::warn!("[host] no message");
return Ok(());
}
};
let slice: &[u8] = &bytes;
ciborium::from_reader(slice)?
};
if let HostMessage::Echo(echo) = &msg {
self.send_message(echo).await.log_warn();
return Ok(());
}
log::debug!(
"{} {}",
"[host::incoming::message]".bold(),
format!("{msg:?}").dimmed()
);
Ok(self.host_send.send(msg).await?)
}
async fn send_message(&mut self, msg: &ServerToHostMessage) -> Result<(), anyhow::Error> {
Ok(self
.socket
.send(
#[cfg(not(feature = "cbor"))]
ws::Message::Text(serde_json::to_string(msg)?.into()),
#[cfg(feature = "cbor")]
ws::Message::Binary({
let mut bytes = Vec::new();
ciborium::into_writer(msg, &mut bytes)?;
bytes.into()
}),
)
.await?)
}
pub async fn run(mut self) {
loop {
tokio::select! {
msg = self.socket.recv() => {
if let Err(err) = self.on_recv(msg).await {
log::error!("{} {err}", "[host::incoming]".bold());
return;
}
},
msg = self.server_recv.recv() => {
match msg {
Ok(msg) => {
log::debug!("sending message to host: {}", format!("{msg:?}").dimmed());
if let Err(err) = self.send_message(&msg).await {
log::error!("{} {err}", "[host::outgoing]".bold())
}
},
Err(err) => {
log::error!("{} {err}", "[host::mpsc]".bold());
return;
}
}
}
};
}
}
}

View File

@ -0,0 +1,345 @@
use core::{
num::NonZeroU8,
ops::{Deref, DerefMut},
time::Duration,
};
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast::Sender;
use werewolves_proto::{
error::GameError,
game::{Game, GameSettings, Village},
message::{
CharacterState, ClientMessage, DayCharacter, Identification, PlayerState, ServerMessage,
UpdateSelf,
host::{HostLobbyMessage, HostMessage, ServerToHostMessage},
},
player::PlayerId,
};
use crate::{
LogError,
communication::lobby::LobbyComms,
connection::JoinedPlayers,
game::GameRunner,
runner::{IdentifiedClientMessage, Message},
};
pub struct Lobby {
players_in_lobby: PlayerIdSender,
settings: GameSettings,
joined_players: JoinedPlayers,
comms: Option<LobbyComms>,
}
impl Lobby {
pub fn new(joined_players: JoinedPlayers, comms: LobbyComms) -> Self {
Self {
joined_players,
comms: Some(comms),
settings: GameSettings::default(),
players_in_lobby: PlayerIdSender(Vec::new()),
}
}
const fn comms(&mut self) -> Result<&mut LobbyComms, GameError> {
match self.comms.as_mut() {
Some(comms) => Ok(comms),
None => Err(GameError::InactiveGameObject),
}
}
pub async fn send_lobby_info_to_clients(&mut self) {
let players = self
.players_in_lobby
.iter()
.map(|(id, _)| id.public.clone())
.collect::<Box<[_]>>();
self.joined_players
.send_all_lobby(
players,
&self
.players_in_lobby
.iter()
.map(|(id, _)| id.player_id.clone())
.collect::<Box<[_]>>(),
)
.await;
}
async fn get_lobby_player_list(&self) -> Box<[PlayerState]> {
let mut players = Vec::new();
for (player, _) in self.players_in_lobby.iter() {
players.push(PlayerState {
identification: player.clone(),
connected: self.joined_players.is_connected(&player.player_id).await,
});
}
players.into_boxed_slice()
}
async fn send_lobby_info_to_host(&mut self) -> Result<(), GameError> {
let players = self.get_lobby_player_list().await;
self.comms()?
.host()
.send(ServerToHostMessage::Lobby(players))
.map_err(|err| GameError::GenericError(err.to_string()))
}
pub async fn next(&mut self) -> Option<GameRunner> {
let msg = self
.comms()
.unwrap()
.next_message()
.await
.expect("get next message");
match self.next_inner(msg.clone()).await.map_err(|err| (msg, err)) {
Ok(None) => {}
Ok(Some(mut game)) => {
game.role_reveal().await;
match game.host_message(HostMessage::GetState) {
Ok(msg) => {
log::info!("initial message after night reveal: {msg:?}");
game.comms().host().send(msg).log_warn();
}
Err(err) => {
log::error!("processing get_state after role reveal to host: {err}")
}
}
return Some(game);
}
Err((Message::Host(_), err)) => self
.comms()
.unwrap()
.host()
.send(ServerToHostMessage::Error(err))
.log_warn(),
Err((
Message::Client(IdentifiedClientMessage {
identity:
Identification {
player_id,
public: _,
},
message: _,
}),
GameError::InvalidMessageForGameState,
)) => {
let _ = self
.players_in_lobby
.send_if_present(&player_id, ServerMessage::InvalidMessageForGameState);
}
Err((
Message::Client(IdentifiedClientMessage {
identity: Identification { player_id, public },
message: _,
}),
err,
)) => {
log::error!("processing message from {public} [{player_id}]: {err}");
let _ = self
.players_in_lobby
.send_if_present(&player_id, ServerMessage::Reset);
}
Err((Message::Connect(_), _)) | Err((Message::Disconnect(_), _)) => {}
}
None
}
async fn next_inner(&mut self, msg: Message) -> Result<Option<GameRunner>, GameError> {
match msg {
Message::Host(HostMessage::InGame(_))
| Message::Host(HostMessage::ForceRoleAckFor(_)) => {
return Err(GameError::InvalidMessageForGameState);
}
Message::Host(HostMessage::NewLobby) => self
.comms()
.unwrap()
.host()
.send(ServerToHostMessage::Error(
GameError::InvalidMessageForGameState,
))
.log_warn(),
Message::Host(HostMessage::Lobby(HostLobbyMessage::GetState))
| Message::Host(HostMessage::GetState) => self.send_lobby_info_to_host().await?,
Message::Host(HostMessage::Lobby(HostLobbyMessage::GetGameSettings)) => {
let msg = ServerToHostMessage::GameSettings(self.settings.clone());
let _ = self.comms().unwrap().host().send(msg);
}
Message::Host(HostMessage::Lobby(HostLobbyMessage::SetGameSettings(settings))) => {
settings.check()?;
self.settings = settings;
}
Message::Host(HostMessage::Lobby(HostLobbyMessage::Start)) => {
if self.players_in_lobby.len() < self.settings.min_players_needed() {
return Err(GameError::TooFewPlayers {
got: self.players_in_lobby.len() as _,
need: self.settings.min_players_needed() as _,
});
}
let playing_players = self
.players_in_lobby
.iter()
.map(|(id, _)| id.clone())
.collect::<Box<[_]>>();
let release_token = self
.joined_players
.start_game_with(
&playing_players
.iter()
.map(|id| id.player_id.clone())
.collect::<Box<[_]>>(),
)
.await?;
let game = Game::new(&playing_players, self.settings.clone())?;
assert_eq!(game.village().characters().len(), playing_players.len());
let (comms, recv) = self.comms.take().unwrap().into_inner();
return Ok(Some(GameRunner::new(
game,
comms,
self.players_in_lobby.drain(),
recv,
self.joined_players.clone(),
release_token,
)));
}
Message::Client(IdentifiedClientMessage {
identity,
message: ClientMessage::Hello,
}) => {
if self
.players_in_lobby
.iter_mut()
.any(|p| p.0.player_id == identity.player_id)
{
// Already have the player
return Ok(None);
}
if let Some(sender) = self.joined_players.get_sender(&identity.player_id).await {
self.players_in_lobby.push((identity, sender.clone()));
self.send_lobby_info_to_clients().await;
self.send_lobby_info_to_host().await?;
}
}
Message::Host(HostMessage::Lobby(HostLobbyMessage::Kick(player_id)))
| Message::Client(IdentifiedClientMessage {
identity:
Identification {
player_id,
public: _,
},
message: ClientMessage::Goodbye,
}) => {
log::error!("we are in there");
if let Some(remove_idx) = self
.players_in_lobby
.iter()
.enumerate()
.find_map(|(idx, p)| (p.0.player_id == player_id).then_some(idx))
{
log::error!("removing player {player_id} at idx {remove_idx}");
self.players_in_lobby.swap_remove(remove_idx);
self.send_lobby_info_to_host().await?;
self.send_lobby_info_to_clients().await;
}
}
Message::Client(IdentifiedClientMessage {
identity:
Identification {
player_id,
public: _,
},
message: ClientMessage::GetState,
}) => {
let msg = ServerMessage::LobbyInfo {
joined: self
.players_in_lobby
.iter()
.any(|(p, _)| p.player_id == player_id),
players: self
.players_in_lobby
.iter()
.map(|(id, _)| id.public.clone())
.collect(),
};
if let Some(sender) = self.joined_players.get_sender(&player_id).await {
sender.send(msg).log_debug();
}
}
Message::Client(IdentifiedClientMessage {
identity: _,
message: ClientMessage::RoleAck,
}) => return Err(GameError::InvalidMessageForGameState),
Message::Client(IdentifiedClientMessage {
identity: Identification { player_id, public },
message: ClientMessage::UpdateSelf(_),
}) => {
self.joined_players
.update(&player_id, move |p| {
p.name = public.name;
p.number = public.number;
p.pronouns = public.pronouns;
})
.await;
self.send_lobby_info_to_clients().await;
self.send_lobby_info_to_host().await.log_debug();
}
Message::Connect(_) | Message::Disconnect(_) => self.send_lobby_info_to_host().await?,
Message::Host(HostMessage::Echo(msg)) => {
self.comms()?.host().send(msg).log_warn();
}
}
Ok(None)
}
}
pub struct PlayerIdSender(Vec<(Identification, Sender<ServerMessage>)>);
impl Deref for PlayerIdSender {
type Target = Vec<(Identification, Sender<ServerMessage>)>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for PlayerIdSender {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl PlayerIdSender {
pub fn find(&self, player_id: &PlayerId) -> Option<&Sender<ServerMessage>> {
self.iter()
.find_map(|(id, s)| (&id.player_id == player_id).then_some(s))
}
pub fn send_if_present(
&self,
player_id: &PlayerId,
message: ServerMessage,
) -> Result<(), GameError> {
if let Some(sender) = self.find(player_id) {
sender
.send(message)
.map(|_| ())
.map_err(|err| GameError::GenericError(err.to_string()))
} else {
Ok(())
}
}
pub fn drain(&mut self) -> Self {
let mut swapped = Self(vec![]);
core::mem::swap(&mut swapped, self);
swapped
}
}

View File

@ -0,0 +1,267 @@
mod client;
mod communication;
mod connection;
mod game;
mod host;
mod lobby;
mod runner;
mod saver;
use axum::{
Router,
http::{Request, header},
response::IntoResponse,
routing::{any, get},
};
use axum_extra::headers;
use communication::lobby::LobbyComms;
use connection::JoinedPlayers;
use core::{fmt::Display, net::SocketAddr, str::FromStr};
use log::Record;
use runner::IdentifiedClientMessage;
use std::{env, io::Write, path::Path};
use tokio::{
sync::{broadcast, mpsc},
time::Instant,
};
use crate::{
communication::{Comms, host::HostComms, player::PlayerIdComms},
saver::FileSaver,
};
const DEFAULT_PORT: u16 = 8080;
const DEFAULT_HOST: &str = "127.0.0.1";
const DEFAULT_SAVE_DIR: &str = "werewolves-saves/";
#[tokio::main]
async fn main() {
// pretty_env_logger::init();
use colored::Colorize;
pretty_env_logger::formatted_builder()
.parse_default_env()
.format(|f, record| {
let time = chrono::Local::now().time().to_string().dimmed();
match record.file() {
Some(file) => {
let file = format!(
"[{file}{}]",
record
.line()
.map(|l| format!(":{l}"))
.unwrap_or_else(String::new),
)
.dimmed();
let level = match record.level() {
log::Level::Error => "[err]".red().bold(),
log::Level::Warn => "[warn]".yellow().bold(),
log::Level::Info => "[info]".white().bold(),
log::Level::Debug => "[debug]".dimmed().bold(),
log::Level::Trace => "[trace]".dimmed(),
};
let args = record.args();
let arrow = "".bold().magenta();
writeln!(
f,
"{time} {file}\n{level} {arrow} {args}",
// "⇗⇘⇗⇘⇗⇘".bold().dimmed(),
)
}
_ => writeln!(f, "{time} [{}] {}", record.level(), record.args()),
}
})
.try_init()
.unwrap();
let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
default_panic(info);
std::process::exit(1);
}));
let host = env::var("HOST").unwrap_or(DEFAULT_HOST.to_string());
let port = env::var("PORT")
.map_err(|err| anyhow::anyhow!("{err}"))
.map(|port_str| {
port_str
.parse::<u16>()
.unwrap_or_else(|err| panic!("parse PORT={port_str} failed: {err}"))
})
.unwrap_or(DEFAULT_PORT);
let listen_addr =
SocketAddr::from_str(format!("{host}:{port}").as_str()).expect("invalid host/port");
let (send, recv) = broadcast::channel(100);
let (server_send, host_recv) = broadcast::channel(100);
let (host_send, server_recv) = mpsc::channel(100);
let (connect_send, connect_recv) = broadcast::channel(100);
let joined_players = JoinedPlayers::new(connect_send);
let lobby_comms = LobbyComms::new(
Comms::new(
HostComms::new(server_send, server_recv),
PlayerIdComms::new(joined_players.clone(), recv, connect_recv.resubscribe()),
),
connect_recv,
);
let jp_clone = joined_players.clone();
let path = Path::new(option_env!("SAVE_PATH").unwrap_or(DEFAULT_SAVE_DIR))
.canonicalize()
.expect("canonicalizing path");
if let Err(err) = std::fs::create_dir(&path)
&& !matches!(err.kind(), std::io::ErrorKind::AlreadyExists)
{
panic!("creating save dir at [{path:?}]: {err}")
}
// Check if we can write to the path
{
let test_file_path = path.join(".test");
if let Err(err) = std::fs::File::create(&test_file_path) {
panic!("can't create files in {path:?}: {err}")
}
std::fs::remove_file(&test_file_path).log_err();
}
let saver = FileSaver::new(path);
tokio::spawn(async move {
crate::runner::run_game(jp_clone, lobby_comms, saver).await;
panic!("game over");
});
let state = AppState {
joined_players,
host_recv,
host_send,
send,
};
let app = Router::new()
.route("/connect/client", any(client::handler))
.route("/connect/host", any(host::handler))
.with_state(state)
.fallback(get(handle_http_static));
let listener = tokio::net::TcpListener::bind(listen_addr).await.unwrap();
log::info!("listening on {}", listener.local_addr().unwrap());
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.unwrap();
}
struct AppState {
joined_players: JoinedPlayers,
send: broadcast::Sender<IdentifiedClientMessage>,
host_send: tokio::sync::mpsc::Sender<werewolves_proto::message::host::HostMessage>,
host_recv: broadcast::Receiver<werewolves_proto::message::host::ServerToHostMessage>,
}
impl Clone for AppState {
fn clone(&self) -> Self {
Self {
joined_players: self.joined_players.clone(),
send: self.send.clone(),
host_send: self.host_send.clone(),
host_recv: self.host_recv.resubscribe(),
}
}
}
async fn handle_http_static(req: Request<axum::body::Body>) -> impl IntoResponse {
use mime_sniffer::MimeTypeSniffer;
const INDEX_FILE: &[u8] = include_bytes!("../../werewolves/dist/index.html");
let path = req.uri().path();
werewolves_macros::include_dist!(DIST_FILES, "werewolves/dist");
let file = if let Some(file) = DIST_FILES.iter().find_map(|(file_path, file)| {
if *file_path == path {
Some(*file)
} else {
None
}
}) {
file
} else {
return (
[(header::CONTENT_TYPE, "text/html".to_string())],
INDEX_FILE,
);
};
let mime = if path.ends_with(".js") {
"text/javascript".to_string()
} else if path.ends_with(".css") {
"text/css".to_string()
} else if path.ends_with(".wasm") {
"application/wasm".to_string()
} else {
file.sniff_mime_type()
.unwrap_or("application/octet-stream")
.to_string()
};
([(header::CONTENT_TYPE, mime)], file)
}
struct XForwardedFor(String);
impl Display for XForwardedFor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0.as_str())
}
}
impl headers::Header for XForwardedFor {
fn name() -> &'static header::HeaderName {
static NAME: header::HeaderName = header::HeaderName::from_static("x-forwarded-for");
&NAME
}
fn decode<'i, I>(values: &mut I) -> Result<Self, headers::Error>
where
Self: Sized,
I: Iterator<Item = &'i header::HeaderValue>,
{
Ok(Self(
values
.next()
.and_then(|v| v.to_str().ok())
.ok_or(headers::Error::invalid())?
.to_string(),
))
}
fn encode<E: Extend<header::HeaderValue>>(&self, _: &mut E) {}
}
pub trait LogError {
fn log_warn(self);
fn log_err(self);
fn log_debug(self);
}
impl<T, E> LogError for Result<T, E>
where
E: Display,
{
fn log_warn(self) {
if let Err(err) = self {
log::warn!("{err}");
}
}
fn log_err(self) {
if let Err(err) = self {
log::error!("{err}");
}
}
fn log_debug(self) {
if let Err(err) = self {
log::debug!("{err}")
}
}
}

View File

@ -0,0 +1,98 @@
use core::time::Duration;
use thiserror::Error;
use werewolves_proto::{
error::GameError,
message::{ClientMessage, Identification, host::HostMessage},
player::PlayerId,
};
use crate::{
communication::lobby::LobbyComms,
connection::JoinedPlayers,
game::{GameEnd, GameRunner},
lobby::Lobby,
saver::Saver,
};
#[derive(Debug, Error)]
enum GameOrSendError<T> {
#[error("game error: {0}")]
GameError(#[from] GameError),
#[error("send error: {0}")]
SendError(#[from] tokio::sync::mpsc::error::SendError<T>),
}
#[derive(Debug, Clone, PartialEq)]
pub struct IdentifiedClientMessage {
pub identity: Identification,
pub message: ClientMessage,
}
pub async fn run_game(joined_players: JoinedPlayers, comms: LobbyComms, mut saver: impl Saver) {
let mut state = RunningState::Lobby(Lobby::new(joined_players, comms));
loop {
match &mut state {
RunningState::Lobby(lobby) => {
if let Some(game) = lobby.next().await {
state = RunningState::Game(game)
}
}
RunningState::Game(game) => {
if let Some(result) = game.next().await {
match saver.save(game.proto_game()) {
Ok(path) => {
log::info!("saved game to {path}");
}
Err(err) => {
log::error!("saving game: {err}");
let game_clone = game.proto_game().clone();
let mut saver_clone = saver.clone();
tokio::spawn(async move {
let started = chrono::Utc::now();
loop {
tokio::time::sleep(Duration::from_secs(30)).await;
match saver_clone.save(&game_clone) {
Ok(path) => {
log::info!("saved game from {started} to {path}");
return;
}
Err(err) => {
log::error!("saving game from {started}: {err}")
}
}
}
});
}
}
state = match state {
RunningState::Game(game) => {
RunningState::GameOver(GameEnd::new(game, result))
}
_ => unsafe { core::hint::unreachable_unchecked() },
};
}
}
RunningState::GameOver(end) => {
if let Some(mut new_lobby) = end.next().await {
new_lobby.send_lobby_info_to_clients().await;
state = RunningState::Lobby(new_lobby)
}
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Message {
Host(HostMessage),
Client(IdentifiedClientMessage),
Connect(PlayerId),
Disconnect(PlayerId),
}
pub enum RunningState {
Lobby(Lobby),
Game(GameRunner),
GameOver(GameEnd),
}

View File

@ -0,0 +1,49 @@
use core::fmt::Display;
use std::{io::Write, path::PathBuf};
use thiserror::Error;
use werewolves_proto::game::Game;
pub trait Saver: Clone + Send + 'static {
type Error: Display;
fn save(&mut self, game: &Game) -> Result<String, Self::Error>;
}
#[derive(Debug, Error)]
pub enum FileSaverError {
#[error("io error: {0}")]
IoError(std::io::Error),
#[error("serialization error")]
SerializationError(#[from] serde_json::Error),
}
impl From<std::io::Error> for FileSaverError {
fn from(value: std::io::Error) -> Self {
Self::IoError(value)
}
}
#[derive(Debug, Clone)]
pub struct FileSaver {
path: PathBuf,
}
impl FileSaver {
pub const fn new(path: PathBuf) -> Self {
Self { path }
}
}
impl Saver for FileSaver {
type Error = FileSaverError;
fn save(&mut self, game: &Game) -> Result<String, Self::Error> {
let name = format!("werewolves_{}.json", chrono::Utc::now().timestamp());
let path = self.path.join(name.clone());
let mut file = std::fs::File::create_new(path.clone())?;
serde_json::to_writer_pretty(&mut file, &game)?;
file.flush()?;
Ok(path.to_str().map(|s| s.to_string()).unwrap_or(name))
}
}

View File

@ -0,0 +1,2 @@
[target.wasm32-unknown-unknown]
rustflags = ['--cfg', 'getrandom_backend="wasm_js"']

1572
werewolves/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

42
werewolves/Cargo.toml Normal file
View File

@ -0,0 +1,42 @@
[package]
name = "werewolves"
version = "0.1.0"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
web-sys = { version = "0.3", features = [
"HtmlTableCellElement",
"Event",
"EventTarget",
"HtmlImageElement",
"HtmlDivElement",
"HtmlSelectElement",
] }
log = "0.4"
rand = { version = "0.9", features = ["small_rng"] }
getrandom = { version = "0.3", features = ["wasm_js"] }
uuid = { version = "*", features = ["js"] }
yew = { version = "0.21", features = ["csr"] }
yew-router = "0.18.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", optional = true }
gloo = "0.11"
wasm-logger = "0.2"
instant = { version = "0.1", features = ["wasm-bindgen"] }
once_cell = "1"
chrono = { version = "0.4" }
werewolves-macros = { path = "../werewolves-macros" }
werewolves-proto = { path = "../werewolves-proto" }
futures = "0.3.31"
wasm-bindgen-futures = "0.4.50"
thiserror = { version = "2" }
convert_case = { version = "0.8" }
ciborium = { version = "0.2", optional = true }
[features]
# default = ["cbor"]
default = ["json"]
cbor = ["dep:ciborium"]
json = ["dep:serde_json"]

13
werewolves/Trunk.toml Normal file
View File

@ -0,0 +1,13 @@
[build]
target = "index.html" # The index HTML file to drive the bundling process.
html_output = "index.html" # The name of the output HTML file.
release = true # Build in release mode.
dist = "dist" # The output dir for all final assets.
public_url = "/" # The public URL from which assets are to be served.
filehash = true # Whether to include hash values in the output file names.
inject_scripts = true # Whether to inject scripts (and module preloads) into the finalized output.
offline = false # Run without network access
frozen = false # Require Cargo.lock and cache are up to date
locked = false # Require Cargo.lock is up to date
minify = "always" # Control minification: can be one of: never, on_release, always
no_sri = false # Allow disabling sub-resource integrity (SRI)

BIN
werewolves/img/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

27
werewolves/index.html Normal file
View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>website</title>
<link rel="icon" href="/img/puffidle.webp" />
<link data-trunk rel="sass" href="index.scss" />
<link data-trunk rel="copy-dir" href="img">
<link data-trunk rel="copy-dir" href="assets">
</head>
<body>
<app></app>
<clients>
<dupe1></dupe1>
<dupe2></dupe2>
<dupe3></dupe3>
<dupe4></dupe4>
<dupe5></dupe5>
<dupe6></dupe6>
</clients>
<error></error>
</body>
</html>

1018
werewolves/index.scss Normal file

File diff suppressed because it is too large Load Diff

19
werewolves/src/app.rs Normal file
View File

@ -0,0 +1,19 @@
use std::collections::HashMap;
use yew::prelude::*;
use yew_router::{
BrowserRouter, Router, Switch,
history::{AnyHistory, History, MemoryHistory},
};
use crate::pages::*;
#[function_component]
pub fn App() -> Html {
html! {
<main>
// <BrowserRouter>
// </BrowserRouter>
</main>
}
}

1
werewolves/src/assets.rs Normal file
View File

@ -0,0 +1 @@
werewolves_macros::static_links!("werewolves/assets" relative to "werewolves/");

View File

@ -0,0 +1,45 @@
use core::fmt::Debug;
use std::sync::Arc;
use futures::SinkExt;
use yew::{html::Scope, prelude::*};
pub fn send_message<T: Clone + Debug + 'static, P>(
msg: T,
send: futures::channel::mpsc::Sender<T>,
) -> Callback<P> {
Callback::from(move |_| {
let mut send = send.clone();
let msg = msg.clone();
yew::platform::spawn_local(async move {
if let Err(err) = send.send(msg.clone()).await {
log::error!("sending message <({msg:?})> in callback: {err}")
};
});
})
}
pub fn mouse_event<F>(inner: F) -> Callback<MouseEvent>
where
F: Fn() + 'static,
{
Callback::from(move |_| (inner)())
}
pub fn send_fn<T, P, F>(msg_fn: F, send: futures::channel::mpsc::Sender<T>) -> Callback<P>
where
T: Clone + Debug + 'static,
F: Fn(P) -> T + 'static,
P: 'static,
{
let msg_fn = Arc::new(msg_fn);
Callback::from(move |param| {
let mut send = send.clone();
let msg_fn = msg_fn.clone();
yew::platform::spawn_local(async move {
if let Err(err) = send.send((msg_fn)(param)).await {
log::error!("sending message in callback: {err}")
};
});
})
}

View File

@ -0,0 +1,109 @@
use core::ops::Not;
use werewolves_proto::{
message::{
PublicIdentity, Target,
host::{HostGameMessage, HostMessage, HostNightMessage},
night::{ActionPrompt, ActionResponse},
},
player::CharacterId,
};
use yew::prelude::*;
use crate::components::{
Identity,
action::{SingleTarget, WolvesIntro},
};
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct ActionPromptProps {
pub prompt: ActionPrompt,
pub ident: PublicIdentity,
#[prop_or_default]
pub big_screen: bool,
pub on_complete: Callback<HostMessage>,
}
#[function_component]
pub fn Prompt(props: &ActionPromptProps) -> Html {
let ident = props
.big_screen
.not()
.then(|| html! {<Identity ident={props.ident.clone()}/>});
match &props.prompt {
ActionPrompt::WolvesIntro { wolves } => {
let on_complete = props.on_complete.clone();
let on_complete = Callback::from(move |_| {
on_complete.emit(HostMessage::InGame(
werewolves_proto::message::host::HostGameMessage::Night(
werewolves_proto::message::host::HostNightMessage::ActionResponse(
werewolves_proto::message::night::ActionResponse::WolvesIntroAck,
),
),
))
});
html! {
<WolvesIntro big_screen={props.big_screen} on_complete={on_complete} wolves={wolves.clone()}/>
}
}
ActionPrompt::Seer { living_players } => {
let on_complete = props.on_complete.clone();
let on_select = Callback::from(move |target: CharacterId| {
on_complete.emit(HostMessage::InGame(HostGameMessage::Night(
HostNightMessage::ActionResponse(ActionResponse::Seer(target)),
)));
});
html! {
<div>
{ident}
<SingleTarget
targets={living_players.clone()}
on_select={on_select}
headline={"check alignment"}
/>
</div>
}
}
ActionPrompt::RoleChange { new_role } => {
let on_complete = props.on_complete.clone();
let on_click = Callback::from(move |_| {
on_complete.emit(HostMessage::InGame(HostGameMessage::Night(
HostNightMessage::ActionResponse(ActionResponse::RoleChangeAck),
)))
});
let cont = props.big_screen.not().then(|| {
html! {
<button onclick={on_click}>{"continue"}</button>
}
});
html! {
<div>
{ident}
<h2>{"your role has changed"}</h2>
<p>{new_role.to_string()}</p>
{cont}
</div>
}
}
ActionPrompt::Protector { targets } => todo!(),
ActionPrompt::Arcanist { living_players } => todo!(),
ActionPrompt::Gravedigger { dead_players } => todo!(),
ActionPrompt::Hunter {
current_target,
living_players,
} => todo!(),
ActionPrompt::Militia { living_players } => todo!(),
ActionPrompt::MapleWolf {
kill_or_die,
living_players,
} => todo!(),
ActionPrompt::Guardian {
previous,
living_players,
} => todo!(),
ActionPrompt::WolfPackKill { living_villagers } => todo!(),
ActionPrompt::Shapeshifter => todo!(),
ActionPrompt::AlphaWolf { living_villagers } => todo!(),
ActionPrompt::DireWolf { living_players } => todo!(),
}
}

View File

@ -0,0 +1,96 @@
use core::ops::Not;
use werewolves_proto::{
message::{
PublicIdentity,
host::{HostGameMessage, HostMessage, HostNightMessage},
night::{ActionPrompt, ActionResponse, ActionResult},
},
role::Alignment,
};
use yew::prelude::*;
use crate::components::{CoverOfDarkness, Identity};
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct ActionResultProps {
pub result: ActionResult,
pub ident: PublicIdentity,
#[prop_or_default]
pub big_screen: bool,
pub on_complete: Callback<HostMessage>,
}
#[function_component]
pub fn ActionResultView(props: &ActionResultProps) -> Html {
let ident = props
.big_screen
.not()
.then(|| html! {<Identity ident={props.ident.clone()}/>});
let on_complete = props.on_complete.clone();
let on_complete = Callback::from(move |_| {
on_complete.emit(HostMessage::InGame(HostGameMessage::Night(
HostNightMessage::Next,
)))
});
let cont = props
.big_screen
.not()
.then(|| html! {<button onclick={on_complete}>{"continue"}</button>});
match &props.result {
ActionResult::RoleBlocked => {
html! {
<div class="result">
{ident}
<h2>{"you were role blocked"}</h2>
{cont}
</div>
}
}
ActionResult::Seer(alignment) => html! {
<div class="result">
{ident}
<h2>{"the alignment was"}</h2>
<p>{match alignment {
Alignment::Village => "village",
Alignment::Wolves => "wolfpack",
}}</p>
{cont}
</div>
},
ActionResult::Arcanist { same } => todo!(),
ActionResult::GraveDigger(role_title) => todo!(),
ActionResult::WolvesMustBeUnanimous => todo!(),
ActionResult::WaitForOthersToVote => todo!(),
ActionResult::GoBackToSleep => {
let next = props.big_screen.not().then(|| {
let on_complete = props.on_complete.clone();
move |_| {
on_complete.emit(HostMessage::InGame(HostGameMessage::Night(
HostNightMessage::Next,
)))
}
});
html! {
<CoverOfDarkness message={"go to sleep"} next={next}>
{"continue"}
</CoverOfDarkness>
}
}
ActionResult::RoleRevealDone => todo!(),
ActionResult::WolvesIntroDone => {
let on_complete = props.on_complete.clone();
let next = props.big_screen.not().then(|| {
Callback::from(move |_| {
on_complete.emit(HostMessage::InGame(HostGameMessage::Night(
HostNightMessage::Next,
)))
})
});
html! {
<CoverOfDarkness message={"wolves go to sleep"} next={next}/>
}
}
}
}

View File

@ -0,0 +1,112 @@
use core::ops::Not;
use werewolves_proto::{message::Target, player::CharacterId};
use yew::{html::Scope, prelude::*};
use crate::components::Identity;
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct SingleTargetProps {
pub targets: Box<[Target]>,
#[prop_or_default]
pub headline: &'static str,
#[prop_or_default]
pub read_only: bool,
pub on_select: Callback<CharacterId>,
}
pub struct SingleTarget {
selected: Option<CharacterId>,
}
impl Component for SingleTarget {
type Message = CharacterId;
type Properties = SingleTargetProps;
fn create(_: &Context<Self>) -> Self {
Self { selected: None }
}
fn view(&self, ctx: &Context<Self>) -> Html {
let SingleTargetProps { read_only, headline, targets, on_select } = ctx.props();
let scope = ctx.link().clone();
let card_select = Callback::from(move |target| {
scope.send_message(target);
});
let targets = targets.iter().map(|t| {
html!{
<TargetCard
target={t.clone()}
selected={self.selected.as_ref().map(|sel| sel == &t.character_id).unwrap_or_default()}
on_select={card_select.clone()}
/>
}
}).collect::<Html>();
let headline = if headline.trim().is_empty() {
html!()
} else {
html!(<h2>{headline}</h2>)
};
let on_select = on_select.clone();
let on_click = if let Some(target) = self.selected.clone() {
Callback::from(move |_| on_select.emit(target.clone()))
} else {
Callback::from(|_| ())
};
let submit = read_only.not().then(|| html!{
<div class="button-container sp-ace">
<button disabled={self.selected.is_none()} onclick={on_click}>{"submit"}</button>
</div>
});
html!{
<div class="column-list">
{headline}
<div class="row-list">
{targets}
</div>
{submit}
</div>
}
}
fn update(&mut self, _: &Context<Self>, msg: Self::Message) -> bool {
if let Some(selected) = self.selected.as_ref() {
if selected == &msg {
self.selected = None;
} else {
self.selected = Some(msg);
}
} else {
self.selected = Some(msg);
}
true
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct TargetCardProps {
pub target: Target,
pub selected: bool,
pub on_select: Callback<CharacterId>,
}
#[function_component]
fn TargetCard(props: &TargetCardProps) -> Html {
let on_select = props.on_select.clone();
let target = props.target.character_id.clone();
let on_click = Callback::from(move |_| {
on_select.emit(target.clone());
});
html! {
<div
class={classes!("target-card", "character", props.selected.then_some("selected"))}
onclick={on_click}
>
<Identity ident={props.target.public.clone()} />
</div>
}
}

View File

@ -0,0 +1,42 @@
use werewolves_proto::{message::Target, role::RoleTitle};
use yew::prelude::*;
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct WolvesIntroProps {
pub wolves: Box<[(Target, RoleTitle)]>,
pub big_screen: bool,
pub on_complete: Callback<()>,
}
#[function_component]
pub fn WolvesIntro(props: &WolvesIntroProps) -> Html {
let on_complete = props.on_complete.clone();
let on_complete = Callback::from(move |_| on_complete.emit(()));
html! {
<div class="wolves-intro">
<h2>{"these are the wolves:"}</h2>
{
if props.big_screen {
html!()
} else {
html!{
<button onclick={on_complete}>{"continue"}</button>
}
}
}
{
props.wolves.iter().map(|w| html!{
<div class={"character wolves"}>
<h2 class="role">{w.1.to_string()}</h2>
<p class="name">{w.0.public.name.clone()}</p>
{
w.0.public.pronouns.as_ref().map(|p| html!{
<p class="pronouns">{"("}{p.as_str()}{")"}</p>
}).unwrap_or(html!())
}
</div>
}).collect::<Html>()
}
</div>
}
}

View File

@ -0,0 +1,28 @@
use yew::prelude::*;
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct ButtonProperties {
pub on_click: Callback<()>,
#[prop_or_default]
pub disabled_reason: Option<String>,
#[prop_or_default]
pub children: Html,
}
#[function_component]
pub fn Button(props: &ButtonProperties) -> Html {
let on_click = props.on_click.clone();
let on_click = Callback::from(move |_| on_click.emit(()));
html! {
<div class="button-container">
<button
class="default-button"
disabled={props.disabled_reason.is_some()}
reason={props.disabled_reason.clone()}
onclick={on_click}
>
{props.children.clone()}
</button>
</div>
}
}

View File

@ -0,0 +1,34 @@
use yew::prelude::*;
use crate::components::Button;
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct CoverOfDarknessProps {
#[prop_or_default]
pub next: Option<Callback<()>>,
#[prop_or_else(|| String::from("night falls"))]
pub message: String,
#[prop_or_else(|| html!("begin"))]
pub children: Html,
}
#[function_component]
pub fn CoverOfDarkness(
CoverOfDarknessProps {
message,
next,
children,
}: &CoverOfDarknessProps,
) -> Html {
let next = next.as_ref().map(|next| {
html! {
<Button on_click={next}>{children.clone()}</Button>
}
});
html! {
<div class="cover-of-darkness">
<p>{message}</p>
{next}
</div>
}
}

View File

@ -0,0 +1,107 @@
use core::ops::Not;
use werewolves_proto::{message::CharacterState, player::CharacterId};
use yew::prelude::*;
use crate::components::{Button, Identity};
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct DaytimePlayerListProps {
pub characters: Box<[CharacterState]>,
pub marked: Box<[CharacterId]>,
pub on_execute: Callback<()>,
pub on_mark: Callback<CharacterId>,
pub big_screen: bool,
}
#[function_component]
pub fn DaytimePlayerList(
DaytimePlayerListProps {
characters,
on_execute,
on_mark,
marked,
big_screen,
}: &DaytimePlayerListProps,
) -> Html {
let on_select = big_screen.not().then(|| on_mark.clone());
let chars = characters
.iter()
.map(|c| {
html! {
<DaytimePlayer
character={c.clone()}
on_the_block={marked.contains(&c.character_id)}
on_select={on_select.clone()}
/>
}
})
.collect::<Html>();
let button = big_screen.not().then(|| {
html! {
<Button
on_click={on_execute}
disabled_reason={
marked.is_empty()
.then_some(String::from("no one is on the block"))
}
>
{"execute"}
</Button>
}
});
html! {
<div class="column-list">
<div class="row-list small baseline player-list gap">
{chars}
</div>
{button}
</div>
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct DaytimePlayerProps {
pub character: CharacterState,
pub on_the_block: bool,
pub on_select: Option<Callback<CharacterId>>,
}
#[function_component]
pub fn DaytimePlayer(
DaytimePlayerProps {
on_select,
on_the_block,
character:
CharacterState {
player_id: _,
character_id,
public_identity,
role: _,
died_to,
},
}: &DaytimePlayerProps,
) -> Html {
let dead = died_to.is_some().then_some("dead");
let button_text = if *on_the_block { "unmark" } else { "mark" };
let on_the_block = on_the_block.then_some("on-the-block");
let submenu = died_to.is_none().then_some(()).and_then(|_| {
on_select.as_ref().map(|on_select| {
let character_id = character_id.clone();
let on_select = on_select.clone();
let on_click = Callback::from(move |_| on_select.emit(character_id.clone()));
html! {
<nav class="submenu">
<Button on_click={on_click}>{button_text}</Button>
</nav>
}
})
});
html! {
<div class={classes!("player", dead, on_the_block, "column-list", "ident")}>
<Identity ident={public_identity.clone()}/>
{submenu}
</div>
}
}

View File

@ -0,0 +1,31 @@
use werewolves_proto::message::PublicIdentity;
use yew::prelude::*;
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct IdentityProps {
pub ident: PublicIdentity,
}
#[function_component]
pub fn Identity(props: &IdentityProps) -> Html {
let IdentityProps {
ident:
PublicIdentity {
name,
pronouns,
number,
},
} = props;
let pronouns = pronouns.as_ref().map(|p| {
html! {
<p class="pronouns">{"("}{p}{")"}</p>
}
});
html! {
<div class="identity">
<p class="number"><b>{number.get()}</b></p>
<p>{name}</p>
{pronouns}
</div>
}
}

View File

@ -0,0 +1,73 @@
use core::num::NonZeroU8;
use web_sys::{HtmlInputElement, HtmlSelectElement, wasm_bindgen::JsCast};
use werewolves_proto::message::PublicIdentity;
use yew::prelude::*;
#[derive(Debug, PartialEq, Properties)]
pub struct InputProps {
#[prop_or_default]
pub initial_value: PublicIdentity,
pub callback: Callback<PublicIdentity, ()>,
}
#[function_component]
pub fn InputName(props: &InputProps) -> Html {
let callback = props.callback.clone();
let on_click = Callback::from(move |_| {
let name = gloo::utils::document()
.query_selector(".identity-input #name")
.expect("cannot find name input")
.and_then(|e| e.dyn_into::<HtmlInputElement>().ok())
.expect("name input element not HtmlInputElement")
.value()
.trim()
.to_string();
let pronouns = match gloo::utils::document()
.query_selector(".identity-input #pronouns")
.expect("cannot find pronouns input")
.and_then(|e| e.dyn_into::<HtmlInputElement>().ok())
.expect("pronouns input element not HtmlInputElement")
.value()
.trim()
{
"" => None,
p => Some(p.to_string()),
};
let number = gloo::utils::document()
.query_selector(".identity-input #number")
.expect("cannot find number input")
.and_then(|e| e.dyn_into::<HtmlSelectElement>().ok())
.expect("number input element not HtmlSelectElement")
.value()
.trim()
.parse::<NonZeroU8>()
.expect("parse number");
if name.is_empty() {
return;
}
callback.emit(PublicIdentity {
name,
pronouns,
number,
});
});
html! {
<div class="identity-input">
<label for="name">{"Name"}</label>
<input name="name" id="name" type="text" value={props.initial_value.name.clone()}/>
<label for="pronouns">{"Pronouns"}</label>
<input name="pronouns" id="pronouns" type="text"
value={props.initial_value.pronouns.clone().unwrap_or_default()}/>
<label for="number">{"Number"}</label>
<select name="number" id="number">
{
(1..=0xFFu8).into_iter().map(|i| html!{
<option value={i.to_string()}>{i}</option>
}).collect::<Html>()
}
</select>
<button onclick={on_click}>{"Submit"}</button>
</div>
}
}

View File

@ -0,0 +1,30 @@
use werewolves_proto::{message::PlayerState, player::PlayerId};
use yew::prelude::*;
use crate::components::{LobbyPlayer, LobbyPlayerAction};
#[derive(Debug, PartialEq, Properties)]
pub struct LobbyProps {
pub players: Box<[PlayerState]>,
#[prop_or_default]
pub on_action: Option<Callback<(PlayerId, LobbyPlayerAction)>>,
}
#[function_component]
pub fn Lobby(LobbyProps { players, on_action }: &LobbyProps) -> Html {
let mut players = players.clone();
players.sort_by_key(|f| f.identification.public.number.get());
html! {
<div class="column-list">
<p style="text-align: center;">{format!("Players in lobby: {}", players.len())}</p>
<div class="row-list small baseline player-list gap">
{
players
.into_iter()
.map(|p| html! {<LobbyPlayer on_action={on_action} player={p} />})
.collect::<Html>()
}
</div>
</div>
}
}

View File

@ -0,0 +1,50 @@
use web_sys::{HtmlDivElement, HtmlElement};
use werewolves_proto::{message::PlayerState, player::PlayerId};
use yew::prelude::*;
use crate::components::{Button, Identity};
#[derive(Debug, PartialEq, Properties)]
pub struct LobbyPlayerProps {
pub player: PlayerState,
#[prop_or_default]
pub on_action: Option<Callback<(PlayerId, LobbyPlayerAction)>>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LobbyPlayerAction {
Kick,
}
#[function_component]
pub fn LobbyPlayer(LobbyPlayerProps { player, on_action }: &LobbyPlayerProps) -> Html {
let class = if player.connected {
"connected"
} else {
"disconnected"
};
let pid = player.identification.player_id.clone();
let action = |action: LobbyPlayerAction| {
let pid = pid.clone();
if let Some(on_action) = on_action.as_ref() {
let on_action = on_action.clone();
Callback::from(move |_| on_action.emit((pid.clone(), action)))
} else {
Callback::noop()
}
};
let submenu = on_action.is_some().then(|| {
html! {
<nav class="submenu">
<Button on_click={(action)(LobbyPlayerAction::Kick)}>{"Kick"}</Button>
</nav>
}
});
html! {
<div class={classes!("player", class, "column-list", "ident")}>
<Identity ident={player.identification.public.clone()}/>
{submenu}
</div>
}
}

View File

@ -0,0 +1,19 @@
use yew::prelude::*;
#[derive(Debug, PartialEq, Properties)]
pub struct NotificationProps {
pub text: String,
pub callback: Callback<()>,
}
#[function_component]
pub fn Notification(props: &NotificationProps) -> Html {
let cb = props.callback.clone();
let on_click = Callback::from(move |_| cb.clone().emit(()));
html! {
<stack>
<h2>{props.text.clone()}</h2>
<button class="confirm" onclick={on_click}>{"Ok"}</button>
</stack>
}
}

View File

@ -0,0 +1,86 @@
use werewolves_proto::message::Target;
use yew::prelude::*;
#[derive(Debug, PartialEq, Properties)]
pub struct RoleRevealProps {
pub ackd: Box<[Target]>,
pub waiting: Box<[Target]>,
pub on_force_ready: Callback<Target>,
}
pub struct RoleReveal {}
impl Component for RoleReveal {
type Message = Target;
type Properties = RoleRevealProps;
fn create(_ctx: &Context<Self>) -> Self {
Self {}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let RoleRevealProps {
ackd,
waiting,
on_force_ready,
} = ctx.props();
let on_force_ready = on_force_ready.clone();
let cards = ackd
.iter()
.map(|t| (t, true))
.chain(waiting.iter().map(|t| (t, false)))
.map(|(t, ready)| {
html! {
<RoleRevealCard
target={t.clone()}
is_ready={ready}
on_force_ready={on_force_ready.clone()}
/>
}
})
.collect::<Html>();
let nack = waiting.clone();
let on_force_all = Callback::from(move |_| {
for t in nack.clone() {
on_force_ready.emit(t);
}
});
html! {
<div class={"role-reveal-cards"}>
<button onclick={on_force_all}>{"force ready all"}</button>
{cards}
</div>
}
}
}
#[derive(Debug, PartialEq, Properties)]
pub struct RoleRevealCardProps {
pub target: Target,
pub is_ready: bool,
pub on_force_ready: Callback<Target>,
}
#[function_component]
pub fn RoleRevealCard(props: &RoleRevealCardProps) -> Html {
let class = if props.is_ready { "ready" } else { "not-ready" };
let target = props.target.clone();
let on_force_ready = props.on_force_ready.clone();
let on_click = Callback::from(move |_| {
on_force_ready.emit(target.clone());
});
html! {
<div class={classes!(class, "role-reveal-card")}>
<p>{props.target.public.name.as_str()}</p>
{
if !props.is_ready {
html! {<button onclick={on_click}>{"force ready"}</button>}
} else {
html!{}
}
}
</div>
}
}

View File

@ -0,0 +1,169 @@
use convert_case::{Case, Casing};
use web_sys::HtmlInputElement;
use werewolves_proto::{
error::GameError,
game::GameSettings,
role::{Alignment, RoleTitle},
};
use yew::prelude::*;
const ALIGN_VILLAGE: &str = "village";
const ALIGN_WOLVES: &str = "wolves";
#[derive(Debug, PartialEq, Properties)]
pub struct SettingsProps {
pub settings: GameSettings,
pub players_in_lobby: usize,
pub on_update: Callback<GameSettings>,
pub on_start: Callback<()>,
#[prop_or_default]
pub on_error: Option<Callback<GameError>>,
}
fn get_role_count(settings: &GameSettings, role: RoleTitle) -> u8 {
match role {
RoleTitle::Villager => {
panic!("villager should not be in the settings page")
}
_ => settings
.roles()
.into_iter()
.find_map(|(r, cnt)| (r == role).then_some(cnt.get()))
.unwrap_or_default(),
}
}
enum AmountChange {
Increment(RoleTitle),
Decrement(RoleTitle),
}
#[function_component]
pub fn Settings(props: &SettingsProps) -> Html {
let on_update = props.on_update.clone();
let (start_game_disabled, reason) = match props.settings.check() {
Ok(_) => {
if props.players_in_lobby < props.settings.min_players_needed() {
(true, String::from("too few players for role setup"))
} else {
(false, String::new())
}
}
Err(err) => (true, err.to_string()),
};
let settings = props.settings.clone();
let on_error = props.on_error.clone();
let on_changed = Callback::from(move |change: AmountChange| {
let mut s = settings.clone();
match change {
AmountChange::Increment(role) => {
if let Err(err) = s.add(role)
&& let Some(on_error) = on_error.as_ref()
{
on_error.emit(err);
}
}
AmountChange::Decrement(role) => s.sub(role),
}
if s != settings {
on_update.emit(s)
}
});
let on_start = props.on_start.clone();
let on_start_game = Callback::from(move |_| on_start.emit(()));
let roles = RoleTitle::ALL
.into_iter()
.filter(|r| !matches!(r, RoleTitle::Scapegoat | RoleTitle::Villager))
.map(|r| html! {<RoleCard role={r} amount={get_role_count(&props.settings, r)} on_changed={on_changed.clone()}/>})
.collect::<Html>();
html! {
<div class="settings">
<h2>{format!("Min players for settings: {}", props.settings.min_players_needed())}</h2>
<div class="role-list">
// <BoolRoleCard role={RoleTitle::Scapegoat} enabled={props.settings.scapegoat} on_changed={on_bool_changed}/>
{roles}
</div>
<button reason={reason} disabled={start_game_disabled} class="start-game" onclick={on_start_game}>{"Start Game"}</button>
</div>
}
}
enum BoolRoleSet {
Scapegoat { enabled: bool },
}
#[derive(Debug, PartialEq, Properties)]
struct BoolRoleProps {
pub role: RoleTitle,
pub enabled: bool,
pub on_changed: Callback<BoolRoleSet>,
}
#[function_component]
fn BoolRoleCard(props: &BoolRoleProps) -> Html {
let align_class = if props.role.wolf() {
ALIGN_WOLVES
} else {
ALIGN_VILLAGE
};
let set_role = match props.role {
RoleTitle::Scapegoat => |enabled| BoolRoleSet::Scapegoat { enabled },
_ => panic!("invalid role for bool card: {}", props.role),
};
log::warn!("Role: {} | {};", props.role, props.enabled);
let enabled = props.enabled;
let role = props.role;
let cb = props.on_changed.clone();
let on_click = Callback::from(move |ev: MouseEvent| {
let input = ev
.target_dyn_into::<HtmlInputElement>()
.expect("input callback not on input");
cb.emit(set_role(input.checked()))
});
html! {
<div class={classes!("role-card", align_class)}>
// <div>
<bool_spacer/>
<bool_role>
<role>{role.to_string()}</role>
<input onclick={on_click} type="checkbox" checked={enabled}/>
</bool_role>
// </div>
</div>
}
}
#[derive(Debug, PartialEq, Properties)]
struct RoleProps {
pub role: RoleTitle,
pub amount: u8,
pub on_changed: Callback<AmountChange>,
}
#[function_component]
fn RoleCard(props: &RoleProps) -> Html {
let align_class = if props.role.wolf() {
ALIGN_WOLVES
} else {
ALIGN_VILLAGE
};
let amount = props.amount;
let role = props.role;
let role_name = role.to_string().to_case(Case::Title);
let cb = props.on_changed.clone();
let decrease = Callback::from(move |_| cb.emit(AmountChange::Decrement(role)));
let cb = props.on_changed.clone();
let increase = Callback::from(move |_| cb.emit(AmountChange::Increment(role)));
html! {
<div class={classes!("role-card", align_class)}>
<button onclick={decrease}>{"-"}</button>
<rolecard><role>{role_name}</role><count>{amount.to_string()}</count></rolecard>
<button onclick={increase}>{"+"}</button>
</div>
}
}

106
werewolves/src/main.rs Normal file
View File

@ -0,0 +1,106 @@
mod assets;
mod storage;
mod components {
werewolves_macros::include_path!("werewolves/src/components");
pub mod host;
pub mod action {
werewolves_macros::include_path!("werewolves/src/components/action");
}
}
mod pages {
werewolves_macros::include_path!("werewolves/src/pages");
}
mod callback;
use core::num::NonZeroU8;
use pages::{Client, ErrorComponent, Host, WerewolfError};
use web_sys::{Element, HtmlElement, Url, wasm_bindgen::JsCast};
use werewolves_proto::{
message::{Identification, PublicIdentity},
player::PlayerId,
};
use yew::prelude::*;
use crate::pages::ClientProps;
fn main() {
wasm_logger::init(wasm_logger::Config::new(log::Level::Trace));
let document = gloo::utils::document();
let url = document.document_uri().expect("get uri");
let url_obj = Url::new(&url).unwrap();
let path = url_obj.pathname();
log::warn!("path: {path}");
let app_element = document.query_selector("app").unwrap().unwrap();
let error_element = document.query_selector("error").unwrap().unwrap();
let ec = yew::Renderer::<ErrorComponent>::with_root(error_element).render();
let cb_clone = ec.clone();
let error_callback =
Callback::from(move |err: Option<WerewolfError>| cb_clone.send_message(err));
if path.starts_with("/host") {
let host = yew::Renderer::<Host>::with_root(app_element).render();
host.send_message(pages::HostEvent::SetErrorCallback(error_callback));
if path.starts_with("/host/big") {
host.send_message(pages::HostEvent::SetBigScreenState(true));
}
} else if path.starts_with("/many-client") {
let mut number = 1..=0xFFu8;
for (player_id, name, dupe) in [
(
PlayerId::from_u128(1),
"player 1",
document.query_selector("app").unwrap().unwrap(),
),
(
PlayerId::from_u128(2),
"player 2",
document.query_selector("dupe1").unwrap().unwrap(),
),
(
PlayerId::from_u128(3),
"player 3",
document.query_selector("dupe2").unwrap().unwrap(),
),
(
PlayerId::from_u128(4),
"player 4",
document.query_selector("dupe3").unwrap().unwrap(),
),
(
PlayerId::from_u128(5),
"player 5",
document.query_selector("dupe4").unwrap().unwrap(),
),
(
PlayerId::from_u128(6),
"player 6",
document.query_selector("dupe5").unwrap().unwrap(),
),
(
PlayerId::from_u128(7),
"player 7",
document.query_selector("dupe6").unwrap().unwrap(),
),
] {
let client =
yew::Renderer::<Client>::with_root_and_props(dupe, ClientProps { auto_join: true })
.render();
client.send_message(pages::Message::ForceIdentity(Identification {
player_id,
public: PublicIdentity {
name: name.to_string(),
pronouns: Some(String::from("he/him")),
number: NonZeroU8::new(number.next().unwrap()).unwrap(),
},
}));
client.send_message(pages::Message::SetErrorCallback(error_callback.clone()));
}
} else {
let client = yew::Renderer::<Client>::with_root_and_props(
app_element,
ClientProps { auto_join: false },
)
.render();
client.send_message(pages::Message::SetErrorCallback(error_callback));
}
}

View File

@ -0,0 +1,534 @@
use core::{sync::atomic::AtomicBool, time::Duration};
use std::collections::VecDeque;
use futures::{
SinkExt, StreamExt,
channel::mpsc::{Receiver, Sender},
};
use gloo::{
net::websocket::{self, futures::WebSocket},
storage::{LocalStorage, Storage, errors::StorageError},
};
use serde::Serialize;
use werewolves_proto::{
error::GameError,
game::GameOver,
message::{
ClientMessage, DayCharacter, Identification, PlayerUpdate, PublicIdentity, ServerMessage,
Target,
night::{ActionPrompt, ActionResponse, ActionResult},
},
player::{Character, CharacterId, PlayerId},
role::RoleTitle,
};
use yew::{html::Scope, prelude::*};
use crate::{
components::{InputName, Notification},
storage::StorageKey,
};
use super::WerewolfError;
const DEBUG_URL: &str = "ws://192.168.1.162:8080/connect/client";
const LIVE_URL: &str = "wss://wolf.emilis.dev/connect/client";
#[derive(Debug)]
pub enum Message {
SetErrorCallback(Callback<Option<WerewolfError>>),
SetPublicIdentity(PublicIdentity),
RecvServerMessage(ServerMessage),
Connect,
ForceIdentity(Identification),
}
pub struct Connection {
scope: Scope<Client>,
ident: Identification,
recv: Receiver<ClientMessage>,
}
impl Connection {
async fn connect_ws() -> WebSocket {
let url = option_env!("DEBUG").map(|_| DEBUG_URL).unwrap_or(LIVE_URL);
loop {
match WebSocket::open(url) {
Ok(ws) => break ws,
Err(err) => {
log::error!("connect: {err}");
yew::platform::time::sleep(Duration::from_secs(1)).await;
}
}
}
}
fn encode_message(msg: &impl Serialize) -> websocket::Message {
#[cfg(feature = "json")]
{
websocket::Message::Text(serde_json::to_string(msg).expect("message serialization"))
}
#[cfg(feature = "cbor")]
{
websocket::Message::Bytes({
let mut v = Vec::new();
ciborium::into_writer(msg, &mut v).expect("serializing message");
v
})
}
}
async fn run(mut self) {
let url = option_env!("DEBUG").map(|_| DEBUG_URL).unwrap_or(LIVE_URL);
'outer: loop {
log::info!("connecting to {url}");
let mut ws = Self::connect_ws().await.fuse();
log::info!("connected to {url}");
if let Err(err) = ws.send(Self::encode_message(&self.ident)).await {
log::error!("websocket identification send: {err}");
continue 'outer;
};
if let Err(err) = ws
.send(Self::encode_message(&ClientMessage::GetState))
.await
{
log::error!("websocket identification send: {err}");
continue 'outer;
};
loop {
let msg = futures::select! {
r = ws.next() => {
match r {
Some(Ok(msg)) => msg,
Some(Err(err)) => {
log::error!("websocket recv: {err}");
continue 'outer;
},
None => {
log::warn!("websocket closed");
continue 'outer;
},
}
}
r = self.recv.next() => {
match r {
Some(msg) => {
log::info!("sending message: {msg:?}");
if let Err(err) = ws.send(
Self::encode_message(&msg)
).await {
log::error!("websocket send error: {err}");
continue 'outer;
}
continue;
},
None => {
log::info!("recv channel closed");
return;
},
}
},
};
let parse = {
#[cfg(feature = "json")]
{
match msg {
websocket::Message::Text(text) => {
serde_json::from_str::<ServerMessage>(&text)
}
websocket::Message::Bytes(items) => serde_json::from_slice(&items),
}
}
#[cfg(feature = "cbor")]
{
match msg {
websocket::Message::Text(_) => {
log::error!("text messages not supported in cbor mode; discarding");
continue;
}
websocket::Message::Bytes(bytes) => {
ciborium::from_reader::<ServerMessage, _>(bytes.as_slice())
}
}
}
};
match parse {
Ok(msg) => self.scope.send_message(Message::RecvServerMessage(msg)),
Err(err) => {
log::error!("parsing server message: {err}; ignoring.")
}
}
}
}
}
}
#[derive(PartialEq)]
pub enum ClientEvent {
Disconnected,
Notification(String),
Waiting,
ShowRole(RoleTitle),
NotInLobby(Box<[PublicIdentity]>),
InLobby(Box<[PublicIdentity]>),
GameInProgress,
GameOver(GameOver),
}
impl TryFrom<ServerMessage> for ClientEvent {
type Error = ServerMessage;
fn try_from(msg: ServerMessage) -> Result<Self, Self::Error> {
Ok(match msg {
ServerMessage::Disconnect => Self::Disconnected,
ServerMessage::LobbyInfo {
joined: false,
players,
} => Self::NotInLobby(players),
ServerMessage::LobbyInfo {
joined: true,
players,
} => Self::InLobby(players),
ServerMessage::GameInProgress => Self::GameInProgress,
_ => return Err(msg),
})
}
}
pub struct Client {
player: Option<Identification>,
send: Sender<ClientMessage>,
recv: Option<Receiver<ClientMessage>>,
current_event: Option<ClientEvent>,
auto_join: bool,
error_callback: Callback<Option<WerewolfError>>,
}
impl Client {
fn error(&self, err: WerewolfError) {
self.error_callback.emit(Some(err))
}
fn clear_error(&self) {
self.error_callback.emit(None)
}
fn bug(&self, msg: &str) {
log::warn!("BUG: {msg}")
}
}
#[derive(Debug, Clone, PartialEq, Copy, Properties)]
pub struct ClientProps {
#[prop_or_default]
pub auto_join: bool,
}
impl Component for Client {
type Message = Message;
type Properties = ClientProps;
fn create(ctx: &Context<Self>) -> Self {
gloo::utils::document().set_title("Werewolves Player");
let player = StorageKey::PlayerId
.get()
.ok()
.and_then(|p| StorageKey::PublicIdentity.get().ok().map(|n| (p, n)))
.map(|(player_id, public)| Identification { player_id, public });
let (send, recv) = futures::channel::mpsc::channel::<ClientMessage>(100);
Self {
player,
send,
recv: Some(recv),
auto_join: ctx.props().auto_join,
error_callback: Callback::from(|err| {
if let Some(err) = err {
log::error!("{err}")
}
}),
current_event: None,
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
if self.player.is_none() {
let scope = ctx.link().clone();
let callback = Callback::from(move |public: PublicIdentity| {
scope.send_message(Message::SetPublicIdentity(public));
});
return html! {
<InputName callback={callback}/>
};
} else if self.recv.is_some() {
// Player info loaded, but connection isn't started
ctx.link().send_message(Message::Connect);
return html! {};
}
let msg = match self.current_event.as_ref() {
Some(msg) => msg,
None => {
return html! {
<div class="connecting">
<p>{"Connecting..."}</p>
</div>
};
}
};
let content = match msg {
ClientEvent::Disconnected => html! {
<div class="disconnected">
<p>{"You were disconnected"}</p>
</div>
},
ClientEvent::Waiting => {
html! {
<div class="waiting-lobby">
<p>{"Waiting"}</p>
</div>
}
}
ClientEvent::ShowRole(role_title) => {
let send = self.send.clone();
let on_click =
Callback::from(
move |_| {
while send.clone().try_send(ClientMessage::RoleAck).is_err() {}
},
);
html! {
<div class="game-start-role">
<p>{format!("Your role: {role_title}")}</p>
<button onclick={on_click}>{"got it"}</button>
</div>
}
}
ClientEvent::NotInLobby(players) => {
let send = self.send.clone();
let on_click =
Callback::from(
move |_| {
while send.clone().try_send(ClientMessage::Hello).is_err() {}
},
);
html! {
<div class="lobby">
<p>{format!("Players in lobby: {}", players.len())}</p>
<ul class="players">
{players.iter().map(|p| html!{<p>{p.to_string()}</p>}).collect::<Html>()}
</ul>
<button onclick={on_click}>{"Join"}</button>
</div>
}
}
ClientEvent::InLobby(players) => {
let send = self.send.clone();
let on_click =
Callback::from(
move |_| {
while send.clone().try_send(ClientMessage::Goodbye).is_err() {}
},
);
html! {
<div class="lobby">
<p>{format!("Players in lobby: {}", players.len())}</p>
<ul class="players">
{players.iter().map(|p| html!{<p>{p.to_string()}</p>}).collect::<Html>()}
</ul>
<button onclick={on_click}>{"Leave"}</button>
</div>
}
}
ClientEvent::GameInProgress => html! {
<div class="waiting-lobby">
<p>{"game in progress"}</p>
</div>
},
ClientEvent::GameOver(result) => html! {
<div>
<h2>{"game over"}</h2>
<p>{
match result {
GameOver::VillageWins => "village wins",
GameOver::WolvesWin => "wolves win",
}}</p>
</div>
},
ClientEvent::Notification(notification) => {
let scope = ctx.link().clone();
let next_event =
// Callback::from(move |_| scope.clone().send_message(Message::));
Callback::from(move |_| log::info!("nothing"));
html! {
<Notification text={notification.clone()} callback={next_event}/>
}
}
};
let player = self
.player
.as_ref()
.map(|player| {
let pronouns = if let Some(pronouns) = player.public.pronouns.as_ref() {
html! {
<p class={"pronouns"}>{"("}{pronouns.as_str()}{")"}</p>
}
} else {
html!()
};
html! {
<player>
<p>{player.public.number.get()}</p>
<name>{player.public.name.clone()}</name>
{pronouns}
</player>
}
})
.unwrap_or(html!());
html! {
<client>
{player}
{content}
</client>
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
log::info!("update: {msg:?}");
match msg {
Message::ForceIdentity(id) => {
self.player.replace(id);
true
}
Message::SetErrorCallback(cb) => {
self.error_callback = cb;
false
}
Message::SetPublicIdentity(public) => {
match self.player.as_mut() {
Some(p) => p.public = public,
None => {
let res =
StorageKey::PlayerId
.get_or_set(PlayerId::new)
.and_then(|player_id| {
StorageKey::PublicIdentity
.set(public.clone())
.map(|_| Identification { player_id, public })
});
match res {
Ok(ident) => {
self.player = Some(ident.clone());
if let Some(recv) = self.recv.take() {
yew::platform::spawn_local(
Connection {
scope: ctx.link().clone(),
ident,
recv,
}
.run(),
);
}
}
Err(err) => {
self.error(err.into());
return false;
}
}
}
}
true
}
Message::RecvServerMessage(msg) => {
if let ServerMessage::LobbyInfo {
joined: false,
players: _,
} = &msg
{
if self.auto_join {
let mut send = self.send.clone();
yew::platform::spawn_local(async move {
if let Err(err) = send.send(ClientMessage::Hello).await {
log::error!("send: {err}");
}
});
self.auto_join = false;
return false;
}
}
let msg = match msg.try_into() {
Ok(event) => {
self.current_event.replace(event);
return true;
}
Err(msg) => msg,
};
match msg {
ServerMessage::GameStart { role } => {
self.current_event.replace(ClientEvent::ShowRole(role));
}
ServerMessage::InvalidMessageForGameState => self.error(
WerewolfError::GameError(GameError::InvalidMessageForGameState),
),
ServerMessage::NoSuchTarget => {
self.error(WerewolfError::InvalidTarget);
}
ServerMessage::GameInProgress
| ServerMessage::LobbyInfo {
joined: _,
players: _,
}
| ServerMessage::Disconnect => return false,
ServerMessage::GameOver(game_over) => {
self.current_event = Some(ClientEvent::GameOver(game_over));
}
ServerMessage::Reset => {
let mut send = self.send.clone();
self.current_event = Some(ClientEvent::Disconnected);
yew::platform::spawn_local(async move {
if let Err(err) = send.send(ClientMessage::GetState).await {
log::error!("send: {err}");
}
});
}
ServerMessage::Sleep => self.current_event = Some(ClientEvent::Waiting),
ServerMessage::Update(update) => match (update, self.player.as_mut()) {
(PlayerUpdate::Number(num), Some(player)) => {
player.public.number = num;
return true;
}
(_, None) => return false,
},
};
true
}
Message::Connect => {
if let Some(player) = self.player.as_ref() {
if let Some(recv) = self.recv.take() {
yew::platform::spawn_local(
Connection {
scope: ctx.link().clone(),
ident: player.clone(),
recv,
}
.run(),
);
return true;
}
}
while let Err(err) = self.send.try_send(ClientMessage::GetState) {
log::error!("send IsThereALobby: {err}")
}
false
}
}
}
}

View File

@ -0,0 +1,60 @@
use gloo::storage::errors::StorageError;
use thiserror::Error;
use werewolves_proto::error::GameError;
use yew::prelude::*;
#[derive(Debug, Clone, PartialEq, Error)]
pub enum WerewolfError {
#[error("{0}")]
GameError(#[from] GameError),
#[error("local storage error: {0}")]
LocalStorageError(String),
#[error("invalid target")]
InvalidTarget,
#[error("send error: {0}")]
SendError(#[from] futures::channel::mpsc::SendError),
}
impl From<StorageError> for WerewolfError {
fn from(storage_error: StorageError) -> Self {
Self::LocalStorageError(storage_error.to_string())
}
}
pub struct ErrorComponent {
error: Option<WerewolfError>,
}
impl Component for ErrorComponent {
type Message = Option<WerewolfError>;
type Properties = ();
fn create(_ctx: &Context<Self>) -> Self {
Self { error: None }
}
fn view(&self, ctx: &Context<Self>) -> Html {
let scope = ctx.link().clone();
let clear = Callback::from(move |_| scope.send_message(None));
match &self.error {
Some(err) => html! {
<div class="error-container">
<div class="error-message">
<p>{err.to_string()}</p>
<button onclick={clear}>{""}</button>
</div>
</div>
},
None => html!(),
}
}
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
// if msg == self.error {
// return false;
// }
self.error = msg;
true
}
}

View File

@ -0,0 +1,566 @@
use core::{num::NonZeroU8, ops::Not, time::Duration};
use futures::{
SinkExt, StreamExt,
channel::mpsc::{Receiver, Sender},
};
use gloo::net::websocket::{self, futures::WebSocket};
use serde::Serialize;
use werewolves_proto::{
error::GameError,
game::{GameOver, GameSettings},
message::{
CharacterState, Identification, PlayerState, PublicIdentity, Target,
host::{
HostDayMessage, HostGameMessage, HostLobbyMessage, HostMessage, HostNightMessage,
ServerToHostMessage,
},
night::{ActionPrompt, ActionResult},
},
player::{CharacterId, PlayerId},
};
use yew::{html::Scope, prelude::*};
use crate::{
callback,
components::{
Button, CoverOfDarkness, Lobby, LobbyPlayerAction, RoleReveal, Settings,
action::{ActionResultView, Prompt},
host::DaytimePlayerList,
},
};
use super::WerewolfError;
const DEBUG_URL: &str = "ws://192.168.1.162:8080/connect/host";
const LIVE_URL: &str = "wss://wolf.emilis.dev/connect/host";
async fn connect_ws() -> WebSocket {
let url = option_env!("DEBUG").map(|_| DEBUG_URL).unwrap_or(LIVE_URL);
loop {
match WebSocket::open(url) {
Ok(ws) => break ws,
Err(err) => {
log::error!("connect: {err}");
yew::platform::time::sleep(Duration::from_secs(1)).await;
}
}
}
}
fn encode_message(msg: &impl Serialize) -> websocket::Message {
#[cfg(feature = "json")]
{
websocket::Message::Text(serde_json::to_string(msg).expect("message serialization"))
}
#[cfg(feature = "cbor")]
{
websocket::Message::Bytes({
let mut v = Vec::new();
ciborium::into_writer(msg, &mut v).expect("serializing message");
v
})
}
}
async fn worker(mut recv: Receiver<HostMessage>, scope: Scope<Host>) {
let url = option_env!("DEBUG").map(|_| DEBUG_URL).unwrap_or(LIVE_URL);
'outer: loop {
log::info!("connecting to {url}");
let mut ws = connect_ws().await.fuse();
log::info!("connected to {url}");
if let Err(err) = ws.send(encode_message(&HostMessage::GetState)).await {
log::error!("sending request for player list: {err}");
continue 'outer;
}
let mut last_msg = chrono::Local::now();
loop {
let msg = futures::select! {
r = ws.next() => {
match r {
Some(Ok(msg)) => {
last_msg = chrono::Local::now();
msg
},
Some(Err(err)) => {
log::error!("websocket recv: {err}");
continue 'outer;
},
None => {
log::warn!("websocket closed");
continue 'outer;
},
}
}
r = recv.next() => {
match r {
Some(msg) => {
log::info!("sending message: {msg:?}");
if let Err(err) = ws.send(
encode_message(&msg)
).await {
log::error!("websocket send error: {err}");
continue 'outer;
}
continue;
},
None => {
log::info!("recv channel closed");
return;
},
}
},
};
let parse = {
#[cfg(feature = "json")]
{
match msg {
websocket::Message::Text(text) => {
serde_json::from_str::<ServerToHostMessage>(&text)
}
websocket::Message::Bytes(items) => serde_json::from_slice(&items),
}
}
#[cfg(feature = "cbor")]
{
match msg {
websocket::Message::Text(_) => {
log::error!("text messages not supported in cbor mode; discarding");
continue;
}
websocket::Message::Bytes(bytes) => {
ciborium::from_reader::<ServerToHostMessage, _>(bytes.as_slice())
}
}
}
};
let took = chrono::Local::now() - last_msg;
if took.num_milliseconds() >= 100 {
log::warn!("took {took}")
}
match parse {
Ok(msg) => scope.send_message::<HostEvent>(msg.into()),
Err(err) => {
log::error!("parsing server message: {err}; ignoring.")
}
}
}
}
}
pub enum HostEvent {
SetErrorCallback(Callback<Option<WerewolfError>>),
SetBigScreenState(bool),
SetState(HostState),
PlayerList(Box<[PlayerState]>),
Settings(GameSettings),
Error(GameError),
}
#[derive(Debug, Clone)]
pub enum HostState {
Disconnected,
Lobby {
players: Box<[PlayerState]>,
settings: GameSettings,
},
Day {
characters: Box<[CharacterState]>,
marked_for_execution: Box<[CharacterId]>,
day: NonZeroU8,
},
GameOver {
result: GameOver,
},
RoleReveal {
ackd: Box<[Target]>,
waiting: Box<[Target]>,
},
Prompt(PublicIdentity, ActionPrompt),
Result(PublicIdentity, ActionResult),
CoverOfDarkness,
}
impl From<ServerToHostMessage> for HostEvent {
fn from(msg: ServerToHostMessage) -> Self {
match msg {
ServerToHostMessage::Disconnect => HostEvent::SetState(HostState::Disconnected),
ServerToHostMessage::Daytime {
characters,
day,
marked: marked_for_execution,
} => HostEvent::SetState(HostState::Day {
characters,
day,
marked_for_execution,
}),
ServerToHostMessage::Lobby(players) => HostEvent::PlayerList(players),
ServerToHostMessage::GameSettings(settings) => HostEvent::Settings(settings),
ServerToHostMessage::Error(err) => HostEvent::Error(err),
ServerToHostMessage::GameOver(game_over) => {
HostEvent::SetState(HostState::GameOver { result: game_over })
}
ServerToHostMessage::ActionPrompt(ident, prompt) => {
HostEvent::SetState(HostState::Prompt(ident, prompt))
}
ServerToHostMessage::ActionResult(ident, result) => {
HostEvent::SetState(HostState::Result(ident, result))
}
ServerToHostMessage::WaitingForRoleRevealAcks { ackd, waiting } => {
HostEvent::SetState(HostState::RoleReveal { ackd, waiting })
}
ServerToHostMessage::CoverOfDarkness => HostEvent::SetState(HostState::CoverOfDarkness),
}
}
}
pub struct Host {
send: Sender<HostMessage>,
state: HostState,
error_callback: Callback<Option<WerewolfError>>,
big_screen: bool,
debug: bool,
}
impl Component for Host {
type Message = HostEvent;
type Properties = ();
fn create(ctx: &Context<Self>) -> Self {
gloo::utils::document().set_title("Werewolves Host");
if let Some(clients) = gloo::utils::document()
.query_selector("clients")
.ok()
.flatten()
{
clients.remove();
}
let (send, recv) = futures::channel::mpsc::channel(100);
let scope = ctx.link().clone();
yew::platform::spawn_local(async move { worker(recv, scope).await });
Self {
send,
state: HostState::Lobby {
players: Box::new([]),
settings: GameSettings::default(),
},
debug: option_env!("DEBUG").is_some(),
big_screen: false,
error_callback: Callback::from(|err| {
if let Some(err) = err {
log::error!("{err}")
}
}),
}
}
fn view(&self, _ctx: &Context<Self>) -> Html {
log::info!("state: {:?}", self.state);
let content = match self.state.clone() {
HostState::GameOver { result } => {
let send = self.send.clone();
let new_lobby = Callback::from(move |_| {
let send = send.clone();
yew::platform::spawn_local(async move {
if let Err(err) = send.clone().send(HostMessage::NewLobby).await {
log::error!("send new lobby: {err}");
}
});
});
html! {
<div>
<p>{format!("game over: {result:?}")}</p>
<div class="button-container">
<button onclick={new_lobby}>{"New Lobby"}</button>
</div>
</div>
}
}
HostState::Disconnected => html! {
<div class="disconnected">
<h2>{"disconnected"}</h2>
</div>
},
HostState::Lobby { players, settings } => {
let on_error = self.error_callback.clone();
let settings = self.big_screen.not().then(|| {
let send = self.send.clone();
let on_changed = Callback::from(move |s| {
let send = send.clone();
yew::platform::spawn_local(async move {
let mut send = send.clone();
if let Err(err) = send
.send(HostMessage::Lobby(HostLobbyMessage::SetGameSettings(s)))
.await
{
log::error!("sending game settings update: {err}");
}
if let Err(err) = send
.send(HostMessage::Lobby(HostLobbyMessage::GetGameSettings))
.await
{
log::error!("sending game settings get: {err}");
}
});
});
let send = self.send.clone();
let on_start = Callback::from(move |_| {
let send = send.clone();
let on_error = on_error.clone();
yew::platform::spawn_local(async move {
let mut send = send.clone();
if let Err(err) =
send.send(HostMessage::Lobby(HostLobbyMessage::Start)).await
{
on_error.emit(Some(err.into()))
}
});
});
html! {
<Settings
settings={settings}
on_start={on_start}
on_update={on_changed}
players_in_lobby={players.len()}
/>
}
});
let on_action = self.big_screen.not().then(|| {
let on_error = self.error_callback.clone();
let send = self.send.clone();
Callback::from(move |(player_id, act): (PlayerId, LobbyPlayerAction)| {
let msg = match act {
LobbyPlayerAction::Kick => {
HostMessage::Lobby(HostLobbyMessage::Kick(player_id))
}
};
let mut send = send.clone();
let on_error = on_error.clone();
yew::platform::spawn_local(async move {
if let Err(err) = send.send(msg).await {
on_error.emit(Some(err.into()))
}
});
})
});
html! {
<div class="column-list">
{settings}
<Lobby players={players} on_action={on_action}/>
</div>
}
}
HostState::Day {
characters,
day,
marked_for_execution,
} => {
let on_mark = crate::callback::send_fn(
|target| {
HostMessage::InGame(HostGameMessage::Day(HostDayMessage::MarkForExecution(
target,
)))
},
self.send.clone(),
);
let on_execute = crate::callback::send_message(
HostMessage::InGame(HostGameMessage::Day(HostDayMessage::Execute)),
self.send.clone(),
);
html! {
<>
<h2>{format!("Day {}", day.get())}</h2>
<DaytimePlayerList
marked={marked_for_execution}
big_screen={self.big_screen}
characters={
characters.into_iter().map(|c| c.into()).collect::<Box<[_]>>()
}
on_execute={on_execute}
on_mark={on_mark}
/>
</>
}
}
HostState::RoleReveal { ackd, waiting } => {
let send = self.send.clone();
let on_force_ready = Callback::from(move |target: Target| {
let send = send.clone();
yew::platform::spawn_local(async move {
if let Err(err) = send
.clone()
.send(HostMessage::ForceRoleAckFor(target.character_id.clone()))
.await
{
log::error!("force role ack for [{target}]: {err}");
}
});
});
html! {
<RoleReveal ackd={ackd} waiting={waiting} on_force_ready={on_force_ready}/>
}
}
HostState::Prompt(ident, prompt) => {
let send = self.send.clone();
let on_complete = Callback::from(move |msg| {
let mut send = send.clone();
yew::platform::spawn_local(async move {
if let Err(err) = send.send(msg).await {
log::error!("sending prompt response: {err}")
}
});
});
html! {
<Prompt
prompt={prompt}
big_screen={self.big_screen}
on_complete={on_complete}
ident={ident}
/>
}
}
HostState::Result(ident, result) => {
let send = self.send.clone();
let on_complete = Callback::from(move |msg| {
let mut send = send.clone();
yew::platform::spawn_local(async move {
if let Err(err) = send.send(msg).await {
log::error!("sending action result response: {err}")
}
});
});
html! {
<ActionResultView
result={result}
big_screen={self.big_screen}
on_complete={on_complete}
ident={ident}
/>
}
}
HostState::CoverOfDarkness => {
let next = self.big_screen.not().then(|| {
let send = self.send.clone();
Callback::from(move |_| {
let mut send = send.clone();
yew::platform::spawn_local(async move {
if let Err(err) = send
.send(HostMessage::InGame(HostGameMessage::Night(
HostNightMessage::Next,
)))
.await
{
log::error!("sending action result response: {err}")
}
});
})
});
return html! {
<CoverOfDarkness next={next} />
};
}
};
let debug_nav = self.debug.then(|| {
let on_error_click = callback::send_message(
HostMessage::Echo(ServerToHostMessage::Error(GameError::NoApprenticeMentor)),
self.send.clone(),
);
html! {
<nav class="debug-nav">
<div class="row-list">
<Button on_click={on_error_click}>{"error"}</Button>
</div>
</nav>
}
});
html! {
<>
{debug_nav}
<div class="content">
{content}
</div>
</>
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
HostEvent::PlayerList(players) => {
match &mut self.state {
HostState::Lobby {
players: p,
settings: _,
} => *p = players,
HostState::CoverOfDarkness
| HostState::Prompt(_, _)
| HostState::Result(_, _)
| HostState::Disconnected
| HostState::RoleReveal {
ackd: _,
waiting: _,
}
| HostState::GameOver { result: _ }
| HostState::Day {
characters: _,
day: _,
marked_for_execution: _,
} => {
return false;
}
}
true
}
HostEvent::SetErrorCallback(callback) => {
self.error_callback = callback;
false
}
HostEvent::SetState(state) => {
self.state = state;
true
}
HostEvent::Settings(settings) => match &mut self.state {
HostState::Lobby {
players: _,
settings: s,
} => {
*s = settings;
true
}
HostState::CoverOfDarkness
| HostState::Prompt(_, _)
| HostState::Result(_, _)
| HostState::Disconnected
| HostState::RoleReveal {
ackd: _,
waiting: _,
}
| HostState::GameOver { result: _ }
| HostState::Day {
characters: _,
day: _,
marked_for_execution: _,
} => {
log::info!("ignoring settings get");
false
}
},
HostEvent::Error(err) => {
self.error_callback
.emit(Some(WerewolfError::GameError(err)));
false
}
HostEvent::SetBigScreenState(state) => {
self.big_screen = state;
self.debug = false;
self.error_callback = Callback::noop();
false
}
}
}
}

43
werewolves/src/storage.rs Normal file
View File

@ -0,0 +1,43 @@
use gloo::storage::{LocalStorage, Storage, errors::StorageError};
use serde::{Deserialize, Serialize};
pub enum StorageKey {
PlayerId,
PublicIdentity,
}
impl StorageKey {
const fn key(&self) -> &'static str {
match self {
StorageKey::PlayerId => "player_id",
StorageKey::PublicIdentity => "player_public",
}
}
pub fn get<T>(&self) -> Result<T, StorageError>
where
T: for<'de> Deserialize<'de>,
{
LocalStorage::get(self.key())
}
pub fn set<T>(&self, value: T) -> Result<(), StorageError>
where
T: Serialize,
{
LocalStorage::set(self.key(), value)
}
pub fn get_or_set<T>(&self, value_fn: impl FnOnce() -> T) -> Result<T, StorageError>
where
T: Serialize + for<'de> Deserialize<'de>,
{
match LocalStorage::get(self.key()) {
Ok(v) => Ok(v),
Err(StorageError::KeyNotFound(_)) => {
LocalStorage::set(self.key(), (value_fn)())?;
self.get()
}
Err(err) => Err(err),
}
}
}