From d664ff281dadba4a70eeecf5b27617b2ca9ebfad Mon Sep 17 00:00:00 2001 From: emilis Date: Sat, 4 Oct 2025 17:50:29 +0100 Subject: [PATCH] reworked game setup --- werewolves-macros/src/lib.rs | 2 +- werewolves-proto/src/error.rs | 18 +- werewolves-proto/src/game/mod.rs | 5 +- werewolves-proto/src/game/night.rs | 17 +- werewolves-proto/src/game/settings.rs | 262 ++++++-- .../src/game/settings/settings_role.rs | 261 ++++++++ werewolves-proto/src/game/village.rs | 48 +- werewolves-proto/src/game_test/mod.rs | 48 +- werewolves-proto/src/modifier.rs | 2 +- werewolves-proto/src/player.rs | 77 ++- werewolves-proto/src/role.rs | 52 +- werewolves-server/src/game.rs | 10 +- werewolves-server/src/lobby.rs | 8 +- werewolves/index.scss | 163 ++++- werewolves/src/clients/client/client.rs | 7 +- werewolves/src/clients/client/connection.rs | 6 +- werewolves/src/clients/host/host.rs | 1 + werewolves/src/components/field.rs | 7 +- werewolves/src/components/settings.rs | 609 +++++++++++++----- werewolves/src/components/toggle.rs | 56 ++ 20 files changed, 1296 insertions(+), 363 deletions(-) create mode 100644 werewolves-proto/src/game/settings/settings_role.rs create mode 100644 werewolves/src/components/toggle.rs diff --git a/werewolves-macros/src/lib.rs b/werewolves-macros/src/lib.rs index 34ea540..377f69c 100644 --- a/werewolves-macros/src/lib.rs +++ b/werewolves-macros/src/lib.rs @@ -7,7 +7,7 @@ use std::{ use convert_case::Casing; use proc_macro2::Span; use quote::{ToTokens, quote}; -use syn::{braced, bracketed, parenthesized, parse::Parse, parse_macro_input}; +use syn::{parse::Parse, parse_macro_input}; mod checks; pub(crate) mod hashlist; diff --git a/werewolves-proto/src/error.rs b/werewolves-proto/src/error.rs index 8c1df18..7be72fd 100644 --- a/werewolves-proto/src/error.rs +++ b/werewolves-proto/src/error.rs @@ -1,13 +1,13 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::role::RoleTitle; +use crate::{message::PublicIdentity, player::PlayerId, role::RoleTitle}; #[derive(Debug, Clone, PartialEq, Error, Serialize, Deserialize)] pub enum GameError { - #[error("too many roles. have {players} players, but {roles} roles (incl wolves)")] + #[error("too many roles. there's {players} players, but {roles} roles")] TooManyRoles { players: u8, roles: u8 }, - #[error("wolves range must start at 1")] + #[error("no wolves?")] NoWolves, #[error("message invalid for game state")] InvalidMessageForGameState, @@ -29,8 +29,8 @@ pub enum GameError { TimedOut, #[error("host channel closed")] HostChannelClosed, - #[error("too few players: got {got} but the settings require at least {need}")] - TooFewPlayers { got: u8, need: u8 }, + #[error("too many players: there's {got} players but only {need} roles")] + TooManyPlayers { got: u8, need: u8 }, #[error("it's already daytime")] AlreadyDaytime, #[error("it's not the end of the night yet")] @@ -41,10 +41,10 @@ pub enum GameError { 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("{0} isn't a mentor role")] + NotAMentor(RoleTitle), #[error("inactive game object")] InactiveGameObject, #[error("socket error: {0}")] @@ -71,4 +71,8 @@ pub enum GameError { GuardianInvalidOriginalKill, #[error("player not assigned number: {0}")] PlayerNotAssignedNumber(String), + #[error("player [{0}] has an assigned role slot, but isn't in the player list")] + AssignedPlayerMissing(PlayerId), + #[error(" {0} assigned to {1} roles")] + AssignedMultipleTimes(PublicIdentity, usize), } diff --git a/werewolves-proto/src/game/mod.rs b/werewolves-proto/src/game/mod.rs index 73aeb2c..e26cd8a 100644 --- a/werewolves-proto/src/game/mod.rs +++ b/werewolves-proto/src/game/mod.rs @@ -21,7 +21,10 @@ use crate::{ }, player::CharacterId, }; -pub use {settings::GameSettings, village::Village}; +pub use { + settings::{Category, GameSettings, OrRandom, SetupRole, SetupSlot, SlotId}, + village::Village, +}; type Result = core::result::Result; diff --git a/werewolves-proto/src/game/night.rs b/werewolves-proto/src/game/night.rs index 9419129..fdf0b72 100644 --- a/werewolves-proto/src/game/night.rs +++ b/werewolves-proto/src/game/night.rs @@ -361,30 +361,17 @@ impl Night { } pub fn received_response(&mut self, resp: ActionResponse) -> Result { - // if let NightState::Active { - // current_prompt: ActionPrompt::CoverOfDarkness, - // current_result: None, - // } = &mut self.night_state - // && let ActionResponse::ClearCoverOfDarkness = &resp - // { - // self.night_state = NightState::Active { - // current_prompt: ActionPrompt::CoverOfDarkness, - // current_result: Some(ActionResult::Continue), - // }; - // return Ok(ActionResult::Continue); - // } - match self.received_response_with_role_blocks(resp)? { BlockResolvedOutcome::PromptUpdate(prompt) => match &mut self.night_state { NightState::Active { current_result: Some(_), .. - } => return Err(GameError::AwaitingResponse), + } => Err(GameError::AwaitingResponse), NightState::Active { current_prompt, .. } => { *current_prompt = prompt.clone(); Ok(ServerAction::Prompt(prompt)) } - NightState::Complete => return Err(GameError::NightOver), + NightState::Complete => Err(GameError::NightOver), }, BlockResolvedOutcome::ActionComplete(result, Some(change)) => { match &mut self.night_state { diff --git a/werewolves-proto/src/game/settings.rs b/werewolves-proto/src/game/settings.rs index 181749b..0301930 100644 --- a/werewolves-proto/src/game/settings.rs +++ b/werewolves-proto/src/game/settings.rs @@ -1,92 +1,205 @@ -use super::Result; -use core::num::NonZeroU8; +mod settings_role; + use std::collections::HashMap; +use rand::seq::SliceRandom; +pub use settings_role::*; + +use super::Result; + use serde::{Deserialize, Serialize}; -use crate::{error::GameError, role::RoleTitle}; +use crate::{error::GameError, message::Identification, player::Character, role::RoleTitle}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct GameSettings { - roles: HashMap, + roles: Vec, + next_order: u32, } 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(), + roles: vec![SetupSlot::new(RoleTitle::Werewolf, 0)], + next_order: 1, } } } 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() + .filter(|s| s.role.category().is_wolves()) + .count() + } + + pub fn slots(&self) -> &[SetupSlot] { + &self.roles } pub fn village_roles_count(&self) -> usize { - self.roles + log::warn!( + "wolves: {} total: {}", + self.wolves_count(), + self.roles.len() + ); + self.roles.len() - self.wolves_count() + } + + pub fn remove_assignments_not_in_list(&mut self, list: &[Identification]) { + self.roles.iter_mut().for_each(|r| { + if let Some(pid) = &r.assign_to + && !list.iter().any(|i| i.player_id == *pid) + { + r.assign_to.take(); + } + }) + } + + pub fn remove_duplicate_assignments(&mut self) { + let assignments = self + .roles .iter() - .filter_map(|(r, c)| { - if !r.wolf() { - Some(c.get() as usize) - } else { - None - } + .filter_map(|r| r.assign_to.as_ref()) + .cloned() + .collect::>(); + let mut assignment_counter = HashMap::new(); + for assign in assignments { + if let Some(counter) = assignment_counter.get_mut(&assign) { + *counter += 1; + } else { + assignment_counter.insert(assign, 1usize); + } + } + let to_remove = assignment_counter + .into_iter() + .filter_map(|(pid, cnt)| (cnt > 1).then_some(pid)) + .collect::>(); + + for role in self.roles.iter_mut() { + if let Some(pid) = role.assign_to.as_ref() + && to_remove.contains(pid) + { + role.assign_to.take(); + } + } + } + + pub fn assign(&self, players: &[Identification]) -> Result> { + self.check_with_player_list(players)?; + + let roles_in_game = self + .roles + .iter() + .map(|r| r.role.clone().into()) + .collect::>(); + + let with_assigned_roles = self + .roles + .iter() + .filter_map(|s| { + s.assign_to.as_ref().map(|assign_to| { + players + .iter() + .find(|pid| pid.player_id == *assign_to) + .ok_or(GameError::AssignedPlayerMissing(assign_to.clone())) + .map(|id| (id, s)) + }) }) - .sum() + .collect::>>()?; + + let mut random_assign_players = players + .iter() + .filter(|p| { + !with_assigned_roles + .iter() + .any(|(r, _)| r.player_id == p.player_id) + }) + .collect::>(); + + random_assign_players.shuffle(&mut rand::rng()); + + with_assigned_roles + .into_iter() + .chain( + random_assign_players + .into_iter() + .zip(self.roles.iter().filter(|s| s.assign_to.is_none())), + ) + .map(|(id, slot)| slot.clone().into_character(id.clone(), &roles_in_game)) + .collect::>>() } - 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 { - let min = self.min_players_needed(); - if min > players { - return Err(GameError::TooFewPlayers { - got: players as _, - need: min as _, + pub fn check_with_player_list(&self, players: &[Identification]) -> Result<()> { + self.check()?; + let (p_len, r_len) = (players.len(), self.roles.len()); + if p_len > r_len { + return Err(GameError::TooManyPlayers { + got: p_len.min(0xFF) as u8, + need: self.roles.len().min(0xFF) as u8, + }); + } else if p_len < r_len { + return Err(GameError::TooManyRoles { + players: p_len.min(0xFF) as u8, + roles: r_len.min(0xFF) as u8, }); } - Ok(players - self.roles.values().map(|c| c.get() as usize).sum::()) + + for role in self.roles.iter() { + if let Some(assigned) = role.assign_to.as_ref() + && !players.iter().any(|p| p.player_id == *assigned) + { + return Err(GameError::AssignedPlayerMissing(assigned.clone())); + } + } + + let assignments = self + .roles + .iter() + .filter_map(|r| r.assign_to.as_ref()) + .cloned() + .collect::>(); + let mut assignment_counter = HashMap::new(); + for assign in assignments { + if let Some(counter) = assignment_counter.get_mut(&assign) { + *counter += 1; + } else { + assignment_counter.insert(assign, 1usize); + } + } + if let Some((assign, cnt)) = assignment_counter.into_iter().find(|(_, cnt)| *cnt > 1) { + let ident = players + .iter() + .find(|i| i.player_id == assign) + .ok_or(GameError::AssignedPlayerMissing(assign))?; + return Err(GameError::AssignedMultipleTimes(ident.public.clone(), cnt)); + } + + Ok(()) } pub fn check(&self) -> Result<()> { if self.wolves_count() == 0 { return Err(GameError::NoWolves); } - if self + let mentor_count = self .roles .iter() - .any(|(r, _)| matches!(r, RoleTitle::Apprentice)) - && self.roles.iter().filter(|(r, _)| r.is_mentor()).count() == 0 - { - return Err(GameError::NoApprenticeMentor); - } + .filter(|r| Into::::into(r.role.clone()).is_mentor()) + .count(); + self.roles.iter().try_for_each(|s| match &s.role { + SetupRole::Apprentice { specifically: None } => (mentor_count > 0) + .then_some(()) + .ok_or(GameError::NoApprenticeMentor), + SetupRole::Apprentice { + specifically: Some(role), + } => role + .is_mentor() + .then_some(()) + .ok_or(GameError::NotAMentor(*role)), + _ => Ok(()), + })?; Ok(()) } @@ -102,26 +215,35 @@ impl GameSettings { } } - 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 new_slot(&mut self, role: RoleTitle) -> SlotId { + let slot = SetupSlot::new(role, self.next_order); + self.next_order += 1; + let slot_id = slot.slot_id; + self.roles.push(slot); + self.sort_roles(); + slot_id } - 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); + pub fn update_slot(&mut self, slot: SetupSlot) { + if let Some(old_slot) = self.roles.iter_mut().find(|r| r.slot_id == slot.slot_id) { + *old_slot = slot; } } + + pub fn remove_slot(&mut self, slot_id: SlotId) { + if let Some(idx) = self + .roles + .iter() + .enumerate() + .find_map(|(idx, slot)| (slot.slot_id == slot_id).then_some(idx)) + { + self.roles.swap_remove(idx); + self.sort_roles(); + } + } + + fn sort_roles(&mut self) { + self.roles + .sort_by(|l, r| l.created_order.cmp(&r.created_order).reverse()); + } } diff --git a/werewolves-proto/src/game/settings/settings_role.rs b/werewolves-proto/src/game/settings/settings_role.rs new file mode 100644 index 0000000..7974502 --- /dev/null +++ b/werewolves-proto/src/game/settings/settings_role.rs @@ -0,0 +1,261 @@ +use core::{ + fmt::{Debug, Display}, + num::NonZeroU8, +}; + +use rand::distr::{Distribution, StandardUniform}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use werewolves_macros::ChecksAs; + +use crate::{ + error::GameError, + message::Identification, + modifier::Modifier, + player::{Character, PlayerId}, + role::{Role, RoleTitle}, +}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub enum OrRandom +where + T: Debug + Clone + PartialEq, + StandardUniform: Distribution, +{ + Determined(T), + #[default] + Random, +} + +impl OrRandom +where + for<'a> T: Debug + Clone + PartialEq, + StandardUniform: Distribution, +{ + pub fn into_concrete(self) -> T { + match self { + Self::Determined(value) => value, + Self::Random => rand::random(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, ChecksAs)] +pub enum Category { + #[checks] + Wolves, + Villager, + Intel, + Defensive, + Offensive, + StartsAsVillager, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ChecksAs)] +pub enum SetupRole { + #[checks(Category::Villager)] + Villager, + #[checks(Category::Villager)] + Scapegoat { redeemed: OrRandom }, + #[checks(Category::Intel)] + Seer, + #[checks(Category::Intel)] + Arcanist, + #[checks(Category::Intel)] + Gravedigger, + #[checks(Category::Offensive)] + Hunter, + #[checks(Category::Offensive)] + Militia, + #[checks(Category::Offensive)] + MapleWolf, + #[checks(Category::Defensive)] + Guardian, + #[checks(Category::Defensive)] + Protector, + #[checks(Category::StartsAsVillager)] + Apprentice { specifically: Option }, + #[checks(Category::StartsAsVillager)] + Elder { knows_on_night: NonZeroU8 }, + + #[checks(Category::Wolves)] + Werewolf, + #[checks(Category::Wolves)] + AlphaWolf, + #[checks(Category::Wolves)] + DireWolf, + #[checks(Category::Wolves)] + Shapeshifter, +} + +impl Display for SetupRole { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + SetupRole::Villager => "Villager", + SetupRole::Scapegoat { .. } => "Scapegoat", + SetupRole::Seer => "Seer", + SetupRole::Arcanist => "Arcanist", + SetupRole::Gravedigger => "Gravedigger", + SetupRole::Hunter => "Hunter", + SetupRole::Militia => "Militia", + SetupRole::MapleWolf => "MapleWolf", + SetupRole::Guardian => "Guardian", + SetupRole::Protector => "Protector", + SetupRole::Apprentice { .. } => "Apprentice", + SetupRole::Elder { .. } => "Elder", + SetupRole::Werewolf => "Werewolf", + SetupRole::AlphaWolf => "AlphaWolf", + SetupRole::DireWolf => "DireWolf", + SetupRole::Shapeshifter => "Shapeshifter", + }) + } +} + +impl SetupRole { + pub fn into_role(self, roles_in_game: &[RoleTitle]) -> Result { + Ok(match self { + SetupRole::Villager => Role::Villager, + SetupRole::Scapegoat { redeemed } => Role::Scapegoat { + redeemed: redeemed.into_concrete(), + }, + SetupRole::Seer => Role::Seer, + SetupRole::Arcanist => Role::Arcanist, + SetupRole::Gravedigger => Role::Gravedigger, + SetupRole::Hunter => Role::Hunter { target: None }, + SetupRole::Militia => Role::Militia { targeted: None }, + SetupRole::MapleWolf => Role::MapleWolf { + last_kill_on_night: 0, + }, + SetupRole::Guardian => Role::Guardian { + last_protected: None, + }, + SetupRole::Protector => Role::Protector { + last_protected: None, + }, + SetupRole::Apprentice { + specifically: Some(role), + } => Role::Apprentice(role), + SetupRole::Apprentice { specifically: None } => { + let mentors = roles_in_game + .iter() + .filter(|r| r.is_mentor()) + .collect::>(); + if mentors.is_empty() { + return Err(GameError::NoApprenticeMentor); + } + let mentor = *mentors[rand::random_range(0..mentors.len())]; + Role::Apprentice(mentor) + } + SetupRole::Elder { knows_on_night } => Role::Elder { + knows_on_night, + has_protection: true, + }, + SetupRole::Werewolf => Role::Werewolf, + SetupRole::AlphaWolf => Role::AlphaWolf { killed: None }, + SetupRole::DireWolf => Role::DireWolf, + SetupRole::Shapeshifter => Role::Shapeshifter { shifted_into: None }, + }) + } +} + +impl From for RoleTitle { + fn from(value: SetupRole) -> Self { + match value { + SetupRole::Villager => RoleTitle::Villager, + SetupRole::Scapegoat { .. } => RoleTitle::Scapegoat, + SetupRole::Seer => RoleTitle::Seer, + SetupRole::Arcanist => RoleTitle::Arcanist, + SetupRole::Gravedigger => RoleTitle::Gravedigger, + SetupRole::Hunter => RoleTitle::Hunter, + SetupRole::Militia => RoleTitle::Militia, + SetupRole::MapleWolf => RoleTitle::MapleWolf, + SetupRole::Guardian => RoleTitle::Guardian, + SetupRole::Protector => RoleTitle::Protector, + SetupRole::Apprentice { .. } => RoleTitle::Apprentice, + SetupRole::Elder { .. } => RoleTitle::Elder, + SetupRole::Werewolf => RoleTitle::Werewolf, + SetupRole::AlphaWolf => RoleTitle::AlphaWolf, + SetupRole::DireWolf => RoleTitle::DireWolf, + SetupRole::Shapeshifter => RoleTitle::Shapeshifter, + } + } +} + +impl From for SetupRole { + fn from(value: RoleTitle) -> Self { + match value { + RoleTitle::Villager => SetupRole::Villager, + RoleTitle::Scapegoat => SetupRole::Scapegoat { + redeemed: Default::default(), + }, + RoleTitle::Seer => SetupRole::Seer, + RoleTitle::Arcanist => SetupRole::Arcanist, + RoleTitle::Gravedigger => SetupRole::Gravedigger, + RoleTitle::Hunter => SetupRole::Hunter, + RoleTitle::Militia => SetupRole::Militia, + RoleTitle::MapleWolf => SetupRole::MapleWolf, + RoleTitle::Guardian => SetupRole::Guardian, + RoleTitle::Protector => SetupRole::Protector, + RoleTitle::Apprentice => SetupRole::Apprentice { specifically: None }, + RoleTitle::Elder => SetupRole::Elder { + knows_on_night: NonZeroU8::new(3).unwrap(), + }, + RoleTitle::Werewolf => SetupRole::Werewolf, + RoleTitle::AlphaWolf => SetupRole::AlphaWolf, + RoleTitle::DireWolf => SetupRole::DireWolf, + RoleTitle::Shapeshifter => SetupRole::Shapeshifter, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct SlotId(Uuid); + +impl SlotId { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SetupSlot { + pub slot_id: SlotId, + pub role: SetupRole, + pub modifiers: Vec, + pub assign_to: Option, + pub created_order: u32, +} + +impl SetupSlot { + pub fn new(title: RoleTitle, created_order: u32) -> Self { + Self { + created_order, + assign_to: None, + role: title.into(), + modifiers: Vec::new(), + slot_id: SlotId::new(), + } + } + + pub fn into_character( + self, + ident: Identification, + roles_in_game: &[RoleTitle], + ) -> Result { + Character::new(ident.clone(), self.role.into_role(roles_in_game)?) + .ok_or(GameError::PlayerNotAssignedNumber(ident.to_string())) + } +} + +impl Category { + pub const fn class(&self) -> &'static str { + match self { + Category::Wolves => "wolves", + Category::Villager => "village", + Category::Intel => "intel", + Category::Defensive => "defensive", + Category::Offensive => "offensive", + Category::StartsAsVillager => "starts-as-villager", + } + } +} diff --git a/werewolves-proto/src/game/village.rs b/werewolves-proto/src/game/village.rs index 061a884..70689ba 100644 --- a/werewolves-proto/src/game/village.rs +++ b/werewolves-proto/src/game/village.rs @@ -26,44 +26,12 @@ impl Village { roles: settings.min_players_needed() as u8, }); } - settings.check()?; + let mut characters = settings.assign(players)?; + assert_eq!(characters.len(), players.len()); + characters.sort_by_key(|l| l.number()); - 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::>(); - - 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::>(); - - assert_eq!(players.len(), roles.len()); - roles.shuffle(&mut rand::rng()); Ok(Self { - characters: players - .iter() - .cloned() - .zip(roles) - .map(|(player, role)| { - let player_str = player.public.to_string(); - Character::new(player, role) - .ok_or(GameError::PlayerNotAssignedNumber(player_str)) - }) - .collect::>>()?, + characters, date_time: DateTime::Night { number: 0 }, }) } @@ -259,17 +227,19 @@ impl RoleTitle { pub fn title_to_role_excl_apprentice(self) -> Role { match self { RoleTitle::Villager => Role::Villager, - RoleTitle::Scapegoat => Role::Scapegoat, + RoleTitle::Scapegoat => Role::Scapegoat { + redeemed: rand::random(), + }, RoleTitle::Seer => Role::Seer, RoleTitle::Arcanist => Role::Arcanist, RoleTitle::Elder => Role::Elder { knows_on_night: NonZeroU8::new(rand::rng().random_range(1u8..3)).unwrap(), + has_protection: true, }, 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, }, @@ -282,6 +252,8 @@ impl RoleTitle { RoleTitle::Guardian => Role::Guardian { last_protected: None, }, + // fallback to villager + RoleTitle::Apprentice => Role::Villager, } } } diff --git a/werewolves-proto/src/game_test/mod.rs b/werewolves-proto/src/game_test/mod.rs index 8b7a2ba..7f4dafa 100644 --- a/werewolves-proto/src/game_test/mod.rs +++ b/werewolves-proto/src/game_test/mod.rs @@ -2,7 +2,7 @@ mod night_order; use crate::{ error::GameError, - game::{Game, GameSettings}, + game::{Game, GameSettings, SetupRole}, message::{ CharacterState, Identification, PublicIdentity, host::{HostDayMessage, HostGameMessage, HostNightMessage, ServerToHostMessage}, @@ -223,9 +223,15 @@ fn starts_with_wolf_intro() { fn no_wolf_kill_n1() { let players = gen_players(1..10); let mut settings = GameSettings::default(); - settings.add(RoleTitle::Shapeshifter).unwrap(); - settings.sub(RoleTitle::Werewolf); - settings.add(RoleTitle::Protector).unwrap(); + settings.new_slot(RoleTitle::Shapeshifter); + settings.new_slot(RoleTitle::Protector); + if let Some(slot) = settings + .slots() + .iter() + .find(|s| matches!(s.role, SetupRole::Werewolf)) + { + settings.remove_slot(slot.slot_id); + } let mut game = Game::new(&players, settings).unwrap(); assert_eq!( game.process(HostGameMessage::Night(HostNightMessage::ActionResponse( @@ -342,9 +348,15 @@ fn protect_stops_shapeshift() { init_log(); let players = gen_players(1..10); let mut settings = GameSettings::default(); - settings.add(RoleTitle::Shapeshifter).unwrap(); - settings.sub(RoleTitle::Werewolf); - settings.add(RoleTitle::Protector).unwrap(); + settings.new_slot(RoleTitle::Shapeshifter); + settings.new_slot(RoleTitle::Protector); + if let Some(slot) = settings + .slots() + .iter() + .find(|s| matches!(s.role, SetupRole::Werewolf)) + { + settings.remove_slot(slot.slot_id); + } let mut game = Game::new(&players, settings).unwrap(); assert_eq!( game.process(HostGameMessage::Night(HostNightMessage::ActionResponse( @@ -484,8 +496,14 @@ fn wolfpack_kill_all_targets_valid() { init_log(); let players = gen_players(1..10); let mut settings = GameSettings::default(); - settings.add(RoleTitle::Shapeshifter).unwrap(); - settings.sub(RoleTitle::Werewolf); + settings.new_slot(RoleTitle::Shapeshifter); + if let Some(slot) = settings + .slots() + .iter() + .find(|s| matches!(s.role, SetupRole::Werewolf)) + { + settings.remove_slot(slot.slot_id); + } let mut game = Game::new(&players, settings).unwrap(); game.r#continue().r#continue(); @@ -549,9 +567,15 @@ fn wolfpack_kill_all_targets_valid() { fn only_1_shapeshift_prompt_if_first_shifts() { let players = gen_players(1..10); let mut settings = GameSettings::default(); - settings.add(RoleTitle::Shapeshifter).unwrap(); - settings.add(RoleTitle::Shapeshifter).unwrap(); - settings.sub(RoleTitle::Werewolf); + settings.new_slot(RoleTitle::Shapeshifter); + settings.new_slot(RoleTitle::Shapeshifter); + if let Some(slot) = settings + .slots() + .iter() + .find(|s| matches!(s.role, SetupRole::Werewolf)) + { + settings.remove_slot(slot.slot_id); + } let mut game = Game::new(&players, settings).unwrap(); game.r#continue().r#continue(); assert_eq!(game.next().title(), ActionPromptTitle::WolvesIntro); diff --git a/werewolves-proto/src/modifier.rs b/werewolves-proto/src/modifier.rs index 7a7a37c..abd694f 100644 --- a/werewolves-proto/src/modifier.rs +++ b/werewolves-proto/src/modifier.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub enum Modifier { Drunk, Insane, diff --git a/werewolves-proto/src/player.rs b/werewolves-proto/src/player.rs index 9dfa102..a3acbea 100644 --- a/werewolves-proto/src/player.rs +++ b/werewolves-proto/src/player.rs @@ -1,11 +1,12 @@ use core::{fmt::Display, num::NonZeroU8}; +use rand::seq::SliceRandom; use serde::{Deserialize, Serialize}; use crate::{ diedto::DiedTo, error::GameError, - game::{DateTime, Village}, + game::{DateTime, SetupSlot, Village}, message::{CharacterIdentity, Identification, PublicIdentity, night::ActionPrompt}, modifier::Modifier, role::{Alignment, MAPLE_WOLF_ABSTAIN_LIMIT, PreviousGuardianAction, Role, RoleTitle}, @@ -74,6 +75,13 @@ pub enum KillOutcome { Failed, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoleChange { + role: Role, + new_role: RoleTitle, + changed_on_night: u8, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Character { player_id: PlayerId, @@ -84,13 +92,6 @@ pub struct Character { role_changes: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RoleChange { - role: Role, - new_role: RoleTitle, - changed_on_night: u8, -} - impl Character { pub fn new( Identification { @@ -119,6 +120,27 @@ impl Character { }) } + pub const fn is_power_role(&self) -> bool { + match &self.role { + Role::Scapegoat { .. } | Role::Villager => false, + + Role::Seer + | Role::Arcanist + | Role::Gravedigger + | Role::Hunter { .. } + | Role::Militia { .. } + | Role::MapleWolf { .. } + | Role::Guardian { .. } + | Role::Protector { .. } + | Role::Apprentice(..) + | Role::Elder { .. } + | Role::Werewolf + | Role::AlphaWolf { .. } + | Role::DireWolf + | Role::Shapeshifter { .. } => true, + } + } + pub fn identity(&self) -> CharacterIdentity { self.identity.clone() } @@ -230,8 +252,23 @@ impl Character { } | Role::AlphaWolf { killed: Some(_) } | Role::Militia { targeted: Some(_) } - | Role::Scapegoat + | Role::Scapegoat { redeemed: false } | Role::Villager => return Ok(None), + Role::Scapegoat { redeemed: true } => { + let mut dead = village.dead_characters(); + dead.shuffle(&mut rand::rng()); + if let Some(pr) = dead + .into_iter() + .find_map(|d| d.is_power_role().then_some(d.role().title())) + { + ActionPrompt::RoleChange { + character_id: self.identity(), + new_role: pr, + } + } else { + return Ok(None); + } + } Role::Seer => ActionPrompt::Seer { character_id: self.identity(), living_players: village.living_players_excluding(self.character_id()), @@ -264,7 +301,7 @@ impl Character { return Ok(village .characters() .into_iter() - .filter(|c| c.role().title() == role.title()) + .filter(|c| c.role().title() == *role) .filter_map(|char| char.died_to) .any(|died_to| match died_to.date_time() { DateTime::Day { number } => number.get() + 1 >= current_night, @@ -272,10 +309,10 @@ impl Character { }) .then(|| ActionPrompt::RoleChange { character_id: self.identity(), - new_role: role.title(), + new_role: *role, })); } - Role::Elder { knows_on_night } => { + Role::Elder { knows_on_night, .. } => { let current_night = match village.date_time() { DateTime::Day { number: _ } => return Ok(None), DateTime::Night { number } => number, @@ -309,11 +346,17 @@ impl Character { Role::Shapeshifter { shifted_into: None } => ActionPrompt::Shapeshifter { character_id: self.identity(), }, - Role::Gravedigger => ActionPrompt::Gravedigger { - character_id: self.identity(), - dead_players: village.dead_targets(), - marked: None, - }, + Role::Gravedigger => { + let dead = village.dead_targets(); + if dead.is_empty() { + return Ok(None); + } + ActionPrompt::Gravedigger { + character_id: self.identity(), + dead_players: village.dead_targets(), + marked: None, + } + } Role::Hunter { target } => ActionPrompt::Hunter { character_id: self.identity(), current_target: target.as_ref().and_then(|t| village.target_by_id(t)), diff --git a/werewolves-proto/src/role.rs b/werewolves-proto/src/role.rs index cb59a84..0692db4 100644 --- a/werewolves-proto/src/role.rs +++ b/werewolves-proto/src/role.rs @@ -16,7 +16,7 @@ pub enum Role { #[checks(Alignment::Wolves)] #[checks("killer")] #[checks("powerful")] - Scapegoat, + Scapegoat { redeemed: bool }, #[checks(Alignment::Village)] #[checks("powerful")] #[checks("is_mentor")] @@ -58,11 +58,14 @@ pub enum Role { Protector { last_protected: Option }, #[checks(Alignment::Village)] #[checks("powerful")] - Apprentice(Box), + Apprentice(RoleTitle), #[checks(Alignment::Village)] #[checks("powerful")] #[checks("is_mentor")] - Elder { knows_on_night: NonZeroU8 }, + Elder { + knows_on_night: NonZeroU8, + has_protection: bool, + }, #[checks(Alignment::Wolves)] #[checks("killer")] @@ -90,7 +93,7 @@ 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, + Role::Apprentice(_) | Role::Elder { .. } => RoleTitle::Villager, _ => self.title(), } } @@ -104,50 +107,47 @@ impl Role { return match self { Role::DireWolf | Role::Arcanist | Role::Seer => true, - Role::Shapeshifter { shifted_into: _ } + Role::Shapeshifter { .. } | Role::Werewolf - | Role::AlphaWolf { killed: _ } - | Role::Elder { knows_on_night: _ } + | Role::AlphaWolf { .. } + | Role::Elder { .. } | Role::Gravedigger - | Role::Hunter { target: _ } - | Role::Militia { targeted: _ } - | Role::MapleWolf { - last_kill_on_night: _, - } - | Role::Guardian { last_protected: _ } + | Role::Hunter { .. } + | Role::Militia { .. } + | Role::MapleWolf { .. } + | Role::Guardian { .. } | Role::Apprentice(_) | Role::Villager - | Role::Scapegoat - | Role::Protector { last_protected: _ } => false, + | Role::Scapegoat { .. } + | Role::Protector { .. } => false, }; } match self { Role::AlphaWolf { killed: Some(_) } | Role::Werewolf - | Role::Scapegoat + | Role::Scapegoat { redeemed: false } | Role::Militia { targeted: Some(_) } | Role::Villager => false, - Role::Shapeshifter { shifted_into: _ } + Role::Scapegoat { redeemed: true } + | Role::Shapeshifter { .. } | Role::DireWolf | Role::AlphaWolf { killed: None } | Role::Arcanist - | Role::Protector { last_protected: _ } + | Role::Protector { .. } | Role::Gravedigger - | Role::Hunter { target: _ } + | Role::Hunter { .. } | Role::Militia { targeted: None } - | Role::MapleWolf { - last_kill_on_night: _, - } - | Role::Guardian { last_protected: _ } + | Role::MapleWolf { .. } + | Role::Guardian { .. } | Role::Seer => true, - Role::Apprentice(role) => village + Role::Apprentice(title) => village .characters() .iter() - .any(|c| c.role().title() == role.title()), + .any(|c| c.role().title() == *title), - Role::Elder { knows_on_night } => match village.date_time() { + Role::Elder { knows_on_night, .. } => match village.date_time() { DateTime::Night { number } => number == knows_on_night.get(), _ => false, }, diff --git a/werewolves-server/src/game.rs b/werewolves-server/src/game.rs index c47c243..6b5e399 100644 --- a/werewolves-server/src/game.rs +++ b/werewolves-server/src/game.rs @@ -8,7 +8,7 @@ use crate::{ lobby::{Lobby, LobbyPlayers}, runner::{IdentifiedClientMessage, Message}, }; -use tokio::{sync::broadcast::Receiver, time::Instant}; +use tokio::time::Instant; use werewolves_proto::{ error::GameError, game::{Game, GameOver, Village}, @@ -158,8 +158,8 @@ impl GameRunner { continue; }; if acks.iter().any(|(c, d)| c.player_id() == &player_id && *d) { - // already ack'd just disconnect - sender.send(ServerMessage::Disconnect).log_debug(); + // already ack'd just sleep + sender.send(ServerMessage::Sleep).log_debug(); continue; } if let Some(char) = self @@ -196,7 +196,7 @@ impl GameRunner { } (update_host)(&acks, &mut self.comms); if let Some(sender) = self.joined_players.get_sender(&player_id).await { - sender.send(ServerMessage::Disconnect).log_debug(); + sender.send(ServerMessage::Sleep).log_debug(); } } Message::Client(IdentifiedClientMessage { @@ -219,7 +219,7 @@ impl GameRunner { for char in self.game.village().characters() { if let Some(sender) = self.joined_players.get_sender(char.player_id()).await { - let _ = sender.send(ServerMessage::Disconnect); + let _ = sender.send(ServerMessage::Sleep); } } diff --git a/werewolves-server/src/lobby.rs b/werewolves-server/src/lobby.rs index 094f79a..be981b4 100644 --- a/werewolves-server/src/lobby.rs +++ b/werewolves-server/src/lobby.rs @@ -168,7 +168,6 @@ impl Lobby { let _ = self.comms().unwrap().host().send(msg); } Message::Host(HostMessage::Lobby(HostLobbyMessage::SetGameSettings(settings))) => { - settings.check()?; self.settings = settings; } Message::Host(HostMessage::Lobby(HostLobbyMessage::SetPlayerNumber(pid, num))) => { @@ -187,17 +186,12 @@ impl Lobby { self.send_lobby_info_to_host().await.log_debug(); } 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::>(); + self.settings.check_with_player_list(&playing_players)?; let game = Game::new(&playing_players, self.settings.clone())?; assert_eq!(game.village().characters().len(), playing_players.len()); diff --git a/werewolves/index.scss b/werewolves/index.scss index fac019b..a2f4271 100644 --- a/werewolves/index.scss +++ b/werewolves/index.scss @@ -7,6 +7,16 @@ $disconnected_color: hsl(0, 68%, 50%); $client_shadow_color: hsl(260, 55%, 61%); $client_shadow_color_2: hsl(240, 55%, 61%); $client_filter: drop-shadow(5px 5px 0 $client_shadow_color) drop-shadow(5px 5px 0 $client_shadow_color_2); +$village_border: color.change($village_color, $alpha: 1.0); +$wolves_border: color.change($wolves_color, $alpha: 1.0); +$intel_color: color.adjust($village_color, $hue: -30deg); +$intel_border: color.change($intel_color, $alpha: 1.0); +$defensive_color: color.adjust($intel_color, $hue: -30deg); +$defensive_border: color.change($defensive_color, $alpha: 1.0); +$offensive_color: color.adjust($village_color, $hue: 30deg); +$offensive_border: color.change($offensive_color, $alpha: 1.0); +$starts_as_villager_color: color.adjust($offensive_color, $hue: 30deg); +$starts_as_villager_border: color.change($starts_as_villager_color, $alpha: 1.0); @mixin flexbox() { @@ -112,6 +122,7 @@ nav.debug-nav { flex-wrap: wrap; gap: 10px; justify-items: center; + justify-content: space-evenly; } .player { @@ -180,8 +191,9 @@ nav.debug-nav { background-color: black; border: 1px solid rgba(255, 255, 255, 0.7); padding: 10px; - // position: absolute; position: relative; + // position: fixed; + // left: 0%; // top: 1px; align-self: stretch; @@ -190,6 +202,11 @@ nav.debug-nav { & button { width: 100%; } + + &>label { + font-size: 1rem; + margin-bottom: 0; + } } .click-backdrop { @@ -289,6 +306,12 @@ button { gap: 30px; + &>p { + text-align: center; + margin: 0px; + padding: 0px; + font-size: 0.7em; + } } .wolves-intro { @@ -368,11 +391,6 @@ button.confirm { } } -.role-card.village { - background-color: $village_color; - color: rgba(255, 255, 255, 1); -} - rolecard { display: flex; flex-direction: row; @@ -383,9 +401,7 @@ rolecard { justify-content: space-between; } -.role.wolves { - background-color: $wolves_color; -} + bool_spacer { min-width: 25px; @@ -835,6 +851,10 @@ input { @extend .column-list; gap: 10px; + &>.identity { + align-self: flex-start; + } + &>button { width: 90vw; align-self: center; @@ -931,3 +951,128 @@ input { height: 100vh; position: fixed; } + +.align-start { + align-self: flex-start; +} + +.align-end { + align-self: flex-end; +} + +.increment-decrement { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + text-align: center; + justify-content: center; + align-items: center; + align-content: center; + + &>p { + height: 100%; + width: 100%; + } + +} + +.setup-slot { + text-align: center; + + & button label { + cursor: pointer; + } +} + +.add-role { + color: white; +} + + +.village { + background-color: $village_color; + border: 1px solid $village_border; + + &:hover { + color: white; + background-color: $village_border; + } +} + +.wolves { + background-color: $wolves_color; + border: 1px solid $wolves_border; + + &:hover { + color: white; + background-color: $wolves_border; + } +} + +.intel { + background-color: $intel_color; + border: 1px solid $intel_border; + + &:hover { + color: white; + background-color: $intel_border; + } +} + +.defensive { + background-color: $defensive_color; + border: 1px solid $defensive_border; + + &:hover { + color: white; + background-color: $defensive_border; + } +} + +.offensive { + background-color: $offensive_color; + border: 1px solid $offensive_border; + + &:hover { + color: white; + background-color: $offensive_border; + } +} + +.starts-as-villager { + background-color: $starts_as_villager_color; + border: 1px solid $starts_as_villager_border; + + &:hover { + color: white; + background-color: $starts_as_villager_border; + } +} + +.assignments { + display: flex; + flex-direction: row; + gap: 0; + flex-wrap: wrap; + font-size: 0.5em; + gap: 10px; + + .assignment { + text-align: center; + padding-left: 10px; + padding-right: 10px; + border: 1px solid white; + + &>* { + cursor: pointer; + } + } +} + +.roles-add-list { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-around; + row-gap: 10px; +} diff --git a/werewolves/src/clients/client/client.rs b/werewolves/src/clients/client/client.rs index 6258757..1dfca82 100644 --- a/werewolves/src/clients/client/client.rs +++ b/werewolves/src/clients/client/client.rs @@ -11,7 +11,7 @@ use yew::prelude::*; use crate::{ clients::client::connection::{Connection2, ConnectionError}, components::{ - Button, Identity, + Button, CoverOfDarkness, Identity, client::{ClientNav, Signin}, }, storage::StorageKey, @@ -24,7 +24,7 @@ pub enum ClientEvent2 { Disconnected, Connecting, ShowRole(RoleTitle), - + Sleep, Lobby { joined: bool, players: Rc<[PublicIdentity]>, @@ -79,6 +79,9 @@ pub fn Client2(ClientProps { auto_join }: &ClientProps) -> Html { let connection = use_mut_ref(|| Connection2::new(client_state.setter(), ident.clone(), recv)); let content = match &*client_state { + ClientEvent2::Sleep => html! { + + }, ClientEvent2::Disconnected => html! {

{"disconnected"}

diff --git a/werewolves/src/clients/client/connection.rs b/werewolves/src/clients/client/connection.rs index b44cf2d..b93cceb 100644 --- a/werewolves/src/clients/client/connection.rs +++ b/werewolves/src/clients/client/connection.rs @@ -218,6 +218,7 @@ impl Connection2 { fn message_to_client_state(&self, msg: ServerMessage) -> Option { log::debug!("received message: {msg:?}"); Some(match msg { + ServerMessage::Sleep => ClientEvent2::Sleep, ServerMessage::Disconnect => ClientEvent2::Disconnected, ServerMessage::LobbyInfo { joined, @@ -245,10 +246,7 @@ impl Connection2 { self.ident.set((pid, ident)); return None; } - ServerMessage::GameOver(_) - | ServerMessage::Sleep - | ServerMessage::Reset - | ServerMessage::GameInProgress => { + ServerMessage::GameOver(_) | ServerMessage::Reset | ServerMessage::GameInProgress => { log::info!("ignoring: {msg:?}"); return None; } diff --git a/werewolves/src/clients/host/host.rs b/werewolves/src/clients/host/host.rs index 722d9ca..a52d88e 100644 --- a/werewolves/src/clients/host/host.rs +++ b/werewolves/src/clients/host/host.rs @@ -159,6 +159,7 @@ async fn worker(mut recv: Receiver, scope: Scope) { } }; match parse { + Ok(ServerToHostMessage::Error(GameError::AwaitingResponse)) => {} Ok(msg) => { log::debug!("got message: {:?}", msg.title()); log::trace!("message content: {msg:?}"); diff --git a/werewolves/src/components/field.rs b/werewolves/src/components/field.rs index 97df837..80f5317 100644 --- a/werewolves/src/components/field.rs +++ b/werewolves/src/components/field.rs @@ -10,6 +10,8 @@ pub struct ClickableFieldProps { #[prop_or_default] pub class: yew::Classes, #[prop_or_default] + pub button_class: yew::Classes, + #[prop_or_default] pub with_backdrop_exit: bool, pub state: UseStateHandle, } @@ -20,6 +22,7 @@ pub fn ClickableField( children, options, class, + button_class, with_backdrop_exit, state, }: &ClickableFieldProps, @@ -46,7 +49,9 @@ pub fn ClickableField( }); html! {
- + {submenu}
} diff --git a/werewolves/src/components/settings.rs b/werewolves/src/components/settings.rs index 226ad99..3fd9d52 100644 --- a/werewolves/src/components/settings.rs +++ b/werewolves/src/components/settings.rs @@ -4,12 +4,14 @@ use std::{collections::HashMap, rc::Rc}; use convert_case::{Case, Casing}; use web_sys::HtmlInputElement; use werewolves_proto::{ - error::GameError, game::GameSettings, message::PlayerState, role::RoleTitle, + error::GameError, + game::{GameSettings, OrRandom, SetupRole, SetupSlot, SlotId}, + message::{Identification, PlayerState}, + role::RoleTitle, }; use yew::prelude::*; -const ALIGN_VILLAGE: &str = "village"; -const ALIGN_WOLVES: &str = "wolves"; +use crate::components::{Button, ClickableField, Identity}; #[derive(Debug, PartialEq, Properties)] pub struct SettingsProps { @@ -21,173 +23,486 @@ pub struct SettingsProps { pub on_error: Option>, } -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 disabled_reason = match props.settings.check() { - Ok(_) => (props.players_in_lobby.len() < props.settings.min_players_needed()) - .then(|| String::from("too few players for role setup")) - .or_else(|| { - props - .players_in_lobby - .iter() - .any(|p| p.identification.public.number.is_none()) - .then(|| String::from("not all players are assigned numbers")) - }) - .or_else(|| { - let mut unique: HashMap = HashMap::new(); - for p in props.players_in_lobby.iter() { - let num = p.identification.public.number.unwrap(); - if let Some(cnt) = unique.get_mut(&num) { - *cnt = NonZeroU8::new(cnt.get() + 1).unwrap(); - } else { - unique.insert(num, NonZeroU8::new(1).unwrap()); - } - } - let dupes = unique - .iter() - .filter_map(|(num, cnt)| (cnt.get() > 1).then_some(*num)) - .map(|dupe| dupe.to_string()) - .collect::>(); - dupes - .is_empty() - .not() - .then(|| format!("duplicate numbers: {}", dupes.join(", "))) - }), - Err(err) => Some(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), +pub fn Settings( + SettingsProps { + settings, + players_in_lobby, + on_update, + on_start, + on_error, + }: &SettingsProps, +) -> Html { + let players = players_in_lobby + .iter() + .map(|p| p.identification.clone()) + .collect::>(); + let disabled_reason = settings + .check_with_player_list(&players) + .err() + .map(|err| err.to_string()); + + let roles_in_setup: Rc<[RoleTitle]> = settings + .slots() + .iter() + .map(|s| Into::::into(s.role.clone())) + .collect(); + let on_update_role = on_update.clone(); + let settings = Rc::new(settings.clone()); + let on_update_role_settings = settings.clone(); + let already_assigned_pids = settings + .slots() + .iter() + .filter_map(|r| r.assign_to.clone()) + .collect::>(); + let players_for_assign = players + .iter() + .filter(|p| !already_assigned_pids.contains(&p.player_id)) + .cloned() + .collect::>(); + let update_role_card = Callback::from(move |act: SettingSlotAction| { + let mut new_settings = (*on_update_role_settings).clone(); + match act { + SettingSlotAction::Remove(slot_id) => new_settings.remove_slot(slot_id), + SettingSlotAction::Update(slot) => new_settings.update_slot(slot), } - if s != settings { - on_update.emit(s) + on_update_role.emit(new_settings); + }); + let roles = settings + .slots() + .iter() + .map(|slot| { + html! { + + } + }) + .collect::(); + + let add_roles_update = on_update.clone(); + let add_roles_buttons = RoleTitle::ALL + .iter() + .map(|r| { + let update = add_roles_update.clone(); + let settings = settings.clone(); + let role = *r; + let on_click = Callback::from(move |_| { + let mut settings = (*settings).clone(); + settings.new_slot(role); + update.emit(settings); + }); + let class = Into::::into(*r).category().class(); + let name = r.to_string().to_case(Case::Title); + html! { + + } + }) + .collect::(); + + let clear_bad_assigned = matches!( + settings.check_with_player_list(&players), + Err(GameError::AssignedMultipleTimes(_, _)) | Err(GameError::AssignedPlayerMissing(_)) + ) + .then(|| { + let clear_settings = settings.clone(); + let clear_update = on_update.clone(); + let clear_players = players.clone(); + let clear_bad_assigned = Callback::from(move |_| { + let mut settings = (*clear_settings).clone(); + settings.remove_assignments_not_in_list(&clear_players); + settings.remove_duplicate_assignments(); + clear_update.emit(settings); + }); + + html! { + } }); - let on_start = props.on_start.clone(); - let on_start_game = Callback::from(move |_| on_start.emit(())); + let clear_all_assignments = settings + .slots() + .iter() + .any(|s| s.assign_to.is_some()) + .then(|| { + let settings = settings.clone(); + let update = on_update.clone(); + let on_click = Callback::from(move |_| { + let mut settings = (*settings).clone(); + settings + .slots() + .iter() + .filter(|s| s.assign_to.is_some()) + .map(|s| { + let mut s = s.clone(); + s.assign_to.take(); + s + }) + .collect::>() + .into_iter() + .for_each(|s| settings.update_slot(s)); + update.emit(settings); + }); + html! { + + } + }); + + let assignments = settings + .slots() + .iter() + .any(|s| s.assign_to.is_some()) + .then(|| { + let assignments = settings + .slots() + .iter() + .cloned() + .filter_map(|s| { + s.assign_to + .as_ref() + .map(|a| { + players + .iter() + .find(|p| p.player_id == *a) + .map(|assign| { + html! { + + } + }) + .unwrap_or_else(|| { + html! { + {"[left the lobby]"} + } + }) + }) + .map(|who| { + let assignments_update = on_update.clone(); + let assignments_settings = settings.clone(); + let click_slot = s.clone(); + let on_click = Callback::from(move |_| { + let mut settings = (*assignments_settings).clone(); + let mut click_slot = click_slot.clone(); + click_slot.assign_to.take(); + settings.update_slot(click_slot); + assignments_update.emit(settings); + }); + html! { + + } + }) + }) + .collect::(); + + html! { + <> + +
+ {assignments} +
+ + } + }); - let roles = RoleTitle::ALL - .into_iter() - .filter(|r| !matches!(r, RoleTitle::Scapegoat | RoleTitle::Villager)) - .map(|r| html! {}) - .collect::(); - let disabled = disabled_reason.is_some(); html! {
-

{format!("Min players for settings: {}", props.settings.min_players_needed())}

+
+ {clear_all_assignments} + {clear_bad_assigned} +
+ {assignments} +

{format!("min roles for setup: {}", settings.min_players_needed())}

+

{format!("current role count: {}", settings.slots().len())}

+
+ {add_roles_buttons} +
- // {roles}
- +
} } -enum BoolRoleSet { - Scapegoat { enabled: bool }, +pub enum SettingSlotAction { + Remove(SlotId), + Update(SetupSlot), } #[derive(Debug, PartialEq, Properties)] -struct BoolRoleProps { - pub role: RoleTitle, - pub enabled: bool, - pub on_changed: Callback, +pub struct SettingsSlotProps { + pub players_for_assign: Rc<[Identification]>, + pub roles_in_setup: Rc<[RoleTitle]>, + pub slot: SetupSlot, + pub update: Callback, } #[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::() - .expect("input callback not on input"); - cb.emit(set_role(input.checked())) +pub fn SettingsSlot( + SettingsSlotProps { + players_for_assign, + roles_in_setup, + slot, + update, + }: &SettingsSlotProps, +) -> Html { + let open = use_state(|| false); + let open_update = open.setter(); + let update = update.clone(); + let update = Callback::from(move |act| { + update.emit(act); }); - html! { -
- //
- - - {role.to_string()} - - - //
-
- } -} - -#[derive(Debug, PartialEq, Properties)] -struct RoleProps { - pub role: RoleTitle, - pub amount: u8, - pub on_changed: Callback, -} - -#[function_component] -fn RoleCard(props: &RoleProps) -> Html { - let align_class = if props.role.wolf() { - ALIGN_WOLVES - } else { - ALIGN_VILLAGE + let role_name = slot.role.to_string().to_case(Case::Title); + let apprentice_open = use_state(|| false); + let submenu = { + let on_kick_update = update.clone(); + let slot_id = slot.slot_id; + let assign_open = use_state(|| false); + let on_kick = Callback::from(move |_| { + on_kick_update.emit(SettingSlotAction::Remove(slot_id)); + open_update.set(false); + }); + let assign_to = assign_to_submenu(players_for_assign, slot, &update, &open.setter()); + let options = + setup_options_for_slot(slot, &update, roles_in_setup, apprentice_open, open.clone()); + html! { + <> + + + {"assign"} + + {options} + + } }; - 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))); + let class = slot.role.category().class(); html! { -
- - {role_name}{amount.to_string()} - -
+ + + } } + +fn assign_to_submenu( + players: &[Identification], + slot: &SetupSlot, + update: &Callback, + assign_setter: &UseStateSetter, +) -> Html { + players + .iter() + .map(|p| { + let slot = slot.clone(); + let update = update.clone(); + let pid = p.player_id.clone(); + let setter = assign_setter.clone(); + let on_click = Callback::from(move |_| { + let mut slot = slot.clone(); + slot.assign_to.replace(pid.clone()); + update.emit(SettingSlotAction::Update(slot)); + setter.set(false); + }); + html! { + + } + }) + .collect() +} + +fn setup_options_for_slot( + slot: &SetupSlot, + update: &Callback, + roles_in_setup: &[RoleTitle], + open_apprentice_assign: UseStateHandle, + slot_field_open: UseStateHandle, +) -> Html { + let setup_options_for_role = match &slot.role { + SetupRole::Scapegoat { redeemed } => { + let next = { + let next_redeemed = match redeemed { + OrRandom::Random => OrRandom::Determined(true), + OrRandom::Determined(true) => OrRandom::Determined(false), + OrRandom::Determined(false) => OrRandom::Random, + }; + let mut s = slot.clone(); + match &mut s.role { + SetupRole::Scapegoat { redeemed } => *redeemed = next_redeemed, + _ => unreachable!(), + } + s + }; + let update = update.clone(); + let on_click = + Callback::from(move |_| update.emit(SettingSlotAction::Update(next.clone()))); + let body = match redeemed { + OrRandom::Determined(true) => "redeemed", + OrRandom::Determined(false) => "irredeemable", + OrRandom::Random => "random", + }; + Some(html! { + <> + + + + }) + } + SetupRole::Apprentice { specifically } => { + let options = roles_in_setup + .iter() + .filter(|r| r.is_mentor()) + .cloned() + .collect::>(); + + #[allow(clippy::obfuscated_if_else)] + options + .is_empty() + .not() + .then(|| { + options + .into_iter() + .filter(|o| specifically.as_ref().map(|s| s != o).unwrap_or(true)) + .map(|option| { + let open_apprentice_assign = open_apprentice_assign.clone(); + let open = slot_field_open.clone(); + let update = update.clone(); + let role_name = option.to_string().to_case(Case::Title); + let mut slot = slot.clone(); + match &mut slot.role { + SetupRole::Apprentice { specifically } => { + specifically.replace(option); + } + _ => unreachable!(), + } + let on_click = Callback::from(move |_| { + update.emit(SettingSlotAction::Update(slot.clone())); + open_apprentice_assign.set(false); + open.set(false); + }); + html! { + + } + }) + .chain(specifically.is_some().then(|| { + let open_apprentice_assign = open_apprentice_assign.clone(); + let open = slot_field_open.clone(); + let update = update.clone(); + let role_name = "random"; + let mut slot = slot.clone(); + match &mut slot.role { + SetupRole::Apprentice { specifically } => { + specifically.take(); + } + _ => unreachable!(), + } + let on_click = Callback::from(move |_| { + update.emit(SettingSlotAction::Update(slot.clone())); + open_apprentice_assign.set(false); + open.set(false); + }); + html! { + + } + })) + .collect::() + }) + .map(|options| { + let current = specifically + .as_ref() + .map(|r| r.to_string().to_case(Case::Title)) + .unwrap_or_else(|| String::from("random")); + html! { + + {"mentor ("}{current}{")"} + + } + }) + } + SetupRole::Elder { knows_on_night } => { + const SAFE_NUM: NonZeroU8 = NonZeroU8::new(1).unwrap(); + let increment_slot = { + let mut s = slot.clone(); + match &mut s.role { + SetupRole::Elder { knows_on_night } => { + *knows_on_night = + NonZeroU8::new(knows_on_night.get().checked_add(1).unwrap_or(1)) + .unwrap_or(SAFE_NUM) + } + _ => unreachable!(), + } + s + }; + let decrement_slot = { + let mut s = slot.clone(); + match &mut s.role { + SetupRole::Elder { knows_on_night } => { + *knows_on_night = + NonZeroU8::new(knows_on_night.get().checked_sub(1).unwrap_or(u8::MAX)) + .unwrap_or(SAFE_NUM) + } + _ => unreachable!(), + } + s + }; + let update_decrement = update.clone(); + let decrement = Callback::from(move |_| { + update_decrement.emit(SettingSlotAction::Update(decrement_slot.clone())) + }); + let update_increment = update.clone(); + let increment = Callback::from(move |_| { + update_increment.emit(SettingSlotAction::Update(increment_slot.clone())) + }); + Some(html! { + <> + +
+ + + +
+ + }) + } + + _ => None, + }; + + setup_options_for_role.unwrap_or_default() +} diff --git a/werewolves/src/components/toggle.rs b/werewolves/src/components/toggle.rs new file mode 100644 index 0000000..d33831e --- /dev/null +++ b/werewolves/src/components/toggle.rs @@ -0,0 +1,56 @@ +// use core::{ +// fmt::{Debug, Display}, +// ops::Not, +// }; + +// use yew::prelude::*; + +// use crate::components::Button; + +// #[derive(Debug, Clone, PartialEq, Properties)] +// pub struct ToggleIterProps +// where +// T: Debug + PartialEq + Clone + Display + 'static, +// I: Iterator + PartialEq + Clone + 'static, +// { +// pub iter: I, +// pub on_update: Callback, +// #[prop_or_default] +// pub disabled_reason: Option, +// #[prop_or_default] +// pub classes: yew::Classes, +// } + +// #[function_component] +// pub fn ToggleIter( +// ToggleIterProps { +// iter, +// on_update, +// disabled_reason, +// classes, +// }: &ToggleIterProps, +// ) -> Html +// where +// T: Debug + PartialEq + Clone + Display + 'static, +// I: Iterator + PartialEq + Clone + 'static, +// { +// let mut iter = iter.clone(); + +// let value = use_state(|| iter.next().unwrap()); +// let on_click_value = value.clone(); +// let on_click = Callback::from(move |_| on_click_value.set(on_click_value.not())); +// let children = if **value { +// on_true.clone() +// } else { +// on_false.clone() +// }; +// html! { +// +// } +// }