reworked game setup

This commit is contained in:
emilis 2025-10-04 17:50:29 +01:00
parent 48828cac8a
commit d664ff281d
No known key found for this signature in database
20 changed files with 1296 additions and 363 deletions

View File

@ -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;

View File

@ -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),
}

View File

@ -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<T> = core::result::Result<T, GameError>;

View File

@ -361,30 +361,17 @@ impl Night {
}
pub fn received_response(&mut self, resp: ActionResponse) -> Result<ServerAction> {
// 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 {

View File

@ -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<RoleTitle, NonZeroU8>,
roles: Vec<SetupSlot>,
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::<Box<[_]>>();
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::<Box<[_]>>();
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<Box<[Character]>> {
self.check_with_player_list(players)?;
let roles_in_game = self
.roles
.iter()
.map(|r| r.role.clone().into())
.collect::<Box<[RoleTitle]>>();
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::<Result<Box<[_]>>>()?;
let mut random_assign_players = players
.iter()
.filter(|p| {
!with_assigned_roles
.iter()
.any(|(r, _)| r.player_id == p.player_id)
})
.collect::<Box<[_]>>();
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::<Result<Box<[_]>>>()
}
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 _,
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::<usize>())
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::<Box<[_]>>();
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::<RoleTitle>::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());
}
}

View File

@ -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<T>
where
T: Debug + Clone + PartialEq,
StandardUniform: Distribution<T>,
{
Determined(T),
#[default]
Random,
}
impl<T> OrRandom<T>
where
for<'a> T: Debug + Clone + PartialEq,
StandardUniform: Distribution<T>,
{
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<bool> },
#[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<RoleTitle> },
#[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<Role, GameError> {
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::<Box<[_]>>();
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<SetupRole> 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<RoleTitle> 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<Modifier>,
pub assign_to: Option<PlayerId>,
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, GameError> {
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",
}
}
}

View File

@ -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::<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)| {
let player_str = player.public.to_string();
Character::new(player, role)
.ok_or(GameError::PlayerNotAssignedNumber(player_str))
})
.collect::<Result<Box<[_]>>>()?,
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,
}
}
}

View File

@ -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);

View File

@ -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,

View File

@ -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<RoleChange>,
}
#[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)),

View File

@ -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<CharacterId> },
#[checks(Alignment::Village)]
#[checks("powerful")]
Apprentice(Box<Role>),
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,
},

View File

@ -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);
}
}

View File

@ -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::<Box<[_]>>();
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());

View File

@ -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;
}

View File

@ -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! {
<CoverOfDarkness />
},
ClientEvent2::Disconnected => html! {
<div class="column-list">
<p>{"disconnected"}</p>

View File

@ -218,6 +218,7 @@ impl Connection2 {
fn message_to_client_state(&self, msg: ServerMessage) -> Option<ClientEvent2> {
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;
}

View File

@ -159,6 +159,7 @@ async fn worker(mut recv: Receiver<HostMessage>, scope: Scope<Host>) {
}
};
match parse {
Ok(ServerToHostMessage::Error(GameError::AwaitingResponse)) => {}
Ok(msg) => {
log::debug!("got message: {:?}", msg.title());
log::trace!("message content: {msg:?}");

View File

@ -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<bool>,
}
@ -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! {
<div class={class.clone()}>
<Button on_click={open_close}>{children.clone()}</Button>
<Button on_click={open_close} classes={button_class}>
{children.clone()}
</Button>
{submenu}
</div>
}

View File

@ -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<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 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<NonZeroU8, NonZeroU8> = 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::<Box<[_]>>();
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::<Rc<[_]>>();
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::<RoleTitle>::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::<Box<[_]>>();
let players_for_assign = players
.iter()
.filter(|p| !already_assigned_pids.contains(&p.player_id))
.cloned()
.collect::<Rc<[_]>>();
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! {
<SettingsSlot
players_for_assign={players_for_assign.clone()}
roles_in_setup={roles_in_setup.clone()}
slot={slot.clone()}
update={update_role_card.clone()}
/>
}
})
.collect::<Html>();
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::<SetupRole>::into(*r).category().class();
let name = r.to_string().to_case(Case::Title);
html! {
<Button on_click={on_click} classes={classes!(class, "add-role")}>
{name}
</Button>
}
})
.collect::<Html>();
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! {
<Button on_click={clear_bad_assigned}>
{"clear bad assignments"}
</Button>
}
});
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::<Box<[_]>>()
.into_iter()
.for_each(|s| settings.update_slot(s));
update.emit(settings);
});
html! {
<Button on_click={on_click}>{"clear all assignments"}</Button>
}
});
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! {
<Identity ident={assign.public.clone()}/>
}
})
.unwrap_or_else(|| {
html! {
<span>{"[left the lobby]"}</span>
}
})
})
.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! {
<Button classes={classes!("assignment")} on_click={on_click}>
<label>{s.role.to_string().to_case(Case::Title)}</label>
{who}
</Button>
}
})
})
.collect::<Html>();
html! {
<>
<label>{"assignments"}</label>
<div class="assignments">
{assignments}
</div>
</>
}
});
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>();
let disabled = disabled_reason.is_some();
html! {
<div class="settings">
<h2>{format!("Min players for settings: {}", props.settings.min_players_needed())}</h2>
<div class="top-settings">
{clear_all_assignments}
{clear_bad_assigned}
</div>
{assignments}
<p>{format!("min roles for setup: {}", settings.min_players_needed())}</p>
<p>{format!("current role count: {}", settings.slots().len())}</p>
<div class="roles-add-list">
{add_roles_buttons}
</div>
<div class="role-list">
// <BoolRoleCard role={RoleTitle::Scapegoat} enabled={props.settings.scapegoat} on_changed={on_bool_changed}/>
{roles}
</div>
<button reason={disabled_reason} disabled={disabled} class="start-game" onclick={on_start_game}>{"Start Game"}</button>
<Button
disabled_reason={disabled_reason}
classes={classes!("start-game")}
on_click={on_start.clone()}
>
{"start game"}
</Button>
</div>
}
}
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<BoolRoleSet>,
pub struct SettingsSlotProps {
pub players_for_assign: Rc<[Identification]>,
pub roles_in_setup: Rc<[RoleTitle]>,
pub slot: SetupSlot,
pub update: Callback<SettingSlotAction>,
}
#[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()))
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! {
<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 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! {
<>
<Button on_click={on_kick}>
{"remove"}
</Button>
<ClickableField
options={assign_to}
state={assign_open}
class={classes!("assign-list")}
>
{"assign"}
</ClickableField>
{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! {
<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>
<ClickableField
class={classes!("setup-slot")}
button_class={classes!(class)}
options={submenu}
state={open}
with_backdrop_exit=true
>
<label>{role_name}</label>
</ClickableField>
}
}
fn assign_to_submenu(
players: &[Identification],
slot: &SetupSlot,
update: &Callback<SettingSlotAction>,
assign_setter: &UseStateSetter<bool>,
) -> 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! {
<Button on_click={on_click}>
<Identity ident={p.public.clone()}/>
</Button>
}
})
.collect()
}
fn setup_options_for_slot(
slot: &SetupSlot,
update: &Callback<SettingSlotAction>,
roles_in_setup: &[RoleTitle],
open_apprentice_assign: UseStateHandle<bool>,
slot_field_open: UseStateHandle<bool>,
) -> 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! {
<>
<label>{"redeemed?"}</label>
<Button on_click={on_click}>
<label>{body}</label>
</Button>
</>
})
}
SetupRole::Apprentice { specifically } => {
let options = roles_in_setup
.iter()
.filter(|r| r.is_mentor())
.cloned()
.collect::<Box<[_]>>();
#[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! {
<Button on_click={on_click}>
{role_name}
</Button>
}
})
.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! {
<Button on_click={on_click}>
{role_name}
</Button>
}
}))
.collect::<Html>()
})
.map(|options| {
let current = specifically
.as_ref()
.map(|r| r.to_string().to_case(Case::Title))
.unwrap_or_else(|| String::from("random"));
html! {
<ClickableField
state={open_apprentice_assign}
options={options}
>
{"mentor ("}{current}{")"}
</ClickableField>
}
})
}
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! {
<>
<label>{"knows on night"}</label>
<div class={classes!("increment-decrement")}>
<Button on_click={decrement}>{"-"}</Button>
<label>{knows_on_night.to_string()}</label>
<Button on_click={increment}>{"+"}</Button>
</div>
</>
})
}
_ => None,
};
setup_options_for_role.unwrap_or_default()
}

View File

@ -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<T, I>
// where
// T: Debug + PartialEq + Clone + Display + 'static,
// I: Iterator<Item = T> + PartialEq + Clone + 'static,
// {
// pub iter: I,
// pub on_update: Callback<T>,
// #[prop_or_default]
// pub disabled_reason: Option<String>,
// #[prop_or_default]
// pub classes: yew::Classes,
// }
// #[function_component]
// pub fn ToggleIter<T, I>(
// ToggleIterProps {
// iter,
// on_update,
// disabled_reason,
// classes,
// }: &ToggleIterProps<T, I>,
// ) -> Html
// where
// T: Debug + PartialEq + Clone + Display + 'static,
// I: Iterator<Item = T> + 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! {
// <Button
// on_click={on_click}
// disabled_reason={disabled_reason.clone()}
// classes={classes}
// >
// {children}
// </Button>
// }
// }