aura setup & impl, shift fail screen, bugfixes

This commit is contained in:
emilis 2025-11-12 20:05:40 +00:00
parent f193e4e691
commit ac4ce81638
No known key found for this signature in database
45 changed files with 1796 additions and 366 deletions

View File

@ -9,7 +9,7 @@ log = { version = "0.4" }
serde_json = { version = "1.0" } serde_json = { version = "1.0" }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
uuid = { version = "1.17", features = ["v4", "serde"] } uuid = { version = "1.17", features = ["v4", "serde"] }
rand = { version = "0.9" } rand = { version = "0.9", features = ["std_rng"] }
werewolves-macros = { path = "../werewolves-macros" } werewolves-macros = { path = "../werewolves-macros" }
[dev-dependencies] [dev-dependencies]

View File

@ -1,5 +1,3 @@
use core::fmt::Display;
// Copyright (C) 2025 Emilis Bliūdžius // Copyright (C) 2025 Emilis Bliūdžius
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
@ -14,33 +12,37 @@ use core::fmt::Display;
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
use core::fmt::Display;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use werewolves_macros::ChecksAs; use werewolves_macros::{ChecksAs, Titles};
use crate::{ use crate::{
bag::DrunkBag,
game::{GameTime, Village}, game::{GameTime, Village},
role::{Alignment, Killer, Powerful}, role::{Alignment, Killer, Powerful},
team::Team, team::Team,
}; };
const BLOODLET_DURATION_DAYS: u8 = 2; const BLOODLET_DURATION_DAYS: u8 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ChecksAs)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ChecksAs, Titles)]
pub enum Aura { pub enum Aura {
#[checks("assignable")]
Traitor, Traitor,
#[checks("assignable")]
#[checks("cleansible")] #[checks("cleansible")]
Drunk, Drunk(DrunkBag),
#[checks("assignable")]
Insane, Insane,
#[checks("cleansible")] #[checks("cleansible")]
Bloodlet { Bloodlet { night: u8 },
night: u8,
},
} }
impl Display for Aura { impl Display for Aura {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self { f.write_str(match self {
Aura::Traitor => "Traitor", Aura::Traitor => "Traitor",
Aura::Drunk => "Drunk", Aura::Drunk(_) => "Drunk",
Aura::Insane => "Insane", Aura::Insane => "Insane",
Aura::Bloodlet { .. } => "Bloodlet", Aura::Bloodlet { .. } => "Bloodlet",
}) })
@ -50,7 +52,7 @@ impl Display for Aura {
impl Aura { impl Aura {
pub const fn expired(&self, village: &Village) -> bool { pub const fn expired(&self, village: &Village) -> bool {
match self { match self {
Aura::Traitor | Aura::Drunk | Aura::Insane => false, Aura::Traitor | Aura::Drunk(_) | Aura::Insane => false,
Aura::Bloodlet { Aura::Bloodlet {
night: applied_night, night: applied_night,
} => match village.time() { } => match village.time() {
@ -88,8 +90,16 @@ impl Auras {
&self.0 &self.0
} }
pub fn remove_aura(&mut self, aura: Aura) { pub fn list_mut(&mut self) -> &mut [Aura] {
self.0.retain(|a| *a != aura); &mut self.0
}
pub fn remove_aura(&mut self, aura: AuraTitle) {
self.0.retain(|a| a.title() != aura);
}
pub fn get_mut(&mut self, aura: AuraTitle) -> Option<&mut Aura> {
self.0.iter_mut().find(|a| a.title() == aura)
} }
/// purges expired [Aura]s and returns the ones that were removed /// purges expired [Aura]s and returns the ones that were removed
@ -129,7 +139,7 @@ impl Auras {
match aura { match aura {
Aura::Traitor => return Some(Alignment::Traitor), Aura::Traitor => return Some(Alignment::Traitor),
Aura::Bloodlet { .. } => return Some(Alignment::Wolves), Aura::Bloodlet { .. } => return Some(Alignment::Wolves),
Aura::Drunk | Aura::Insane => {} Aura::Drunk(_) | Aura::Insane => {}
} }
} }
None None
@ -151,3 +161,14 @@ impl Auras {
.then_some(Powerful::Powerful) .then_some(Powerful::Powerful)
} }
} }
impl AuraTitle {
pub fn into_aura(self) -> Aura {
match self {
AuraTitle::Traitor => Aura::Traitor,
AuraTitle::Drunk => Aura::Drunk(DrunkBag::default()),
AuraTitle::Insane => Aura::Insane,
AuraTitle::Bloodlet => Aura::Bloodlet { night: 0 },
}
}
}

160
werewolves-proto/src/bag.rs Normal file
View File

@ -0,0 +1,160 @@
// Copyright (C) 2025 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use rand::{SeedableRng, rngs::SmallRng, seq::SliceRandom};
// #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
// pub enum BagItem<T, V> {
// Left(T),
// Right(V),
// }
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Bag<T>(Vec<T>);
impl<T> Bag<T> {
pub fn new(items: impl IntoIterator<Item = T>) -> Self {
Self(items.into_iter().collect())
}
pub fn pull(&mut self) -> Option<T> {
self.0.pop()
}
pub fn peek(&self) -> Option<&T> {
self.0.last()
}
pub const fn len(&self) -> usize {
self.0.len()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
pub enum DrunkRoll {
Drunk,
#[default]
Sober,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct DrunkBag {
#[serde(skip)]
rng: SmallRng,
seed: u64,
bag_number: usize,
bag: Bag<DrunkRoll>,
}
impl<'de> Deserialize<'de> for DrunkBag {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct DrunkBagNoRng {
seed: u64,
bag_number: usize,
bag: Bag<DrunkRoll>,
}
let DrunkBagNoRng {
seed,
bag_number,
bag,
} = DrunkBagNoRng::deserialize(deserializer)?;
let mut rng = SmallRng::seed_from_u64(seed);
// Shuffle the default bag bag_number of times to get the smallrng to the same state
for _ in 0..bag_number {
Self::DEFAULT_BAG
.iter()
.copied()
.collect::<Box<[_]>>()
.shuffle(&mut rng);
}
Ok(Self {
rng,
seed,
bag_number,
bag,
})
}
}
impl Default for DrunkBag {
fn default() -> Self {
Self::new()
}
}
impl DrunkBag {
const DEFAULT_BAG: &[DrunkRoll] = &[
DrunkRoll::Drunk,
DrunkRoll::Drunk,
DrunkRoll::Sober,
DrunkRoll::Sober,
DrunkRoll::Sober,
];
#[cfg(test)]
#[doc(hidden)]
pub fn all_drunk() -> Self {
Self {
rng: SmallRng::seed_from_u64(0),
seed: 0,
bag_number: 1,
bag: Bag::new([
DrunkRoll::Drunk,
DrunkRoll::Drunk,
DrunkRoll::Drunk,
DrunkRoll::Drunk,
DrunkRoll::Drunk,
]),
}
}
pub fn new() -> Self {
let seed = rand::random();
let mut rng = SmallRng::seed_from_u64(seed);
let mut starting_bag = Self::DEFAULT_BAG.iter().copied().collect::<Box<[_]>>();
starting_bag.shuffle(&mut rng);
let bag = Bag::new(starting_bag);
Self {
rng,
seed,
bag,
bag_number: 1,
}
}
pub fn peek(&self) -> DrunkRoll {
self.bag.peek().copied().unwrap_or_default()
}
fn next_bag(&mut self) {
let mut starting_bag = Self::DEFAULT_BAG.iter().copied().collect::<Box<[_]>>();
starting_bag.shuffle(&mut self.rng);
self.bag = Bag::new(starting_bag);
self.bag_number += 1;
}
pub fn pull(&mut self) -> DrunkRoll {
if self.bag.len() < 2 {
*self = Self::new();
} else if self.bag.len() == 2 {
let pulled = self.bag.pull().unwrap_or_default();
self.next_bag();
return pulled;
}
self.bag.pull().unwrap_or_default()
}
}

View File

@ -22,7 +22,7 @@ use rand::seq::SliceRandom;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
aura::{Aura, Auras}, aura::{Aura, AuraTitle, Auras},
diedto::DiedTo, diedto::DiedTo,
error::GameError, error::GameError,
game::{GameTime, Village}, game::{GameTime, Village},
@ -207,6 +207,10 @@ impl Character {
self.auras.list() self.auras.list()
} }
pub fn auras_mut(&mut self) -> &mut [Aura] {
self.auras.list_mut()
}
pub fn role_change(&mut self, new_role: RoleTitle, at: GameTime) -> Result<()> { pub fn role_change(&mut self, new_role: RoleTitle, at: GameTime) -> Result<()> {
let mut role = new_role.title_to_role_excl_apprentice(); let mut role = new_role.title_to_role_excl_apprentice();
core::mem::swap(&mut role, &mut self.role); core::mem::swap(&mut role, &mut self.role);
@ -304,7 +308,7 @@ impl Character {
self.auras.add(aura); self.auras.add(aura);
} }
pub fn remove_aura(&mut self, aura: Aura) { pub fn remove_aura(&mut self, aura: AuraTitle) {
self.auras.remove_aura(aura); self.auras.remove_aura(aura);
} }
@ -319,7 +323,13 @@ impl Character {
GameTime::Day { number: _ } => return Err(GameError::NotNight), GameTime::Day { number: _ } => return Err(GameError::NotNight),
GameTime::Night { number } => number, GameTime::Night { number } => number,
}; };
Ok(Box::new([match &self.role { let mut prompts = Vec::new();
if night == 0 && self.auras.list().contains(&Aura::Traitor) {
prompts.push(ActionPrompt::TraitorIntro {
character_id: self.identity(),
});
}
match &self.role {
Role::Empath { cursed: true } Role::Empath { cursed: true }
| Role::Diseased | Role::Diseased
| Role::Weightlifter | Role::Weightlifter
@ -334,11 +344,11 @@ impl Character {
woken_for_reveal: true, woken_for_reveal: true,
.. ..
} }
| Role::Villager => return Ok(Box::new([])), | Role::Villager => {}
Role::Insomniac => ActionPrompt::Insomniac { Role::Insomniac => prompts.push(ActionPrompt::Insomniac {
character_id: self.identity(), character_id: self.identity(),
}, }),
Role::Scapegoat { redeemed: true } => { Role::Scapegoat { redeemed: true } => {
let mut dead = village.dead_characters(); let mut dead = village.dead_characters();
@ -347,49 +357,47 @@ impl Character {
.into_iter() .into_iter()
.find_map(|d| (d.is_village() && d.is_power_role()).then_some(d.role_title())) .find_map(|d| (d.is_village() && d.is_power_role()).then_some(d.role_title()))
{ {
ActionPrompt::RoleChange { prompts.push(ActionPrompt::RoleChange {
character_id: self.identity(), character_id: self.identity(),
new_role: pr, new_role: pr,
} });
} else {
return Ok(Box::new([]));
} }
} }
Role::Bloodletter => ActionPrompt::Bloodletter { Role::Bloodletter => prompts.push(ActionPrompt::Bloodletter {
character_id: self.identity(), character_id: self.identity(),
living_players: village.living_villagers(), living_players: village.living_villagers(),
marked: None, marked: None,
}, }),
Role::Seer => ActionPrompt::Seer { Role::Seer => prompts.push(ActionPrompt::Seer {
character_id: self.identity(), character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()), living_players: village.living_players_excluding(self.character_id()),
marked: None, marked: None,
}, }),
Role::Arcanist => ActionPrompt::Arcanist { Role::Arcanist => prompts.push(ActionPrompt::Arcanist {
character_id: self.identity(), character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()), living_players: village.living_players_excluding(self.character_id()),
marked: (None, None), marked: (None, None),
}, }),
Role::Protector { Role::Protector {
last_protected: Some(last_protected), last_protected: Some(last_protected),
} => ActionPrompt::Protector { } => prompts.push(ActionPrompt::Protector {
character_id: self.identity(), character_id: self.identity(),
targets: village.living_players_excluding(*last_protected), targets: village.living_players_excluding(*last_protected),
marked: None, marked: None,
}, }),
Role::Protector { Role::Protector {
last_protected: None, last_protected: None,
} => ActionPrompt::Protector { } => prompts.push(ActionPrompt::Protector {
character_id: self.identity(), character_id: self.identity(),
targets: village.living_players_excluding(self.character_id()), targets: village.living_players(),
marked: None, marked: None,
}, }),
Role::Apprentice(role) => { Role::Apprentice(role) => match village.time() {
let current_night = match village.time() { GameTime::Day { number: _ } => {}
GameTime::Day { number: _ } => return Ok(Box::new([])), GameTime::Night {
GameTime::Night { number } => number, number: current_night,
}; } => {
return Ok(village if village
.characters() .characters()
.into_iter() .into_iter()
.filter(|c| c.role_title() == *role) .filter(|c| c.role_title() == *role)
@ -398,48 +406,45 @@ impl Character {
GameTime::Day { number } => number.get() + 1 >= current_night, GameTime::Day { number } => number.get() + 1 >= current_night,
GameTime::Night { number } => number + 1 >= current_night, GameTime::Night { number } => number + 1 >= current_night,
}) })
.then(|| ActionPrompt::RoleChange { {
prompts.push(ActionPrompt::RoleChange {
character_id: self.identity(), character_id: self.identity(),
new_role: *role, new_role: *role,
}) });
.into_iter()
.collect());
} }
}
},
Role::Elder { Role::Elder {
knows_on_night, knows_on_night,
woken_for_reveal: false, woken_for_reveal: false,
.. ..
} => { } => match village.time() {
let current_night = match village.time() { GameTime::Day { number: _ } => {}
GameTime::Day { number: _ } => return Ok(Box::new([])), GameTime::Night { number } => {
GameTime::Night { number } => number, if number >= knows_on_night.get() {
}; prompts.push(ActionPrompt::ElderReveal {
return Ok((current_night >= knows_on_night.get())
.then_some({
ActionPrompt::ElderReveal {
character_id: self.identity(), character_id: self.identity(),
});
} }
})
.into_iter()
.collect());
} }
Role::Militia { targeted: None } => ActionPrompt::Militia { },
Role::Militia { targeted: None } => prompts.push(ActionPrompt::Militia {
character_id: self.identity(), character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()), living_players: village.living_players_excluding(self.character_id()),
marked: None, marked: None,
}, }),
Role::Werewolf => ActionPrompt::WolfPackKill { Role::Werewolf => prompts.push(ActionPrompt::WolfPackKill {
living_villagers: village.living_players(), living_villagers: village.living_players(),
marked: None, marked: None,
}, }),
Role::AlphaWolf { killed: None } => ActionPrompt::AlphaWolf { Role::AlphaWolf { killed: None } => prompts.push(ActionPrompt::AlphaWolf {
character_id: self.identity(), character_id: self.identity(),
living_villagers: village.living_players_excluding(self.character_id()), living_villagers: village.living_players_excluding(self.character_id()),
marked: None, marked: None,
}, }),
Role::DireWolf { Role::DireWolf {
last_blocked: Some(last_blocked), last_blocked: Some(last_blocked),
} => ActionPrompt::DireWolf { } => prompts.push(ActionPrompt::DireWolf {
character_id: self.identity(), character_id: self.identity(),
living_players: village living_players: village
.living_players_excluding(self.character_id()) .living_players_excluding(self.character_id())
@ -447,137 +452,124 @@ impl Character {
.filter(|c| c.character_id != *last_blocked) .filter(|c| c.character_id != *last_blocked)
.collect(), .collect(),
marked: None, marked: None,
}, }),
Role::DireWolf { .. } => ActionPrompt::DireWolf { Role::DireWolf { .. } => prompts.push(ActionPrompt::DireWolf {
character_id: self.identity(), character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()), living_players: village.living_players_excluding(self.character_id()),
marked: None, marked: None,
}, }),
Role::Shapeshifter { shifted_into: None } => ActionPrompt::Shapeshifter { Role::Shapeshifter { shifted_into: None } => prompts.push(ActionPrompt::Shapeshifter {
character_id: self.identity(), character_id: self.identity(),
}, }),
Role::Gravedigger => { Role::Gravedigger => {
let dead = village.dead_targets(); let dead = village.dead_targets();
if dead.is_empty() { if !dead.is_empty() {
return Ok(Box::new([])); prompts.push(ActionPrompt::Gravedigger {
}
ActionPrompt::Gravedigger {
character_id: self.identity(), character_id: self.identity(),
dead_players: village.dead_targets(), dead_players: village.dead_targets(),
marked: None, marked: None,
});
} }
} }
Role::Hunter { target } => ActionPrompt::Hunter { Role::Hunter { target } => prompts.push(ActionPrompt::Hunter {
character_id: self.identity(), character_id: self.identity(),
current_target: target.as_ref().and_then(|t| village.target_by_id(*t).ok()), current_target: target.as_ref().and_then(|t| village.target_by_id(*t).ok()),
living_players: village.living_players_excluding(self.character_id()), living_players: village.living_players_excluding(self.character_id()),
marked: None, marked: None,
}, }),
Role::MapleWolf { last_kill_on_night } => ActionPrompt::MapleWolf { Role::MapleWolf { last_kill_on_night } => prompts.push(ActionPrompt::MapleWolf {
character_id: self.identity(), character_id: self.identity(),
kill_or_die: last_kill_on_night + MAPLE_WOLF_ABSTAIN_LIMIT.get() == night, kill_or_die: last_kill_on_night + MAPLE_WOLF_ABSTAIN_LIMIT.get() == night,
living_players: village.living_players_excluding(self.character_id()), living_players: village.living_players_excluding(self.character_id()),
marked: None, marked: None,
}, }),
Role::Guardian { Role::Guardian {
last_protected: Some(PreviousGuardianAction::Guard(prev_target)), last_protected: Some(PreviousGuardianAction::Guard(prev_target)),
} => ActionPrompt::Guardian { } => prompts.push(ActionPrompt::Guardian {
character_id: self.identity(), character_id: self.identity(),
previous: Some(PreviousGuardianAction::Guard(prev_target.clone())), previous: Some(PreviousGuardianAction::Guard(prev_target.clone())),
living_players: village.living_players_excluding(prev_target.character_id), living_players: village.living_players_excluding(prev_target.character_id),
marked: None, marked: None,
}, }),
Role::Guardian { Role::Guardian {
last_protected: Some(PreviousGuardianAction::Protect(prev_target)), last_protected: Some(PreviousGuardianAction::Protect(prev_target)),
} => ActionPrompt::Guardian { } => prompts.push(ActionPrompt::Guardian {
character_id: self.identity(), character_id: self.identity(),
previous: Some(PreviousGuardianAction::Protect(prev_target.clone())), previous: Some(PreviousGuardianAction::Protect(prev_target.clone())),
living_players: village.living_players(), living_players: village.living_players(),
marked: None, marked: None,
}, }),
Role::Guardian { Role::Guardian {
last_protected: None, last_protected: None,
} => ActionPrompt::Guardian { } => prompts.push(ActionPrompt::Guardian {
character_id: self.identity(), character_id: self.identity(),
previous: None, previous: None,
living_players: village.living_players(), living_players: village.living_players(),
marked: None, marked: None,
}, }),
Role::Adjudicator => ActionPrompt::Adjudicator { Role::Adjudicator => prompts.push(ActionPrompt::Adjudicator {
character_id: self.identity(), character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()), living_players: village.living_players_excluding(self.character_id()),
marked: None, marked: None,
}, }),
Role::PowerSeer => ActionPrompt::PowerSeer { Role::PowerSeer => prompts.push(ActionPrompt::PowerSeer {
character_id: self.identity(), character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()), living_players: village.living_players_excluding(self.character_id()),
marked: None, marked: None,
}, }),
Role::Mortician => ActionPrompt::Mortician { Role::Mortician => {
character_id: self.identity(),
dead_players: {
let dead = village.dead_targets(); let dead = village.dead_targets();
if dead.is_empty() { if !dead.is_empty() {
return Ok(Box::new([])); prompts.push(ActionPrompt::Mortician {
} character_id: self.identity(),
dead dead_players: dead,
},
marked: None, marked: None,
}, });
Role::Beholder => ActionPrompt::Beholder { }
}
Role::Beholder => prompts.push(ActionPrompt::Beholder {
character_id: self.identity(), character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()), living_players: village.living_players_excluding(self.character_id()),
marked: None, marked: None,
}, }),
Role::MasonLeader { .. } => { Role::MasonLeader { .. } => {
log::error!( log::error!(
"night_action_prompts got to MasonLeader, should be handled before the living check" "night_action_prompts got to MasonLeader, should be handled before the living check"
); );
return Ok(Box::new([]));
} }
Role::Empath { cursed: false } => ActionPrompt::Empath { Role::Empath { cursed: false } => prompts.push(ActionPrompt::Empath {
character_id: self.identity(), character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()), living_players: village.living_players_excluding(self.character_id()),
marked: None, marked: None,
}, }),
Role::Vindicator => { Role::Vindicator => {
let last_day = match village.time() { if night != 0
GameTime::Day { .. } => { && let Some(last_day) = NonZeroU8::new(night)
log::error!( && village
"vindicator trying to get a prompt during the day? village state: {village:?}"
);
return Ok(Box::new([]));
}
GameTime::Night { number } => {
if number == 0 {
return Ok(Box::new([]));
}
NonZeroU8::new(number).unwrap()
}
};
return Ok(village
.executions_on_day(last_day) .executions_on_day(last_day)
.iter() .iter()
.any(|c| c.is_village()) .any(|c| c.is_village())
.then(|| ActionPrompt::Vindicator { {
prompts.push(ActionPrompt::Vindicator {
character_id: self.identity(), character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()), living_players: village.living_players_excluding(self.character_id()),
marked: None, marked: None,
}) });
.into_iter()
.collect());
} }
Role::PyreMaster { .. } => ActionPrompt::PyreMaster { }
Role::PyreMaster { .. } => prompts.push(ActionPrompt::PyreMaster {
character_id: self.identity(), character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()), living_players: village.living_players_excluding(self.character_id()),
marked: None, marked: None,
}, }),
Role::LoneWolf => ActionPrompt::LoneWolfKill { Role::LoneWolf => prompts.push(ActionPrompt::LoneWolfKill {
character_id: self.identity(), character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()), living_players: village.living_players_excluding(self.character_id()),
marked: None, marked: None,
}, }),
}])) }
Ok(prompts.into_boxed_slice())
} }
#[cfg(test)] #[cfg(test)]
@ -830,6 +822,28 @@ impl Character {
} }
} }
pub const fn maple_wolf_mut<'a>(&'a mut self) -> Result<MapleWolfMut<'a>> {
let title = self.role.title();
match &mut self.role {
Role::MapleWolf { last_kill_on_night } => Ok(MapleWolfMut(last_kill_on_night)),
_ => Err(GameError::InvalidRole {
expected: RoleTitle::MapleWolf,
got: title,
}),
}
}
pub const fn protector_mut<'a>(&'a mut self) -> Result<ProtectorMut<'a>> {
let title = self.role.title();
match &mut self.role {
Role::Protector { last_protected } => Ok(ProtectorMut(last_protected)),
_ => Err(GameError::InvalidRole {
expected: RoleTitle::Protector,
got: title,
}),
}
}
pub const fn initial_shown_role(&self) -> RoleTitle { pub const fn initial_shown_role(&self) -> RoleTitle {
self.role.initial_shown_role() self.role.initial_shown_role()
} }
@ -872,6 +886,8 @@ decl_ref_and_mut!(
Guardian, GuardianMut: Option<PreviousGuardianAction>; Guardian, GuardianMut: Option<PreviousGuardianAction>;
Direwolf, DirewolfMut: Option<CharacterId>; Direwolf, DirewolfMut: Option<CharacterId>;
Militia, MilitiaMut: Option<CharacterId>; Militia, MilitiaMut: Option<CharacterId>;
MapleWolf, MapleWolfMut: u8;
Protector, ProtectorMut: Option<CharacterId>;
); );
pub struct BlackKnightKill<'a> { pub struct BlackKnightKill<'a> {

View File

@ -97,4 +97,6 @@ pub enum GameError {
NoPreviousDuringDay, NoPreviousDuringDay,
#[error("militia already spent")] #[error("militia already spent")]
MilitiaSpent, MilitiaSpent,
#[error("this role doesn't mark anyone")]
RoleDoesntMark,
} }

View File

@ -72,6 +72,15 @@ impl Game {
} }
} }
#[cfg(test)]
#[doc(hidden)]
pub const fn village_mut(&mut self) -> &mut Village {
match &mut self.state {
GameState::Day { village, marked: _ } => village,
GameState::Night { night } => night.village_mut(),
}
}
pub fn process(&mut self, message: HostGameMessage) -> Result<ServerToHostMessage> { pub fn process(&mut self, message: HostGameMessage) -> Result<ServerToHostMessage> {
match (&mut self.state, message) { match (&mut self.state, message) {
(GameState::Night { night }, HostGameMessage::Night(HostNightMessage::NextPage)) => { (GameState::Night { night }, HostGameMessage::Night(HostNightMessage::NextPage)) => {
@ -102,7 +111,6 @@ impl Game {
(GameState::Day { village, marked }, HostGameMessage::Day(HostDayMessage::Execute)) => { (GameState::Day { village, marked }, HostGameMessage::Day(HostDayMessage::Execute)) => {
let time = village.time(); let time = village.time();
if let Some(outcome) = village.execute(marked)? { if let Some(outcome) = village.execute(marked)? {
log::warn!("adding to history for {}", village.time());
self.history.add( self.history.add(
village.time(), village.time(),
GameActions::DayDetails( GameActions::DayDetails(
@ -112,7 +120,6 @@ impl Game {
return Ok(ServerToHostMessage::GameOver(outcome)); return Ok(ServerToHostMessage::GameOver(outcome));
} }
let night = Night::new(village.clone())?; let night = Night::new(village.clone())?;
log::warn!("adding to history for {time}");
self.history.add( self.history.add(
time, time,
GameActions::DayDetails( GameActions::DayDetails(
@ -163,7 +170,6 @@ impl Game {
Err(GameError::NightOver) => { Err(GameError::NightOver) => {
let changes = night.collect_changes()?; let changes = night.collect_changes()?;
let village = night.village().with_night_changes(&changes)?; let village = night.village().with_night_changes(&changes)?;
log::warn!("adding to history for {}", night.village().time());
self.history.add( self.history.add(
night.village().time(), night.village().time(),
GameActions::NightDetails(NightDetails::new( GameActions::NightDetails(NightDetails::new(

View File

@ -13,6 +13,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
pub mod changes; pub mod changes;
mod next;
mod process; mod process;
use core::num::NonZeroU8; use core::num::NonZeroU8;
@ -58,7 +59,8 @@ impl From<ActionComplete> for ResponseOutcome {
impl ActionPrompt { impl ActionPrompt {
fn unless(&self) -> Option<Unless> { fn unless(&self) -> Option<Unless> {
match &self { match &self {
ActionPrompt::Insomniac { .. } ActionPrompt::TraitorIntro { .. }
| ActionPrompt::Insomniac { .. }
| ActionPrompt::MasonsWake { .. } | ActionPrompt::MasonsWake { .. }
| ActionPrompt::WolvesIntro { .. } | ActionPrompt::WolvesIntro { .. }
| ActionPrompt::RoleChange { .. } | ActionPrompt::RoleChange { .. }
@ -532,13 +534,7 @@ impl Night {
// role change associated with the shapeshift // role change associated with the shapeshift
self.action_queue.push_front(last_prompt); self.action_queue.push_front(last_prompt);
} }
log::warn!(
"next prompts: {:?}",
self.action_queue
.iter()
.map(ActionPrompt::title)
.collect::<Box<[_]>>()
);
*current_result = CurrentResult::None; *current_result = CurrentResult::None;
*current_changes = Vec::new(); *current_changes = Vec::new();
Ok(()) Ok(())
@ -592,7 +588,7 @@ impl Night {
} }
} }
fn apply_shapeshift(&mut self, source: &CharacterId) -> Result<()> { fn apply_shapeshift(&mut self, source: &CharacterId) -> Result<Option<ActionResult>> {
if let Some(kill_target) = self if let Some(kill_target) = self
.changes_from_actions() .changes_from_actions()
.into_iter() .into_iter()
@ -616,7 +612,7 @@ impl Night {
_ => false, _ => false,
}) { }) {
// there is protection, so the kill doesn't happen -> no shapeshift // there is protection, so the kill doesn't happen -> no shapeshift
return Ok(()); return Ok(Some(ActionResult::ShiftFailed));
} }
if self.changes_from_actions().into_iter().any(|c| { if self.changes_from_actions().into_iter().any(|c| {
@ -667,7 +663,7 @@ impl Night {
} }
} }
self.action_queue = new_queue; self.action_queue = new_queue;
Ok(()) Ok(None)
} }
pub const fn page(&self) -> Option<usize> { pub const fn page(&self) -> Option<usize> {
@ -681,8 +677,8 @@ impl Night {
match &mut self.night_state { match &mut self.night_state {
NightState::Active { current_result, .. } => match current_result { NightState::Active { current_result, .. } => match current_result {
CurrentResult::None => self.received_response(ActionResponse::Continue), CurrentResult::None => self.received_response(ActionResponse::Continue),
CurrentResult::Result(ActionResult::Continue) CurrentResult::GoBackToSleepAfterShown { .. }
| CurrentResult::GoBackToSleepAfterShown { .. } | CurrentResult::Result(ActionResult::Continue)
| CurrentResult::Result(ActionResult::GoBackToSleep) => { | CurrentResult::Result(ActionResult::GoBackToSleep) => {
Err(GameError::NightNeedsNext) Err(GameError::NightNeedsNext)
} }
@ -697,6 +693,18 @@ impl Night {
} }
} }
fn set_current_result(&mut self, result: CurrentResult) -> Result<()> {
match &mut self.night_state {
NightState::Active {
current_prompt: _,
current_result,
..
} => *current_result = result,
NightState::Complete => return Err(GameError::NightOver),
};
Ok(())
}
pub fn received_response(&mut self, resp: ActionResponse) -> Result<ServerAction> { pub fn received_response(&mut self, resp: ActionResponse) -> Result<ServerAction> {
match self.received_response_with_role_blocks(resp)? { match self.received_response_with_role_blocks(resp)? {
BlockResolvedOutcome::PromptUpdate(prompt) => match &mut self.night_state { BlockResolvedOutcome::PromptUpdate(prompt) => match &mut self.night_state {
@ -715,18 +723,18 @@ impl Night {
NightState::Complete => Err(GameError::NightOver), NightState::Complete => Err(GameError::NightOver),
}, },
BlockResolvedOutcome::ActionComplete(mut result, Some(change)) => { BlockResolvedOutcome::ActionComplete(mut result, Some(change)) => {
match &mut self.night_state { self.set_current_result(result.clone().into())?;
NightState::Active {
current_prompt: _,
current_result,
..
} => *current_result = result.clone().into(),
NightState::Complete => return Err(GameError::NightOver),
};
if let NightChange::Shapeshift { source, .. } = &change { if let NightChange::Shapeshift { source, .. } = &change {
// needs to be resolved _now_ so that the target can be woken // needs to be resolved _now_ so that the target can be woken
// for the role change with the wolves // for the role change with the wolves
self.apply_shapeshift(source)?; if let Some(result) = self.apply_shapeshift(source)? {
if let NightState::Active { current_result, .. } = &mut self.night_state {
*current_result = CurrentResult::None;
};
return Ok(ServerAction::Result(result));
}
return Ok(ServerAction::Result( return Ok(ServerAction::Result(
self.action_queue self.action_queue
.iter() .iter()
@ -766,10 +774,10 @@ impl Night {
} }
} }
fn received_response_consecutive_same_player_no_sleep( fn outcome_consecutive_same_player_no_sleep(
&self, &self,
resp: ActionResponse, outcome: ResponseOutcome,
) -> Result<ResponseOutcome> { ) -> ResponseOutcome {
let same_char = self let same_char = self
.current_character_id() .current_character_id()
.and_then(|curr| { .and_then(|curr| {
@ -780,25 +788,31 @@ impl Night {
}) })
.unwrap_or_default(); .unwrap_or_default();
match ( match (outcome, same_char) {
self.received_response_consecutive_wolves_dont_sleep(resp)?, (ResponseOutcome::PromptUpdate(p), _) => ResponseOutcome::PromptUpdate(p),
same_char,
) {
(ResponseOutcome::PromptUpdate(p), _) => Ok(ResponseOutcome::PromptUpdate(p)),
( (
ResponseOutcome::ActionComplete(ActionComplete { ResponseOutcome::ActionComplete(ActionComplete {
result: ActionResult::GoBackToSleep, result: ActionResult::GoBackToSleep,
change, change,
}), }),
true, true,
) => Ok(ResponseOutcome::ActionComplete(ActionComplete { ) => ResponseOutcome::ActionComplete(ActionComplete {
result: ActionResult::Continue, result: ActionResult::Continue,
change, change,
})), }),
(act, _) => Ok(act), (act, _) => act,
} }
} }
fn received_response_consecutive_same_player_no_sleep(
&self,
resp: ActionResponse,
) -> Result<ResponseOutcome> {
Ok(self.outcome_consecutive_same_player_no_sleep(
self.received_response_consecutive_wolves_dont_sleep(resp)?,
))
}
fn received_response_consecutive_wolves_dont_sleep( fn received_response_consecutive_wolves_dont_sleep(
&self, &self,
resp: ActionResponse, resp: ActionResponse,
@ -826,7 +840,11 @@ impl Night {
})); }));
} }
match (self.process(resp)?, current_wolfy, next_wolfy) { match (
self.received_response_with_auras(resp)?,
current_wolfy,
next_wolfy,
) {
(ResponseOutcome::PromptUpdate(p), _, _) => Ok(ResponseOutcome::PromptUpdate(p)), (ResponseOutcome::PromptUpdate(p), _, _) => Ok(ResponseOutcome::PromptUpdate(p)),
( (
ResponseOutcome::ActionComplete(ActionComplete { ResponseOutcome::ActionComplete(ActionComplete {
@ -911,6 +929,12 @@ impl Night {
&self.village &self.village
} }
#[cfg(test)]
#[doc(hidden)]
pub const fn village_mut(&mut self) -> &mut Village {
&mut self.village
}
pub const fn current_result(&self) -> Option<&ActionResult> { pub const fn current_result(&self) -> Option<&ActionResult> {
match &self.night_state { match &self.night_state {
NightState::Active { NightState::Active {
@ -957,6 +981,11 @@ impl Night {
.and_then(|id| self.village.character_by_id(id).ok()) .and_then(|id| self.village.character_by_id(id).ok())
} }
pub fn current_character_mut(&mut self) -> Option<&mut Character> {
self.current_character_id()
.and_then(|id| self.village.character_by_id_mut(id).ok())
}
pub const fn complete(&self) -> bool { pub const fn complete(&self) -> bool {
matches!(self.night_state, NightState::Complete) matches!(self.night_state, NightState::Complete)
} }
@ -968,83 +997,21 @@ impl Night {
.collect() .collect()
} }
#[allow(clippy::should_implement_trait)] pub fn append_change(&mut self, change: NightChange) -> Result<()> {
pub fn next(&mut self) -> Result<()> { match &mut self.night_state {
match &self.night_state {
NightState::Active { NightState::Active {
current_prompt, current_changes, ..
current_result: CurrentResult::Result(ActionResult::Continue),
current_changes,
..
} => { } => {
self.used_actions.push(( current_changes.push(change);
current_prompt.clone(),
ActionResult::Continue,
current_changes.clone(),
));
}
NightState::Active {
current_prompt,
current_result: CurrentResult::Result(ActionResult::GoBackToSleep),
current_changes,
..
} => {
self.used_actions.push((
current_prompt.clone(),
ActionResult::GoBackToSleep,
current_changes.clone(),
));
}
NightState::Active {
current_result: CurrentResult::Result(_),
..
} => {
// needs Continue, not Next
return Err(GameError::AwaitingResponse);
}
NightState::Active {
current_prompt,
current_result: CurrentResult::GoBackToSleepAfterShown { result_with_data },
current_changes,
..
} => {
self.used_actions.push((
current_prompt.clone(),
result_with_data.clone(),
current_changes.clone(),
));
}
NightState::Active {
current_prompt: _,
current_result: CurrentResult::None,
..
} => return Err(GameError::AwaitingResponse),
NightState::Complete => return Err(GameError::NightOver),
}
if let Some(prompt) = self.action_queue.pop_front() {
if let ActionPrompt::Insomniac { character_id } = &prompt
&& self.get_visits_for(character_id.character_id).is_empty()
{
// skip!
self.used_actions.pop(); // it will be re-added
return self.next();
}
self.night_state = NightState::Active {
current_prompt: prompt,
current_result: CurrentResult::None,
current_changes: Vec::new(),
current_page: 0,
};
} else {
self.night_state = NightState::Complete;
}
Ok(()) Ok(())
} }
NightState::Complete => Err(GameError::NightOver),
}
}
/// resolves whether the target [CharacterId] dies tonight with the current /// resolves whether the target [CharacterId] dies tonight with the current
/// state of the night /// state of the night and returns the [DiedTo] cause of death
fn dies_tonight(&self, character_id: CharacterId) -> Result<bool> { fn died_to_tonight(&self, character_id: CharacterId) -> Result<Option<DiedTo>> {
let ch = self.current_changes(); let ch = self.current_changes();
let mut changes = ChangesLookup::new(&ch); let mut changes = ChangesLookup::new(&ch);
if let Some(died_to) = changes.killed(character_id) if let Some(died_to) = changes.killed(character_id)
@ -1057,9 +1024,9 @@ impl Night {
)? )?
.is_some() .is_some()
{ {
Ok(true) Ok(Some(died_to.clone()))
} else { } else {
Ok(false) Ok(None)
} }
} }
@ -1202,7 +1169,8 @@ impl Night {
.. ..
} => (*marked == visit_char).then(|| character_id.clone()), } => (*marked == visit_char).then(|| character_id.clone()),
ActionPrompt::Bloodletter { .. } ActionPrompt::TraitorIntro { .. }
| ActionPrompt::Bloodletter { .. }
| ActionPrompt::WolfPackKill { marked: None, .. } | ActionPrompt::WolfPackKill { marked: None, .. }
| ActionPrompt::Arcanist { marked: _, .. } | ActionPrompt::Arcanist { marked: _, .. }
| ActionPrompt::LoneWolfKill { marked: None, .. } | ActionPrompt::LoneWolfKill { marked: None, .. }

View File

@ -0,0 +1,148 @@
// Copyright (C) 2025 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use core::num::NonZeroU8;
use crate::{
diedto::DiedTo,
error::GameError,
game::night::{CurrentResult, Night, NightState, changes::NightChange},
message::night::{ActionPrompt, ActionResult},
};
use super::Result;
impl Night {
#[allow(clippy::should_implement_trait)]
pub fn next(&mut self) -> Result<()> {
self.next_state_process_maple_starving()?;
match &self.night_state {
NightState::Active {
current_prompt,
current_result: CurrentResult::Result(ActionResult::Continue),
current_changes,
..
} => {
self.used_actions.push((
current_prompt.clone(),
ActionResult::Continue,
current_changes.clone(),
));
}
NightState::Active {
current_prompt,
current_result: CurrentResult::Result(ActionResult::GoBackToSleep),
current_changes,
..
} => {
self.used_actions.push((
current_prompt.clone(),
ActionResult::GoBackToSleep,
current_changes.clone(),
));
}
NightState::Active {
current_result: CurrentResult::Result(_),
..
} => {
// needs Continue, not Next
return Err(GameError::AwaitingResponse);
}
NightState::Active {
current_prompt,
current_result: CurrentResult::GoBackToSleepAfterShown { result_with_data },
current_changes,
..
} => {
self.used_actions.push((
current_prompt.clone(),
result_with_data.clone(),
current_changes.clone(),
));
}
NightState::Active {
current_prompt: _,
current_result: CurrentResult::None,
..
} => return Err(GameError::AwaitingResponse),
NightState::Complete => return Err(GameError::NightOver),
}
if let Some(prompt) = self.action_queue.pop_front() {
if let ActionPrompt::Insomniac { character_id } = &prompt
&& self.get_visits_for(character_id.character_id).is_empty()
{
// skip!
self.used_actions.pop(); // it will be re-added
return self.next();
}
self.night_state = NightState::Active {
current_prompt: prompt,
current_result: CurrentResult::None,
current_changes: Vec::new(),
current_page: 0,
};
} else {
self.night_state = NightState::Complete;
}
Ok(())
}
fn next_state_process_maple_starving(&mut self) -> Result<()> {
let (maple_id, target) = match self.current_prompt() {
Some((
ActionPrompt::MapleWolf {
character_id,
kill_or_die,
marked,
..
},
_,
)) => {
if *kill_or_die {
(character_id.character_id, *marked)
} else {
return Ok(());
}
}
Some(_) | None => return Ok(()),
};
let starve_change = if let Some(night) = NonZeroU8::new(self.night) {
NightChange::Kill {
target: maple_id,
died_to: DiedTo::MapleWolfStarved { night },
}
} else {
return Ok(());
};
let Some(target) = target else {
return self.append_change(starve_change);
};
match self.died_to_tonight(target)? {
Some(DiedTo::MapleWolf { source, .. }) => {
if source != maple_id {
self.append_change(starve_change)?;
}
}
Some(_) | None => {
self.append_change(starve_change)?;
}
}
Ok(())
}
}

View File

@ -16,6 +16,7 @@ use core::num::NonZeroU8;
use crate::{ use crate::{
aura::Aura, aura::Aura,
bag::DrunkRoll,
diedto::DiedTo, diedto::DiedTo,
error::GameError, error::GameError,
game::night::{ game::night::{
@ -23,7 +24,7 @@ use crate::{
}, },
message::night::{ActionPrompt, ActionResponse, ActionResult}, message::night::{ActionPrompt, ActionResponse, ActionResult},
player::Protection, player::Protection,
role::{AlignmentEq, PreviousGuardianAction, RoleBlock, RoleTitle}, role::{Alignment, AlignmentEq, PreviousGuardianAction, RoleBlock, RoleTitle},
}; };
type Result<T> = core::result::Result<T, GameError>; type Result<T> = core::result::Result<T, GameError>;
@ -109,10 +110,15 @@ impl Night {
}; };
match current_prompt { match current_prompt {
ActionPrompt::TraitorIntro { .. } => Ok(ActionComplete {
result: ActionResult::GoBackToSleep,
change: None,
}
.into()),
ActionPrompt::Bloodletter { ActionPrompt::Bloodletter {
character_id, character_id,
living_players,
marked: Some(marked), marked: Some(marked),
..
} => Ok(ActionComplete { } => Ok(ActionComplete {
result: ActionResult::GoBackToSleep, result: ActionResult::GoBackToSleep,
change: Some(NightChange::ApplyAura { change: Some(NightChange::ApplyAura {
@ -236,12 +242,11 @@ impl Night {
.. ..
} => Ok(ResponseOutcome::ActionComplete(ActionComplete { } => Ok(ResponseOutcome::ActionComplete(ActionComplete {
result: ActionResult::GoBackToSleep, result: ActionResult::GoBackToSleep,
change: Some(NightChange::Kill { change: NonZeroU8::new(self.night).map(|night| NightChange::Kill {
target: *marked, target: *marked,
died_to: DiedTo::MapleWolf { died_to: DiedTo::MapleWolf {
night,
source: character_id.character_id, source: character_id.character_id,
night: NonZeroU8::new(self.night)
.ok_or(GameError::InvalidMessageForGameState)?,
starves_if_fails: *kill_or_die, starves_if_fails: *kill_or_die,
}, },
}), }),
@ -410,10 +415,11 @@ impl Night {
} => { } => {
if let Some(result) = self.used_actions.iter().find_map(|(prompt, result, _)| { if let Some(result) = self.used_actions.iter().find_map(|(prompt, result, _)| {
prompt.matches_beholding(*marked).then_some(result) prompt.matches_beholding(*marked).then_some(result)
}) && self.dies_tonight(*marked)? }) && self.died_to_tonight(*marked)?.is_some()
{ {
Ok(ActionComplete { Ok(ActionComplete {
result: if matches!(result, ActionResult::RoleBlocked) { result: if matches!(result, ActionResult::RoleBlocked | ActionResult::Drunk)
{
ActionResult::BeholderSawNothing ActionResult::BeholderSawNothing
} else { } else {
result.clone() result.clone()
@ -534,4 +540,38 @@ impl Night {
| ActionPrompt::Seer { marked: None, .. } => Err(GameError::InvalidMessageForGameState), | ActionPrompt::Seer { marked: None, .. } => Err(GameError::InvalidMessageForGameState),
} }
} }
pub(super) fn received_response_with_auras(
&self,
resp: ActionResponse,
) -> Result<ResponseOutcome> {
let outcome = self.process(resp)?;
let mut act = match outcome {
ResponseOutcome::PromptUpdate(prompt) => {
return Ok(ResponseOutcome::PromptUpdate(prompt));
}
ResponseOutcome::ActionComplete(act) => act,
};
let Some(char) = self.current_character() else {
return Ok(ResponseOutcome::ActionComplete(act));
};
for aura in char.auras() {
match aura {
Aura::Traitor | Aura::Bloodlet { .. } => continue,
Aura::Drunk(bag) => {
if bag.peek() == DrunkRoll::Drunk {
act.change = None;
act.result = ActionResult::Drunk;
return Ok(ResponseOutcome::ActionComplete(act));
}
}
Aura::Insane => {
if let Some(insane_result) = act.result.insane() {
act.result = insane_result;
}
}
}
}
Ok(ResponseOutcome::ActionComplete(act))
}
} }

View File

@ -23,7 +23,7 @@ use uuid::Uuid;
use werewolves_macros::{All, ChecksAs, Titles}; use werewolves_macros::{All, ChecksAs, Titles};
use crate::{ use crate::{
aura::Aura, aura::{Aura, AuraTitle},
character::Character, character::Character,
error::GameError, error::GameError,
message::Identification, message::Identification,
@ -157,6 +157,39 @@ pub enum SetupRole {
} }
impl SetupRoleTitle { impl SetupRoleTitle {
pub fn can_assign_aura(&self, aura: AuraTitle) -> bool {
if self.into_role().title().wolf() {
return match aura {
AuraTitle::Traitor | AuraTitle::Bloodlet | AuraTitle::Insane => false,
AuraTitle::Drunk => !matches!(self, SetupRoleTitle::Werewolf),
};
}
match aura {
AuraTitle::Traitor => true,
AuraTitle::Drunk => {
matches!(
self.category(),
Category::StartsAsVillager
| Category::Defensive
| Category::Intel
| Category::Offensive
) && !matches!(
self,
Self::Elder
| Self::BlackKnight
| Self::Diseased
| Self::Weightlifter
| Self::Insomniac
| Self::Mortician
)
}
AuraTitle::Insane => {
matches!(self.category(), Category::Intel)
&& !matches!(self, Self::MasonLeader | Self::Empath)
}
AuraTitle::Bloodlet => false,
}
}
pub fn into_role(self) -> Role { pub fn into_role(self) -> Role {
match self { match self {
SetupRoleTitle::Bloodletter => Role::Bloodletter, SetupRoleTitle::Bloodletter => Role::Bloodletter,
@ -379,7 +412,7 @@ impl SlotId {
pub struct SetupSlot { pub struct SetupSlot {
pub slot_id: SlotId, pub slot_id: SlotId,
pub role: SetupRole, pub role: SetupRole,
pub auras: Vec<Aura>, pub auras: Vec<AuraTitle>,
pub assign_to: Option<PlayerId>, pub assign_to: Option<PlayerId>,
pub created_order: u32, pub created_order: u32,
} }
@ -403,7 +436,10 @@ impl SetupSlot {
Character::new( Character::new(
ident.clone(), ident.clone(),
self.role.into_role(roles_in_game)?, self.role.into_role(roles_in_game)?,
self.auras, self.auras
.into_iter()
.map(|aura| aura.into_aura())
.collect(),
) )
.ok_or(GameError::PlayerNotAssignedNumber(ident.to_string())) .ok_or(GameError::PlayerNotAssignedNumber(ident.to_string()))
} }

View File

@ -84,12 +84,18 @@ pub enum StoryActionResult {
Insomniac { visits: Box<[CharacterId]> }, Insomniac { visits: Box<[CharacterId]> },
Empath { scapegoat: bool }, Empath { scapegoat: bool },
BeholderSawNothing, BeholderSawNothing,
BeholderSawEverything,
Drunk,
ShiftFailed,
} }
impl StoryActionResult { impl StoryActionResult {
pub fn new(result: ActionResult) -> Option<Self> { pub fn new(result: ActionResult) -> Option<Self> {
Some(match result { Some(match result {
ActionResult::ShiftFailed => Self::ShiftFailed,
ActionResult::BeholderSawNothing => Self::BeholderSawNothing, ActionResult::BeholderSawNothing => Self::BeholderSawNothing,
ActionResult::BeholderSawEverything => Self::BeholderSawEverything,
ActionResult::Drunk => Self::Drunk,
ActionResult::RoleBlocked => Self::RoleBlocked, ActionResult::RoleBlocked => Self::RoleBlocked,
ActionResult::Seer(alignment) => Self::Seer(alignment), ActionResult::Seer(alignment) => Self::Seer(alignment),
ActionResult::PowerSeer { powerful } => Self::PowerSeer { powerful }, ActionResult::PowerSeer { powerful } => Self::PowerSeer { powerful },
@ -390,7 +396,8 @@ impl StoryActionPrompt {
character_id: character_id.character_id, character_id: character_id.character_id,
}, },
ActionPrompt::Bloodletter { .. } ActionPrompt::TraitorIntro { .. }
| ActionPrompt::Bloodletter { .. }
| ActionPrompt::Protector { .. } | ActionPrompt::Protector { .. }
| ActionPrompt::Gravedigger { .. } | ActionPrompt::Gravedigger { .. }
| ActionPrompt::Hunter { .. } | ActionPrompt::Hunter { .. }

View File

@ -15,7 +15,7 @@
use core::num::NonZeroU8; use core::num::NonZeroU8;
use crate::{ use crate::{
aura::Aura, aura::{Aura, AuraTitle},
diedto::DiedTo, diedto::DiedTo,
error::GameError, error::GameError,
game::{ game::{
@ -51,11 +51,27 @@ impl Village {
let mut changes = ChangesLookup::new(all_changes); let mut changes = ChangesLookup::new(all_changes);
let mut new_village = self.clone(); let mut new_village = self.clone();
// dispose of the current drunk token for every drunk in the village
new_village
.characters_mut()
.iter_mut()
.filter_map(|c| {
c.auras_mut().iter_mut().find_map(|a| match a {
Aura::Drunk(bag) => Some(bag),
_ => None,
})
})
.for_each(|bag| {
// dispose of a token
let _ = bag.pull();
});
for change in all_changes { for change in all_changes {
match change { match change {
NightChange::ApplyAura { target, aura, .. } => { NightChange::ApplyAura { target, aura, .. } => {
let target = new_village.character_by_id_mut(*target)?; let target = new_village.character_by_id_mut(*target)?;
target.apply_aura(*aura); target.apply_aura(aura.clone());
} }
NightChange::ElderReveal { elder } => { NightChange::ElderReveal { elder } => {
new_village.character_by_id_mut(*elder)?.elder_reveal() new_village.character_by_id_mut(*elder)?.elder_reveal()
@ -84,6 +100,11 @@ impl Village {
kill::resolve_kill(&mut changes, *target, died_to, night, self)? kill::resolve_kill(&mut changes, *target, died_to, night, self)?
{ {
kill.apply_to_village(&mut new_village)?; kill.apply_to_village(&mut new_village)?;
if let DiedTo::MapleWolf { source, .. } = died_to
&& let Ok(maple) = new_village.character_by_id_mut(*source)
{
*maple.maple_wolf_mut()? = night;
}
} }
} }
NightChange::Shapeshift { source, into } => { NightChange::Shapeshift { source, into } => {
@ -157,9 +178,38 @@ impl Village {
NightChange::LostAura { character, aura } => { NightChange::LostAura { character, aura } => {
new_village new_village
.character_by_id_mut(*character)? .character_by_id_mut(*character)?
.remove_aura(*aura); .remove_aura(aura.title());
}
NightChange::Protection { protection, target } => {
let target_ident = new_village.character_by_id(*target)?.identity();
match protection {
Protection::Guardian {
source,
guarding: true,
} => {
new_village
.character_by_id_mut(*source)?
.guardian_mut()?
.replace(PreviousGuardianAction::Guard(target_ident));
}
Protection::Guardian {
source,
guarding: false,
} => {
new_village
.character_by_id_mut(*source)?
.guardian_mut()?
.replace(PreviousGuardianAction::Protect(target_ident));
}
Protection::Protector { source } => {
new_village
.character_by_id_mut(*source)?
.protector_mut()?
.replace(*target);
}
Protection::Vindicator { .. } => {}
}
} }
NightChange::Protection { .. } => {}
} }
} }
// black knights death // black knights death

View File

@ -186,9 +186,17 @@ pub trait ActionResultExt {
fn adjudicator(&self) -> Killer; fn adjudicator(&self) -> Killer;
fn mortician(&self) -> DiedToTitle; fn mortician(&self) -> DiedToTitle;
fn empath(&self) -> bool; fn empath(&self) -> bool;
fn drunk(&self);
fn shapeshift_failed(&self);
} }
impl ActionResultExt for ActionResult { impl ActionResultExt for ActionResult {
fn shapeshift_failed(&self) {
assert_eq!(*self, Self::ShiftFailed)
}
fn drunk(&self) {
assert_eq!(*self, Self::Drunk)
}
fn empath(&self) -> bool { fn empath(&self) -> bool {
match self { match self {
Self::Empath { scapegoat } => *scapegoat, Self::Empath { scapegoat } => *scapegoat,
@ -322,6 +330,7 @@ pub trait GameExt {
fn get_state(&mut self) -> ServerToHostMessage; fn get_state(&mut self) -> ServerToHostMessage;
fn next_expect_game_over(&mut self) -> GameOver; fn next_expect_game_over(&mut self) -> GameOver;
fn prev(&mut self) -> ServerToHostMessage; fn prev(&mut self) -> ServerToHostMessage;
fn mark_villager(&mut self) -> ActionPrompt;
} }
impl GameExt for Game { impl GameExt for Game {
@ -394,10 +403,15 @@ impl GameExt for Game {
.prompt() .prompt()
} }
fn mark_villager(&mut self) -> ActionPrompt {
self.mark(self.living_villager().character_id())
}
fn mark_and_check(&mut self, mark: CharacterId) { fn mark_and_check(&mut self, mark: CharacterId) {
let prompt = self.mark(mark); let prompt = self.mark(mark);
match prompt { match prompt {
ActionPrompt::Insomniac { .. } ActionPrompt::TraitorIntro { .. }
| ActionPrompt::Insomniac { .. }
| ActionPrompt::MasonsWake { .. } | ActionPrompt::MasonsWake { .. }
| ActionPrompt::ElderReveal { .. } | ActionPrompt::ElderReveal { .. }
| ActionPrompt::CoverOfDarkness | ActionPrompt::CoverOfDarkness
@ -923,7 +937,9 @@ fn big_game_test_based_on_story_test() {
game.r#continue().r#continue(); game.r#continue().r#continue();
game.next().title().shapeshifter(); game.next().title().shapeshifter();
game.response(ActionResponse::Shapeshift).sleep(); game.response(ActionResponse::Shapeshift)
.shapeshift_failed();
game.r#continue().sleep();
game.next().title().seer(); game.next().title().seer();
game.mark(game.character_by_player_id(werewolf).character_id()); game.mark(game.character_by_player_id(werewolf).character_id());

View File

@ -21,7 +21,8 @@ use crate::{
diedto::DiedToTitle, diedto::DiedToTitle,
game::{Game, GameSettings, GameState, OrRandom, SetupRole}, game::{Game, GameSettings, GameState, OrRandom, SetupRole},
game_test::{ game_test::{
ActionPromptTitleExt, ActionResultExt, GameExt, SettingsExt, gen_players, init_log, ActionPromptTitleExt, ActionResultExt, GameExt, ServerToHostMessageExt, SettingsExt,
gen_players, init_log,
}, },
message::{ message::{
Identification, PublicIdentity, Identification, PublicIdentity,
@ -353,7 +354,9 @@ fn previous_prompt() {
game.r#continue().r#continue(); game.r#continue().r#continue();
game.next().title().shapeshifter(); game.next().title().shapeshifter();
game.response(ActionResponse::Shapeshift).sleep(); game.response(ActionResponse::Shapeshift)
.shapeshift_failed();
game.r#continue().sleep();
game.next().title().seer(); game.next().title().seer();
game.mark(game.character_by_player_id(werewolf).character_id()); game.mark(game.character_by_player_id(werewolf).character_id());

View File

@ -0,0 +1,334 @@
// Copyright (C) 2025 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use core::num::{NonZero, NonZeroU8};
#[allow(unused)]
use pretty_assertions::{assert_eq, assert_ne, assert_str_eq};
use crate::{
aura::Aura,
bag::DrunkBag,
diedto::DiedTo,
game::{Game, GameSettings, SetupRole},
game_test::{
ActionPromptTitleExt, ActionResultExt, GameExt, SettingsExt, gen_players, init_log,
},
message::night::ActionPromptTitle,
role::{Alignment, Killer, Powerful},
};
#[test]
fn maple_starves() {
init_log();
let players = gen_players(1..21);
let mut player_ids = players.iter().map(|p| p.player_id);
let maple = player_ids.next().unwrap();
let wolf = player_ids.next().unwrap();
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::MapleWolf, maple);
settings.add_and_assign(SetupRole::Werewolf, wolf);
settings.fill_remaining_slots_with_villagers(20);
let mut game = Game::new(&players, settings).unwrap();
game.r#continue().r#continue();
assert_eq!(game.next().title(), ActionPromptTitle::WolvesIntro);
game.r#continue().sleep();
game.next_expect_day();
game.execute().title().wolf_pack_kill();
game.mark_villager();
game.r#continue().sleep();
game.next().title().maple_wolf();
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(
*game.character_by_player_id(maple).maple_wolf_mut().unwrap(),
0
);
game.execute().title().wolf_pack_kill();
game.mark_villager();
game.r#continue().sleep();
game.next().title().maple_wolf();
game.r#continue().sleep();
game.next_expect_day();
game.execute().title().wolf_pack_kill();
game.mark_villager();
game.r#continue().sleep();
game.next().title().maple_wolf();
game.r#continue().sleep();
game.next_expect_day();
game.execute().title().wolf_pack_kill();
game.mark_villager();
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(
game.character_by_player_id(maple).died_to().cloned(),
Some(DiedTo::MapleWolfStarved {
night: NonZeroU8::new(3).unwrap()
})
);
}
#[test]
fn maple_last_eat_counter_increments() {
init_log();
let players = gen_players(1..21);
let mut player_ids = players.iter().map(|p| p.player_id);
let maple = player_ids.next().unwrap();
let wolf = player_ids.next().unwrap();
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::MapleWolf, maple);
settings.add_and_assign(SetupRole::Werewolf, wolf);
settings.fill_remaining_slots_with_villagers(20);
let mut game = Game::new(&players, settings).unwrap();
game.r#continue().r#continue();
assert_eq!(game.next().title(), ActionPromptTitle::WolvesIntro);
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(
*game.character_by_player_id(maple).maple_wolf_mut().unwrap(),
0
);
let (maple_kill, wolf_kill) = {
let mut cid = game
.villager_character_ids()
.into_iter()
.filter(|c| game.village().character_by_id(*c).unwrap().alive());
(cid.next().unwrap(), cid.next().unwrap())
};
game.execute().title().wolf_pack_kill();
game.mark(wolf_kill);
game.r#continue().sleep();
game.next().title().maple_wolf();
game.mark(maple_kill);
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(
*game.character_by_player_id(maple).maple_wolf_mut().unwrap(),
1
);
let (maple_kill, wolf_kill) = {
let mut cid = game
.villager_character_ids()
.into_iter()
.filter(|c| game.village().character_by_id(*c).unwrap().alive());
(cid.next().unwrap(), cid.next().unwrap())
};
game.execute().title().wolf_pack_kill();
game.mark(wolf_kill);
game.r#continue().sleep();
game.next().title().maple_wolf();
game.mark(maple_kill);
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(
*game.character_by_player_id(maple).maple_wolf_mut().unwrap(),
2
);
let (maple_kill, wolf_kill) = {
let mut cid = game
.villager_character_ids()
.into_iter()
.filter(|c| game.village().character_by_id(*c).unwrap().alive());
(cid.next().unwrap(), cid.next().unwrap())
};
game.execute().title().wolf_pack_kill();
game.mark(wolf_kill);
game.r#continue().sleep();
game.next().title().maple_wolf();
game.mark(maple_kill);
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(
*game.character_by_player_id(maple).maple_wolf_mut().unwrap(),
3
);
let (maple_kill, wolf_kill) = {
let mut cid = game
.villager_character_ids()
.into_iter()
.filter(|c| game.village().character_by_id(*c).unwrap().alive());
(cid.next().unwrap(), cid.next().unwrap())
};
game.execute().title().wolf_pack_kill();
game.mark(wolf_kill);
game.r#continue().sleep();
game.next().title().maple_wolf();
game.mark(maple_kill);
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(
*game.character_by_player_id(maple).maple_wolf_mut().unwrap(),
4
);
assert_eq!(game.character_by_player_id(maple).died_to().cloned(), None);
}
#[test]
fn drunk_maple_doesnt_eat() {
init_log();
let players = gen_players(1..21);
let mut player_ids = players.iter().map(|p| p.player_id);
let maple = player_ids.next().unwrap();
let wolf = player_ids.next().unwrap();
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::MapleWolf, maple);
settings.add_and_assign(SetupRole::Werewolf, wolf);
settings.fill_remaining_slots_with_villagers(20);
let mut game = Game::new(&players, settings).unwrap();
game.village_mut()
.characters_mut()
.into_iter()
.find(|c| c.player_id() == maple)
.unwrap()
.apply_aura(Aura::Drunk(DrunkBag::all_drunk()));
game.r#continue().r#continue();
assert_eq!(game.next().title(), ActionPromptTitle::WolvesIntro);
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(
*game.character_by_player_id(maple).maple_wolf_mut().unwrap(),
0
);
let (maple_kill, wolf_kill) = {
let mut cid = game
.villager_character_ids()
.into_iter()
.filter(|c| game.village().character_by_id(*c).unwrap().alive());
(cid.next().unwrap(), cid.next().unwrap())
};
game.execute().title().wolf_pack_kill();
game.mark(wolf_kill);
game.r#continue().sleep();
game.next().title().maple_wolf();
game.mark(maple_kill);
game.r#continue().drunk();
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(
*game.character_by_player_id(maple).maple_wolf_mut().unwrap(),
0
);
assert_eq!(
game.village()
.character_by_id(maple_kill)
.unwrap()
.died_to(),
None
);
let (maple_kill, wolf_kill) = {
let mut cid = game
.villager_character_ids()
.into_iter()
.filter(|c| game.village().character_by_id(*c).unwrap().alive());
(cid.next().unwrap(), cid.next().unwrap())
};
game.execute().title().wolf_pack_kill();
game.mark(wolf_kill);
game.r#continue().sleep();
game.next().title().maple_wolf();
game.mark(maple_kill);
game.r#continue().drunk();
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(
*game.character_by_player_id(maple).maple_wolf_mut().unwrap(),
0
);
assert_eq!(
game.village()
.character_by_id(maple_kill)
.unwrap()
.died_to(),
None
);
let (maple_kill, wolf_kill) = {
let mut cid = game
.villager_character_ids()
.into_iter()
.filter(|c| game.village().character_by_id(*c).unwrap().alive());
(cid.next().unwrap(), cid.next().unwrap())
};
game.execute().title().wolf_pack_kill();
game.mark(wolf_kill);
game.r#continue().sleep();
game.next().title().maple_wolf();
game.mark(maple_kill);
game.r#continue().drunk();
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(
*game.character_by_player_id(maple).maple_wolf_mut().unwrap(),
0
);
assert_eq!(
game.character_by_player_id(maple).died_to().cloned(),
Some(DiedTo::MapleWolfStarved {
night: NonZeroU8::new(3).unwrap()
})
);
}

View File

@ -23,9 +23,11 @@ mod guardian;
mod hunter; mod hunter;
mod insomniac; mod insomniac;
mod lone_wolf; mod lone_wolf;
mod maple_wolf;
mod mason; mod mason;
mod militia; mod militia;
mod mortician; mod mortician;
mod protector;
mod pyremaster; mod pyremaster;
mod scapegoat; mod scapegoat;
mod shapeshifter; mod shapeshifter;

View File

@ -0,0 +1,105 @@
// Copyright (C) 2025 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
#[allow(unused)]
use pretty_assertions::{assert_eq, assert_ne, assert_str_eq};
use crate::{
game::{Game, GameSettings, SetupRole},
game_test::{
ActionPromptTitleExt, ActionResultExt, GameExt, SettingsExt, gen_players, init_log,
},
message::night::{ActionPrompt, ActionPromptTitle},
};
#[test]
fn cannot_protect_same_target() {
init_log();
let players = gen_players(1..21);
let mut player_ids = players.iter().map(|p| p.player_id);
let protector = player_ids.next().unwrap();
let wolf = player_ids.next().unwrap();
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::Protector, protector);
settings.add_and_assign(SetupRole::Werewolf, wolf);
settings.fill_remaining_slots_with_villagers(20);
let mut game = Game::new(&players, settings).unwrap();
game.r#continue().r#continue();
assert_eq!(game.next().title(), ActionPromptTitle::WolvesIntro);
game.r#continue().sleep();
game.next_expect_day();
game.execute().title().protector();
let prot = game.living_villager();
game.mark(prot.character_id());
game.r#continue().sleep();
game.next().title().wolf_pack_kill();
game.mark(prot.character_id());
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(
game.character_by_player_id(protector)
.protector_mut()
.unwrap()
.clone(),
Some(prot.character_id())
);
match game.execute() {
ActionPrompt::Protector { targets, .. } => {
assert!(
!targets
.into_iter()
.any(|c| c.character_id == prot.character_id())
);
}
prompt => panic!("expected protector prompt, got {:?}", prompt.title()),
}
}
#[test]
fn can_self_protect() {
init_log();
let players = gen_players(1..21);
let mut player_ids = players.iter().map(|p| p.player_id);
let protector = player_ids.next().unwrap();
let wolf = player_ids.next().unwrap();
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::Protector, protector);
settings.add_and_assign(SetupRole::Werewolf, wolf);
settings.fill_remaining_slots_with_villagers(20);
let mut game = Game::new(&players, settings).unwrap();
game.r#continue().r#continue();
assert_eq!(game.next().title(), ActionPromptTitle::WolvesIntro);
game.r#continue().sleep();
game.next_expect_day();
game.execute().title().protector();
game.mark(game.character_by_player_id(protector).character_id());
game.r#continue().sleep();
game.next().title().wolf_pack_kill();
game.mark(game.character_by_player_id(protector).character_id());
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(
game.character_by_player_id(protector).died_to().cloned(),
None
);
}

View File

@ -130,7 +130,9 @@ fn protect_stops_shapeshift() {
assert_eq!(game.next().title(), ActionPromptTitle::Shapeshifter,); assert_eq!(game.next().title(), ActionPromptTitle::Shapeshifter,);
game.response(ActionResponse::Shapeshift); game.response(ActionResponse::Shapeshift)
.shapeshift_failed();
game.r#continue().sleep();
game.next_expect_day(); game.next_expect_day();
@ -218,3 +220,60 @@ fn i_would_simply_refuse() {
game.next_expect_day(); game.next_expect_day();
} }
#[test]
fn shapeshift_fail_can_continue() {
let players = gen_players(1..21);
let mut player_ids = players.iter().map(|p| p.player_id);
let shapeshifter = player_ids.next().unwrap();
let direwolf = player_ids.next().unwrap();
let wolf = player_ids.next().unwrap();
let protector = player_ids.next().unwrap();
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::Shapeshifter, shapeshifter);
settings.add_and_assign(SetupRole::DireWolf, direwolf);
settings.add_and_assign(SetupRole::Werewolf, wolf);
settings.add_and_assign(SetupRole::Protector, protector);
settings.fill_remaining_slots_with_villagers(20);
let mut game = Game::new(&players, settings).unwrap();
game.r#continue().r#continue();
assert_eq!(game.next().title(), ActionPromptTitle::WolvesIntro);
game.r#continue().r#continue();
game.next().title().direwolf();
let dw_target = game.living_villager();
game.mark(dw_target.character_id());
game.r#continue().sleep();
game.next_expect_day();
game.execute().title().protector();
let ss_target = game.living_villager();
game.mark(ss_target.character_id());
game.r#continue().sleep();
game.next().title().wolf_pack_kill();
game.mark(ss_target.character_id());
game.r#continue().r#continue();
game.next().title().shapeshifter();
match game
.process(HostGameMessage::Night(HostNightMessage::ActionResponse(
ActionResponse::Shapeshift,
)))
.unwrap()
{
ServerToHostMessage::ActionResult(_, ActionResult::ShiftFailed) => {}
other => panic!("expected shift fail, got {other:?}"),
};
game.r#continue().r#continue();
game.next().title().direwolf();
game.mark(
game.living_villager_excl(dw_target.player_id())
.character_id(),
);
game.r#continue().sleep();
game.next_expect_day();
}

View File

@ -13,8 +13,8 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
#![allow(clippy::new_without_default)] #![allow(clippy::new_without_default)]
pub mod aura; pub mod aura;
pub mod bag;
pub mod character; pub mod character;
pub mod diedto; pub mod diedto;
pub mod error; pub mod error;

View File

@ -32,6 +32,7 @@ pub enum ActionType {
Cover, Cover,
#[checks("is_wolfy")] #[checks("is_wolfy")]
WolvesIntro, WolvesIntro,
TraitorIntro,
RoleChange, RoleChange,
Protect, Protect,
#[checks("is_wolfy")] #[checks("is_wolfy")]
@ -209,12 +210,15 @@ pub enum ActionPrompt {
living_players: Box<[CharacterIdentity]>, living_players: Box<[CharacterIdentity]>,
marked: Option<CharacterId>, marked: Option<CharacterId>,
}, },
#[checks(ActionType::TraitorIntro)]
TraitorIntro { character_id: CharacterIdentity },
} }
impl ActionPrompt { impl ActionPrompt {
pub(crate) const fn character_id(&self) -> Option<CharacterId> { pub(crate) const fn character_id(&self) -> Option<CharacterId> {
match self { match self {
ActionPrompt::Insomniac { character_id, .. } ActionPrompt::TraitorIntro { character_id }
| ActionPrompt::Insomniac { character_id, .. }
| ActionPrompt::LoneWolfKill { character_id, .. } | ActionPrompt::LoneWolfKill { character_id, .. }
| ActionPrompt::ElderReveal { character_id } | ActionPrompt::ElderReveal { character_id }
| ActionPrompt::RoleChange { character_id, .. } | ActionPrompt::RoleChange { character_id, .. }
@ -257,7 +261,8 @@ impl ActionPrompt {
| ActionPrompt::Mortician { character_id, .. } | ActionPrompt::Mortician { character_id, .. }
| ActionPrompt::Vindicator { character_id, .. } => character_id.character_id == target, | ActionPrompt::Vindicator { character_id, .. } => character_id.character_id == target,
ActionPrompt::Beholder { .. } ActionPrompt::TraitorIntro { .. }
| ActionPrompt::Beholder { .. }
| ActionPrompt::CoverOfDarkness | ActionPrompt::CoverOfDarkness
| ActionPrompt::WolvesIntro { .. } | ActionPrompt::WolvesIntro { .. }
| ActionPrompt::RoleChange { .. } | ActionPrompt::RoleChange { .. }
@ -281,13 +286,14 @@ impl ActionPrompt {
pub(crate) fn with_mark(&self, mark: CharacterId) -> Result<ActionPrompt> { pub(crate) fn with_mark(&self, mark: CharacterId) -> Result<ActionPrompt> {
let mut prompt = self.clone(); let mut prompt = self.clone();
match &mut prompt { match &mut prompt {
ActionPrompt::Insomniac { .. } ActionPrompt::TraitorIntro { .. }
| ActionPrompt::Insomniac { .. }
| ActionPrompt::MasonsWake { .. } | ActionPrompt::MasonsWake { .. }
| ActionPrompt::ElderReveal { .. } | ActionPrompt::ElderReveal { .. }
| ActionPrompt::WolvesIntro { .. } | ActionPrompt::WolvesIntro { .. }
| ActionPrompt::RoleChange { .. } | ActionPrompt::RoleChange { .. }
| ActionPrompt::Shapeshifter { .. } | ActionPrompt::Shapeshifter { .. }
| ActionPrompt::CoverOfDarkness => Err(GameError::InvalidMessageForGameState), | ActionPrompt::CoverOfDarkness => Err(GameError::RoleDoesntMark),
ActionPrompt::Guardian { ActionPrompt::Guardian {
previous, previous,
@ -489,6 +495,7 @@ pub enum ActionResponse {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ActionResult { pub enum ActionResult {
RoleBlocked, RoleBlocked,
Drunk,
Seer(Alignment), Seer(Alignment),
PowerSeer { powerful: Powerful }, PowerSeer { powerful: Powerful },
Adjudicator { killer: Killer }, Adjudicator { killer: Killer },
@ -498,10 +505,42 @@ pub enum ActionResult {
Insomniac(Visits), Insomniac(Visits),
Empath { scapegoat: bool }, Empath { scapegoat: bool },
BeholderSawNothing, BeholderSawNothing,
BeholderSawEverything,
GoBackToSleep, GoBackToSleep,
ShiftFailed,
Continue, Continue,
} }
impl ActionResult {
pub fn insane(&self) -> Option<Self> {
Some(match self {
ActionResult::Seer(Alignment::Village) => ActionResult::Seer(Alignment::Wolves),
ActionResult::Seer(Alignment::Traitor) | ActionResult::Seer(Alignment::Wolves) => {
ActionResult::Seer(Alignment::Village)
}
ActionResult::PowerSeer { powerful } => ActionResult::PowerSeer {
powerful: !*powerful,
},
ActionResult::Adjudicator { killer } => ActionResult::Adjudicator { killer: !*killer },
ActionResult::Arcanist(alignment_eq) => ActionResult::Arcanist(!*alignment_eq),
ActionResult::Empath { scapegoat } => ActionResult::Empath {
scapegoat: !*scapegoat,
},
ActionResult::BeholderSawNothing => ActionResult::BeholderSawEverything,
ActionResult::BeholderSawEverything => ActionResult::BeholderSawNothing,
ActionResult::ShiftFailed
| ActionResult::RoleBlocked
| ActionResult::Drunk
| ActionResult::GraveDigger(_)
| ActionResult::Mortician(_)
| ActionResult::Insomniac(_)
| ActionResult::GoBackToSleep
| ActionResult::Continue => return None,
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Visits(Box<[CharacterIdentity]>); pub struct Visits(Box<[CharacterIdentity]>);

View File

@ -18,10 +18,12 @@ use serde::{Deserialize, Serialize};
use werewolves_macros::{ChecksAs, Titles}; use werewolves_macros::{ChecksAs, Titles};
use crate::{ use crate::{
character::CharacterId, aura::Aura,
character::{Character, CharacterId},
diedto::DiedTo, diedto::DiedTo,
game::{GameTime, Village}, game::{GameTime, Village},
message::CharacterIdentity, message::{CharacterIdentity, Identification, PublicIdentity, night::ActionType},
player::PlayerId,
}; };
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize, Default)] #[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize, Default)]
@ -318,7 +320,7 @@ impl Role {
Role::Werewolf => KillingWolfOrder::Werewolf, Role::Werewolf => KillingWolfOrder::Werewolf,
Role::AlphaWolf { .. } => KillingWolfOrder::AlphaWolf, Role::AlphaWolf { .. } => KillingWolfOrder::AlphaWolf,
Role::Bloodletter { .. } => KillingWolfOrder::Bloodletter, Role::Bloodletter => KillingWolfOrder::Bloodletter,
Role::DireWolf { .. } => KillingWolfOrder::DireWolf, Role::DireWolf { .. } => KillingWolfOrder::DireWolf,
Role::Shapeshifter { .. } => KillingWolfOrder::Shapeshifter, Role::Shapeshifter { .. } => KillingWolfOrder::Shapeshifter,
Role::LoneWolf => KillingWolfOrder::LoneWolf, Role::LoneWolf => KillingWolfOrder::LoneWolf,
@ -469,7 +471,7 @@ impl Display for Alignment {
match self { match self {
Alignment::Village => f.write_str("Village"), Alignment::Village => f.write_str("Village"),
Alignment::Wolves => f.write_str("Wolves"), Alignment::Wolves => f.write_str("Wolves"),
Alignment::Traitor => f.write_str("Damned"), Alignment::Traitor => f.write_str("Traitor"),
} }
} }
} }

View File

@ -12,6 +12,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
use colored::Colorize;
use tokio::sync::{broadcast::Sender, mpsc::Receiver}; use tokio::sync::{broadcast::Sender, mpsc::Receiver};
use werewolves_proto::{ use werewolves_proto::{
error::GameError, error::GameError,
@ -45,6 +46,10 @@ impl HostComms {
} }
pub fn send(&mut self, message: ServerToHostMessage) -> Result<(), GameError> { pub fn send(&mut self, message: ServerToHostMessage) -> Result<(), GameError> {
log::debug!(
"sending message to host: {}",
format!("{message:?}").dimmed()
);
self.send self.send
.send(message) .send(message)
.map_err(|err| GameError::GenericError(err.to_string()))?; .map_err(|err| GameError::GenericError(err.to_string()))?;

View File

@ -146,7 +146,6 @@ impl Host {
msg = self.server_recv.recv() => { msg = self.server_recv.recv() => {
match msg { match msg {
Ok(msg) => { Ok(msg) => {
log::debug!("sending message to host: {}", format!("{msg:?}").dimmed());
if let Err(err) = self.send_message(&msg).await { if let Err(err) = self.send_message(&msg).await {
log::error!("{} {err}", "[host::outgoing]".bold()) log::error!("{} {err}", "[host::outgoing]".bold())
} }

46
werewolves/img/drunk.svg Normal file
View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="70.481842mm"
height="70.97554mm"
viewBox="0 0 70.481842 70.97554"
version="1.1"
id="svg1"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><g
id="layer4"
transform="translate(-483.44732,-408.61381)"><g
id="g182"><path
id="path182"
style="fill:#c29c00;fill-opacity:1;stroke:#755e00;stroke-width:1.4;stroke-dasharray:none;stroke-opacity:1"
d="m 532.22935,429.6004 a 21,21 0 0 0 -21.00017,21.00017 21,21 0 0 0 21.00017,20.9998 21,21 0 0 0 20.99981,-20.9998 21,21 0 0 0 -20.99981,-21.00017 z m 0,7.0003 a 14,14 0 0 1 13.99987,13.99987 14,14 0 0 1 -13.99987,13.99987 14,14 0 0 1 -14.00023,-13.99987 14,14 0 0 1 14.00023,-13.99987 z" /><path
id="path178"
style="fill:#daaf00;fill-opacity:1;stroke:#755e00;stroke-width:2.21395;stroke-dasharray:none;stroke-opacity:1"
d="m 492.41992,423.0918 c -5.82792,15.71457 -3.5165,47.74793 4.98242,53.65625 1.6238,1.12884 6.50214,1.73437 15.82618,1.73437 9.32404,0 14.20041,-0.60553 15.82421,-1.73437 8.49892,-5.90832 10.81035,-37.94168 4.98243,-53.65625 h -20.80664 z" /><g
id="g181"
transform="translate(64.081238,-6.2578735)"
style="fill:#755e00;fill-opacity:0.496844;stroke:#755e00;stroke-opacity:1"><ellipse
style="fill:#755e00;fill-opacity:0.496844;stroke:#755e00;stroke-width:2;stroke-dasharray:none;stroke-opacity:1"
id="path181"
cx="449.1463"
cy="457.04498"
rx="4"
ry="20" /><ellipse
style="fill:#755e00;fill-opacity:0.496844;stroke:#755e00;stroke-width:2;stroke-dasharray:none;stroke-opacity:1"
id="path181-5"
cx="463.11505"
cy="457.04498"
rx="4"
ry="20" /><ellipse
style="fill:#755e00;fill-opacity:0.496844;stroke:#755e00;stroke-width:2;stroke-dasharray:none;stroke-opacity:1"
id="path181-7"
cx="435.17755"
cy="457.04498"
rx="4"
ry="20" /></g><path
d="m 520.38364,409.61388 a 10,10 0 0 0 -8.32301,4.45657 10,10 0 0 0 -5.31647,-1.53065 10,10 0 0 0 -9.14414,5.95209 10,10 0 0 0 -3.15226,-0.50953 10,10 0 0 0 -10.00043,9.9999 10,10 0 0 0 10.00043,9.99991 10,10 0 0 0 1.16427,-0.0682 10,10 0 0 0 8.99893,5.63893 10,10 0 0 0 6.87503,-2.73833 10,10 0 0 0 7.97729,3.96978 10,10 0 0 0 9.41235,-6.62234 10,10 0 0 0 2.23759,0.25322 10,10 0 0 0 9.99991,-9.99991 10,10 0 0 0 -9.99991,-9.9999 10,10 0 0 0 -0.79789,0.032 10,10 0 0 0 -9.93169,-8.83357 z"
style="fill:#ffffff;fill-opacity:0.9;stroke:#b2b2b2;stroke-width:2;stroke-opacity:0.7"
id="path180" /></g></g></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

29
werewolves/img/insane.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@ -17,6 +17,10 @@ $offensive_color: color.adjust($village_color, $hue: 30deg);
$offensive_border: color.change($offensive_color, $alpha: 1.0); $offensive_border: color.change($offensive_color, $alpha: 1.0);
$starts_as_villager_color: color.adjust($offensive_color, $hue: 30deg); $starts_as_villager_color: color.adjust($offensive_color, $hue: 30deg);
$starts_as_villager_border: color.change($starts_as_villager_color, $alpha: 1.0); $starts_as_villager_border: color.change($starts_as_villager_color, $alpha: 1.0);
$traitor_color: color.adjust($village_color, $hue: 45deg);
$traitor_border: color.change($traitor_color, $alpha: 1.0);
$drunk_color: color.adjust($village_color, $hue: 150deg);
$drunk_border: color.change($drunk_color, $alpha: 1.0);
$wolves_border_faint: color.change($wolves_border, $alpha: 0.3); $wolves_border_faint: color.change($wolves_border, $alpha: 0.3);
$village_border_faint: color.change($village_border, $alpha: 0.3); $village_border_faint: color.change($village_border, $alpha: 0.3);
@ -24,6 +28,8 @@ $offensive_border_faint: color.change($offensive_border, $alpha: 0.3);
$defensive_border_faint: color.change($defensive_border, $alpha: 0.3); $defensive_border_faint: color.change($defensive_border, $alpha: 0.3);
$intel_border_faint: color.change($intel_border, $alpha: 0.3); $intel_border_faint: color.change($intel_border, $alpha: 0.3);
$starts_as_villager_border_faint: color.change($starts_as_villager_border, $alpha: 0.3); $starts_as_villager_border_faint: color.change($starts_as_villager_border, $alpha: 0.3);
$traitor_border_faint: color.change($traitor_border, $alpha: 0.3);
$drunk_border_faint: color.change($drunk_border, $alpha: 0.3);
$wolves_color_faint: color.change($wolves_color, $alpha: 0.1); $wolves_color_faint: color.change($wolves_color, $alpha: 0.1);
$village_color_faint: color.change($village_color, $alpha: 0.1); $village_color_faint: color.change($village_color, $alpha: 0.1);
@ -31,7 +37,8 @@ $offensive_color_faint: color.change($offensive_color, $alpha: 0.1);
$defensive_color_faint: color.change($defensive_color, $alpha: 0.1); $defensive_color_faint: color.change($defensive_color, $alpha: 0.1);
$intel_color_faint: color.change($intel_color, $alpha: 0.1); $intel_color_faint: color.change($intel_color, $alpha: 0.1);
$starts_as_villager_color_faint: color.change($starts_as_villager_color, $alpha: 0.1); $starts_as_villager_color_faint: color.change($starts_as_villager_color, $alpha: 0.1);
$traitor_color_faint: color.change($traitor_color, $alpha: 0.1);
$drunk_color_faint: color.change($drunk_color, $alpha: 0.1);
@mixin flexbox() { @mixin flexbox() {
display: -webkit-box; display: -webkit-box;
@ -280,7 +287,6 @@ nav.host-nav {
align-self: center; align-self: center;
margin-bottom: 30px; margin-bottom: 30px;
font-size: 2rem; font-size: 2rem;
// background-color: hsl(283, 100%, 80%);
border: 1px solid rgba(0, 255, 0, 0.7); border: 1px solid rgba(0, 255, 0, 0.7);
background-color: black; background-color: black;
color: rgba(0, 255, 0, 0.7); color: rgba(0, 255, 0, 0.7);
@ -298,6 +304,7 @@ nav.host-nav {
border: 1px solid rgba(255, 0, 0, 1); border: 1px solid rgba(255, 0, 0, 1);
color: rgba(255, 0, 0, 1); color: rgba(255, 0, 0, 1);
filter: none; filter: none;
background-color: rgba(255, 0, 0, 0.1);
&:hover { &:hover {
background-color: rgba(255, 0, 0, 0.3); background-color: rgba(255, 0, 0, 0.3);
@ -1110,15 +1117,20 @@ input {
cursor: pointer; cursor: pointer;
} }
display: flex;
flex-direction: column;
flex-wrap: nowrap;
align-items: center;
&>.submenu { &>.submenu {
min-width: 30vw; width: 30vw;
// position: absolute;
.assign-list { .assign-list {
// min-width: 5cm;
gap: 10px; gap: 10px;
& .submenu button { & .submenu button {
width: 5cm; width: 10vw;
} }
} }
@ -1126,6 +1138,7 @@ input {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center;
gap: 5px; gap: 5px;
} }
} }
@ -1270,6 +1283,44 @@ input {
} }
} }
.traitor {
background-color: $traitor_color;
border: 1px solid $traitor_border;
&:hover {
color: white;
background-color: $traitor_border;
}
&.faint {
border: 1px solid $traitor_border_faint;
background-color: $traitor_color_faint;
&:hover {
background-color: $traitor_border_faint;
}
}
}
.drunk {
background-color: $drunk_color;
border: 1px solid $drunk_border;
&:hover {
color: white;
background-color: $drunk_border;
}
&.faint {
border: 1px solid $drunk_border_faint;
background-color: $drunk_color_faint;
&:hover {
background-color: $drunk_border_faint;
}
}
}
.assignments { .assignments {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -1790,6 +1841,18 @@ input {
flex-shrink: 1; flex-shrink: 1;
} }
.info-icon-grow {
flex-grow: 1;
width: 100%;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
img {
flex-grow: 1;
}
}
.info-player-list { .info-player-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -1943,6 +2006,39 @@ input {
flex-direction: column; flex-direction: column;
flex-wrap: nowrap; flex-wrap: nowrap;
justify-content: center; justify-content: center;
align-items: center;
height: var(--information-height); height: var(--information-height);
} }
.setup-aura {
&.active {
$active_color: color.change($connected_color, $alpha: 0.7);
border: $active_color 1px solid;
color: $active_color;
background-color: color.change($active_color, $alpha: 0.2);
&:hover {
background-color: $active_color;
color: white;
}
}
}
.aura-title {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 3px;
// text-align: center;
align-items: center;
.title {
flex-grow: 1;
}
img,
.icon {
flex-shrink: 1;
}
}

17
werewolves/src/class.rs Normal file
View File

@ -0,0 +1,17 @@
// Copyright (C) 2025 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
pub trait Class {
fn class(&self) -> Option<&'static str>;
}

View File

@ -822,7 +822,8 @@ impl GameExt for Game {
fn mark_and_check(&mut self, mark: CharacterId) { fn mark_and_check(&mut self, mark: CharacterId) {
let prompt = self.mark(mark); let prompt = self.mark(mark);
match prompt { match prompt {
ActionPrompt::Insomniac { .. } ActionPrompt::TraitorIntro { .. }
| ActionPrompt::Insomniac { .. }
| ActionPrompt::MasonsWake { .. } | ActionPrompt::MasonsWake { .. }
| ActionPrompt::ElderReveal { .. } | ActionPrompt::ElderReveal { .. }
| ActionPrompt::CoverOfDarkness | ActionPrompt::CoverOfDarkness

View File

@ -31,7 +31,7 @@ use crate::{
Button, CoverOfDarkness, Identity, Button, CoverOfDarkness, Identity,
action::{BinaryChoice, TargetPicker, WolvesIntro}, action::{BinaryChoice, TargetPicker, WolvesIntro},
}, },
pages::MasonsWake, pages::{MasonsWake, TraitorIntroPage},
}; };
#[derive(Debug, Clone, PartialEq, Properties)] #[derive(Debug, Clone, PartialEq, Properties)]
@ -113,6 +113,15 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
} }
}); });
let (character_id, targets, marked, role_info) = match &props.prompt { let (character_id, targets, marked, role_info) = match &props.prompt {
ActionPrompt::TraitorIntro { character_id } => {
return html! {
<div class="prompt">
{identity_html(props, Some(character_id))}
<TraitorIntroPage />
{cont}
</div>
};
}
ActionPrompt::CoverOfDarkness => { ActionPrompt::CoverOfDarkness => {
return html! { return html! {
<CoverOfDarkness next={continue_callback}/> <CoverOfDarkness next={continue_callback}/>
@ -403,24 +412,26 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
))); )));
} }
}); });
let choice = props.big_screen.not().then_some(html! {
<BinaryChoice on_chosen={on_select} />
});
return html! { return html! {
<div class="role-page"> <div class="role-page">
{identity_html(props, Some(character_id))} {identity_html(props, Some(character_id))}
<h1 class="wolves">{"SHAPESHIFTER"}</h1> <h1 class="wolves">{"SHAPESHIFTER"}</h1>
<div class="information wolves faint"> <div class="information wolves faint">
<h3> <h2>
{"WOULD YOU LIKE TO USE YOUR "} {"WOULD YOU LIKE TO USE YOUR "}
<span class="yellow">{"ONCE PER GAME"}</span> <span class="yellow">{"ONCE PER GAME"}</span>
{" SHAPESHIFT ABILITY?"} {" SHAPESHIFT ABILITY?"}
</h3> </h2>
<h4> <h2>
<span class="yellow">{"YOU WILL DIE"}</span>{", AND THE "} <span class="yellow">{"YOU WILL DIE"}</span>{", AND THE "}
{"TARGET OF THE WOLFPACK KILL"} {"TARGET OF THE WOLFPACK KILL"}
{" SHALL INSTEAD BECOME A WOLF"} {" SHALL INSTEAD BECOME A WOLF"}
</h4> </h2>
</div> </div>
<BinaryChoice on_chosen={on_select}> {choice}
</BinaryChoice>
</div> </div>
}; };
} }

View File

@ -25,8 +25,9 @@ use yew::prelude::*;
use crate::{ use crate::{
components::{Button, CoverOfDarkness, Icon, IconSource, Identity}, components::{Button, CoverOfDarkness, Icon, IconSource, Identity},
pages::{ pages::{
AdjudicatorResult, ArcanistResult, BeholderSawNothing, EmpathResult, GravediggerResultPage, AdjudicatorResult, ArcanistResult, BeholderSawEverything, BeholderSawNothing, DrunkPage,
InsomniacResult, MorticianResultPage, PowerSeerResult, RoleblockPage, SeerResult, EmpathResult, GravediggerResultPage, InsomniacResult, MorticianResultPage, PowerSeerResult,
RoleblockPage, SeerResult, ShiftFailed,
}, },
}; };
@ -58,6 +59,15 @@ pub fn ActionResultView(props: &ActionResultProps) -> Html {
.not() .not()
.then(|| html! {<Button on_click={on_continue}>{"continue"}</Button>}); .then(|| html! {<Button on_click={on_continue}>{"continue"}</Button>});
let body = match &props.result { let body = match &props.result {
ActionResult::ShiftFailed => html! {
<ShiftFailed />
},
ActionResult::Drunk => html! {
<DrunkPage />
},
ActionResult::BeholderSawEverything => html! {
<BeholderSawEverything />
},
ActionResult::BeholderSawNothing => html! { ActionResult::BeholderSawNothing => html! {
<BeholderSawNothing /> <BeholderSawNothing />
}, },

View File

@ -12,28 +12,39 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
use werewolves_proto::aura; use werewolves_proto::aura::{self, Aura, AuraTitle};
use yew::prelude::*; use yew::prelude::*;
use crate::components::{Icon, IconType, PartialAssociatedIcon}; use crate::{
class::Class,
components::{Icon, IconType, PartialAssociatedIcon},
};
#[derive(Debug, Clone, PartialEq, Properties)] impl Class for AuraTitle {
pub struct AuraProps { fn class(&self) -> Option<&'static str> {
pub aura: aura::Aura, Some(match self {
aura::AuraTitle::Traitor => "traitor",
aura::AuraTitle::Drunk => "drunk",
aura::AuraTitle::Insane => "insane",
aura::AuraTitle::Bloodlet => "wolves",
})
}
} }
fn aura_class(aura: &aura::Aura) -> Option<&'static str> { impl Class for Aura {
Some(match aura { fn class(&self) -> Option<&'static str> {
aura::Aura::Traitor => "traitor", self.title().class()
aura::Aura::Drunk => "drunk", }
aura::Aura::Insane => "insane", }
aura::Aura::Bloodlet { .. } => "wolves",
}) #[derive(Debug, Clone, PartialEq, Properties)]
pub struct AuraSpanProps {
pub aura: aura::AuraTitle,
} }
#[function_component] #[function_component]
pub fn Aura(AuraProps { aura }: &AuraProps) -> Html { pub fn AuraSpan(AuraSpanProps { aura }: &AuraSpanProps) -> Html {
let class = aura_class(aura); let class = aura.class();
let icon = aura.icon().map(|icon| { let icon = aura.icon().map(|icon| {
html! { html! {
<div> <div>

View File

@ -151,16 +151,13 @@ pub fn SetupCategory(
</div> </div>
<div class="attributes"> <div class="attributes">
<div class="alignment"> <div class="alignment">
// <img class="icon" src={alignment} alt={"alignment"}/>
<Icon source={alignment} icon_type={IconType::Small}/> <Icon source={alignment} icon_type={IconType::Small}/>
</div> </div>
<div class={classes!("killer", killer_inactive)}> <div class={classes!("killer", killer_inactive)}>
<Icon source={IconSource::Killer} icon_type={IconType::Small}/> <Icon source={IconSource::Killer} icon_type={IconType::Small}/>
// <img class="icon" src="/img/killer.svg" alt="killer icon"/>
</div> </div>
<div class={classes!("poweful", powerful_inactive)}> <div class={classes!("poweful", powerful_inactive)}>
<Icon source={IconSource::Powerful} icon_type={IconType::Small}/> <Icon source={IconSource::Powerful} icon_type={IconType::Small}/>
// <img class="icon" src="/img/powerful.svg" alt="powerful icon"/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -13,7 +13,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
use werewolves_proto::{ use werewolves_proto::{
aura::Aura, aura::{Aura, AuraTitle},
diedto::DiedToTitle, diedto::DiedToTitle,
role::{Alignment, Killer, Powerful, RoleTitle}, role::{Alignment, Killer, Powerful, RoleTitle},
}; };
@ -79,6 +79,8 @@ decl_icon!(
RedX: "/img/red-x.svg", RedX: "/img/red-x.svg",
Traitor: "/img/traitor.svg", Traitor: "/img/traitor.svg",
Bloodlet: "/img/bloodlet.svg", Bloodlet: "/img/bloodlet.svg",
Drunk: "/img/drunk.svg",
Insane: "/img/insane.svg",
); );
impl IconSource { impl IconSource {
@ -95,7 +97,6 @@ impl IconSource {
pub enum IconType { pub enum IconType {
List, List,
Small, Small,
RoleAdd,
Fit, Fit,
Icon15Pct, Icon15Pct,
Informational, Informational,
@ -110,7 +111,6 @@ impl IconType {
IconType::Fit => "icon-fit", IconType::Fit => "icon-fit",
IconType::List => "icon-in-list", IconType::List => "icon-in-list",
IconType::Small => "icon", IconType::Small => "icon",
IconType::RoleAdd => "icon-role-add",
IconType::Informational => "icon-info", IconType::Informational => "icon-info",
IconType::RoleCheck => "check-icon", IconType::RoleCheck => "check-icon",
} }
@ -227,13 +227,13 @@ impl PartialAssociatedIcon for DiedToTitle {
} }
} }
impl PartialAssociatedIcon for Aura { impl PartialAssociatedIcon for AuraTitle {
fn icon(&self) -> Option<IconSource> { fn icon(&self) -> Option<IconSource> {
match self { match self {
Aura::Traitor => Some(IconSource::Traitor), AuraTitle::Traitor => Some(IconSource::Traitor),
Aura::Drunk => todo!(), AuraTitle::Drunk => Some(IconSource::Drunk),
Aura::Insane => todo!(), AuraTitle::Insane => Some(IconSource::Insane),
Aura::Bloodlet { .. } => Some(IconSource::Bloodlet), AuraTitle::Bloodlet => Some(IconSource::Bloodlet),
} }
} }
} }

View File

@ -17,6 +17,7 @@ use std::rc::Rc;
use convert_case::{Case, Casing}; use convert_case::{Case, Casing};
use werewolves_proto::{ use werewolves_proto::{
aura::{Aura, AuraTitle},
error::GameError, error::GameError,
game::{GameSettings, OrRandom, SetupRole, SetupSlot, SlotId}, game::{GameSettings, OrRandom, SetupRole, SetupSlot, SlotId},
message::{Identification, PlayerState, PublicIdentity}, message::{Identification, PlayerState, PublicIdentity},
@ -24,9 +25,11 @@ use werewolves_proto::{
}; };
use yew::prelude::*; use yew::prelude::*;
use crate::components::{ use crate::{
Button, ClickableField, Icon, IconSource, IconType, Identity, PartialAssociatedIcon, class::Class,
client::Signin, components::{
Button, ClickableField, Icon, IconType, Identity, PartialAssociatedIcon, client::Signin,
},
}; };
#[derive(Debug, PartialEq, Properties)] #[derive(Debug, PartialEq, Properties)]
@ -346,6 +349,7 @@ pub fn SettingsSlot(
}: &SettingsSlotProps, }: &SettingsSlotProps,
) -> Html { ) -> Html {
let open = use_state(|| false); let open = use_state(|| false);
let aura_open = use_state(|| false);
let open_update = open.setter(); let open_update = open.setter();
let update = update.clone(); let update = update.clone();
let update = Callback::from(move |act| { let update = Callback::from(move |act| {
@ -362,8 +366,14 @@ pub fn SettingsSlot(
open_update.set(false); open_update.set(false);
}); });
let assign_to = assign_to_submenu(players_for_assign, slot, &update, &open.setter()); let assign_to = assign_to_submenu(players_for_assign, slot, &update, &open.setter());
let options = let options = setup_options_for_slot(
setup_options_for_slot(slot, &update, roles_in_setup, apprentice_open, open.clone()); slot,
&update,
roles_in_setup,
apprentice_open,
open.clone(),
aura_open.clone(),
);
let assign_text = slot let assign_text = slot
.assign_to .assign_to
.as_ref() .as_ref()
@ -379,7 +389,7 @@ pub fn SettingsSlot(
.unwrap_or_else(|| html! {{"assign"}}); .unwrap_or_else(|| html! {{"assign"}});
html! { html! {
<> <>
<Button on_click={on_kick}> <Button on_click={on_kick} classes={classes!("red")}>
{"remove"} {"remove"}
</Button> </Button>
<ClickableField <ClickableField
@ -447,7 +457,71 @@ fn setup_options_for_slot(
roles_in_setup: &[RoleTitle], roles_in_setup: &[RoleTitle],
open_apprentice_assign: UseStateHandle<bool>, open_apprentice_assign: UseStateHandle<bool>,
slot_field_open: UseStateHandle<bool>, slot_field_open: UseStateHandle<bool>,
open_aura_assign: UseStateHandle<bool>,
) -> Html { ) -> Html {
let aura_assign = {
let options = AuraTitle::ALL
.into_iter()
.filter(AuraTitle::assignable)
// .map(AuraTitle::into_aura)
.filter(|aura| slot.role.title().can_assign_aura(*aura))
.map(|aura| {
let aura_active = slot.auras.contains(&aura);
let active_class = aura_active.then_some("active");
let toggle = {
let slot = slot.clone();
let update = update.clone();
Callback::from(move |_| {
let mut slot = slot.clone();
if aura_active {
slot.auras.retain(|a| *a != aura);
} else {
slot.auras.push(aura);
}
update.emit(SettingSlotAction::Update(slot))
})
};
let icon = aura
.icon()
.map(|icon| {
html! {
// <div
<Icon source={icon} icon_type={IconType::Small}/>
}
})
.unwrap_or_else(|| {
html! {
<div class="icon inactive"/>
}
});
let aura_class = aura.class();
html! {
<Button
on_click={toggle}
classes={classes!(active_class, "setup-aura", aura_class, "faint")}
>
<span class="aura-title">
{icon}
<span class="title">{aura.to_string()}</span>
<div class="icon inactive"/>
</span>
</Button>
}
})
.collect::<Box<[_]>>();
options.is_empty().not().then(|| {
let options = options.into_iter().collect::<Html>();
html! {
<ClickableField
state={open_aura_assign}
options={options}
>
{"auras"}
</ClickableField>
}
})
};
let setup_options_for_role = match &slot.role { let setup_options_for_role = match &slot.role {
SetupRole::MasonLeader { recruits_available } => { SetupRole::MasonLeader { recruits_available } => {
let next = { let next = {
@ -660,5 +734,10 @@ fn setup_options_for_slot(
_ => None, _ => None,
}; };
setup_options_for_role.unwrap_or_default() html! {
<>
{aura_assign}
{setup_options_for_role}
</>
}
} }

View File

@ -17,23 +17,20 @@ use std::{collections::HashMap, rc::Rc};
use convert_case::{Case, Casing}; use convert_case::{Case, Casing};
use werewolves_proto::{ use werewolves_proto::{
character::{Character, CharacterId}, aura::AuraTitle, character::{Character, CharacterId}, game::{
game::{
GameTime, SetupRole, GameTime, SetupRole,
night::changes::NightChange, night::changes::NightChange,
story::{ story::{
DayDetail, GameActions, GameStory, NightChoice, StoryActionPrompt, StoryActionResult, DayDetail, GameActions, GameStory, NightChoice, StoryActionPrompt, StoryActionResult,
}, },
}, }, role::Alignment
role::Alignment,
}; };
use yew::prelude::*; use yew::prelude::*;
use crate::components::{ use crate::components::{
CharacterCard, Icon, IconSource, IconType, PartialAssociatedIcon, AuraSpan, CharacterCard, Icon, IconSource, IconType, PartialAssociatedIcon, attributes::{
attributes::{
AlignmentComparisonSpan, AlignmentSpan, CategorySpan, DiedToSpan, KillerSpan, PowerfulSpan, AlignmentComparisonSpan, AlignmentSpan, CategorySpan, DiedToSpan, KillerSpan, PowerfulSpan,
}, }
}; };
#[derive(Debug, Clone, PartialEq, Properties)] #[derive(Debug, Clone, PartialEq, Properties)]
@ -177,11 +174,12 @@ struct StoryNightChangeProps {
#[function_component] #[function_component]
fn StoryNightChange(StoryNightChangeProps { change, characters }: &StoryNightChangeProps) -> Html { fn StoryNightChange(StoryNightChangeProps { change, characters }: &StoryNightChangeProps) -> Html {
match change { match change {
NightChange::LostAura { character, aura } => characters.get(character).map(|character| html!{ NightChange::LostAura { character, aura } => characters.get(character).map(|character| html!{
<> <>
<CharacterCard faint=true char={character.clone()}/> <CharacterCard faint=true char={character.clone()}/>
{"lost the"} {"lost the"}
<crate::components::Aura aura={*aura}/> <AuraSpan aura={aura.title()}/>
{"aura"} {"aura"}
</> </>
}).unwrap_or_default(), }).unwrap_or_default(),
@ -190,7 +188,7 @@ fn StoryNightChange(StoryNightChangeProps { change, characters }: &StoryNightCha
<> <>
<CharacterCard faint=true char={target.clone()}/> <CharacterCard faint=true char={target.clone()}/>
{"gained the"} {"gained the"}
<crate::components::Aura aura={*aura}/> <AuraSpan aura={aura.title()}/>
{"aura from"} {"aura from"}
<CharacterCard faint=true char={source.clone()}/> <CharacterCard faint=true char={source.clone()}/>
</> </>
@ -293,6 +291,19 @@ struct StoryNightResultProps {
#[function_component] #[function_component]
fn StoryNightResult(StoryNightResultProps { result, characters }: &StoryNightResultProps) -> Html { fn StoryNightResult(StoryNightResultProps { result, characters }: &StoryNightResultProps) -> Html {
match result { match result {
StoryActionResult::ShiftFailed => html!{
<span>{"but it failed"}</span>
},
StoryActionResult::Drunk => html! {
<span>
{"but got "}
<AuraSpan aura={AuraTitle::Drunk}/>
{" instead"}
</span>
},
StoryActionResult::BeholderSawEverything => html!{
<span>{"and saw everything 👁️"}</span>
},
StoryActionResult::BeholderSawNothing => html!{ StoryActionResult::BeholderSawNothing => html!{
<span>{"but saw nothing"}</span> <span>{"but saw nothing"}</span>
}, },
@ -544,6 +555,9 @@ fn StoryNightChoice(StoryNightChoiceProps { choice, characters }: &StoryNightCho
}) })
} }
StoryActionPrompt::Shapeshifter { character_id } => { StoryActionPrompt::Shapeshifter { character_id } => {
if choice.result.is_none() {
return html!{};
}
characters.get(character_id).map(|shifter| { characters.get(character_id).map(|shifter| {
html! { html! {
<> <>

View File

@ -13,6 +13,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
mod assets; mod assets;
mod class;
mod clients; mod clients;
mod storage; mod storage;
mod components { mod components {

View File

@ -0,0 +1,21 @@
use werewolves_proto::aura::AuraTitle;
use yew::prelude::*;
use crate::components::{Icon, IconSource, IconType, PartialAssociatedIcon};
#[function_component]
pub fn DrunkPage() -> Html {
let icon = AuraTitle::Drunk.icon().unwrap_or(IconSource::Roleblock);
html! {
<div class="role-page">
<h1 class="drunk">{"DRUNK"}</h1>
<div class="information drunk faint">
<h2>{"YOU GOT DRUNK INSTEAD"}</h2>
<h4 class="icons">
<Icon source={icon} icon_type={IconType::Informational}/>
</h4>
<h3 class="yellow">{"YOUR NIGHT ACTION DID NOT TAKE PLACE"}</h3>
</div>
</div>
}
}

View File

@ -31,19 +31,21 @@ pub fn RoleChangePage(RoleChangePageProps { role }: &RoleChangePageProps) -> Htm
let class = Into::<SetupRole>::into(*role).category().class(); let class = Into::<SetupRole>::into(*role).category().class();
let icon = role.icon().map(|icon| { let icon = role.icon().map(|icon| {
html! { html! {
<h4 class="icons"> <div class="info-icon-grow">
<Icon source={icon} icon_type={IconType::Informational}/> <Icon source={icon}/>
</h4> </div>
} }
}); });
html! { html! {
<div class="role-page"> <div class="role-page">
<h1 class="intel">{"ROLE CHANGE"}</h1> <h1 class={classes!(class)}>{"ROLE CHANGE"}</h1>
<div class={classes!("information", class, "faint")}> <div class={classes!("information", class, "faint")}>
<h2>{"YOUR ROLE HAS CHANGED"}</h2> <h2>{"YOUR ROLE HAS CHANGED"}</h2>
{icon} {icon}
<h3 class="yellow">{"YOUR NEW ROLE IS"}</h3> <h3>
<h3>{role.to_string().to_case(Case::Upper)}</h3> <span>{"YOUR NEW ROLE IS "}</span>
<span class="yellow">{role.to_string().to_case(Case::Upper)}</span>
</h3>
</div> </div>
</div> </div>
} }

View File

@ -43,3 +43,24 @@ pub fn BeholderSawNothing() -> Html {
</div> </div>
} }
} }
#[function_component]
pub fn BeholderSawEverything() -> Html {
html! {
<div class="role-page">
<h1 class="intel">{"BEHOLDER"}</h1>
<div class="information intel faint">
<h1>{"YOUR TARGET HAS DIED"}</h1>
<div class="info-icon-grow">
<Icon source={IconSource::Beholder}/>
</div>
<h1>
{"BUT SAW "}
<em class="red">
<strong>{"EVERYTHING"}</strong>
</em>
</h1>
</div>
</div>
}
}

View File

@ -25,10 +25,10 @@ pub struct MapleWolfPage1Props {
pub fn MapleWolfPage1(MapleWolfPage1Props { starving }: &MapleWolfPage1Props) -> Html { pub fn MapleWolfPage1(MapleWolfPage1Props { starving }: &MapleWolfPage1Props) -> Html {
let starving = starving let starving = starving
.then_some(html! { .then_some(html! {
<> <div>
<h3 class="red">{"YOU ARE STARVING"}</h3> <h3 class="red">{"YOU ARE STARVING"}</h3>
<h3>{"IF YOU FAIL TO EAT TONIGHT, YOU WILL DIE"}</h3> <h3>{"IF YOU FAIL TO EAT TONIGHT, YOU WILL DIE"}</h3>
</> </div>
}) })
.unwrap_or_else(|| { .unwrap_or_else(|| {
html! { html! {
@ -45,8 +45,8 @@ pub fn MapleWolfPage1(MapleWolfPage1Props { starving }: &MapleWolfPage1Props) ->
<h2> <h2>
{"YOU CAN CHOOSE TO EAT A PLAYER TONIGHT"} {"YOU CAN CHOOSE TO EAT A PLAYER TONIGHT"}
</h2> </h2>
<div class="icons"> <div class="info-icon-grow">
<Icon source={IconSource::MapleWolf} icon_type={IconType::Informational}/> <Icon source={IconSource::MapleWolf} icon_type={IconType::Fit}/>
</div> </div>
{starving} {starving}
</div> </div>

View File

@ -12,12 +12,11 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
use core::ops::Not;
use werewolves_proto::role::Powerful; use werewolves_proto::role::Powerful;
use yew::prelude::*; use yew::prelude::*;
use crate::components::{AssociatedIcon, Icon, IconSource, IconType}; use crate::components::{Icon, IconSource, IconType};
#[function_component] #[function_component]
pub fn PowerSeerPage1() -> Html { pub fn PowerSeerPage1() -> Html {
@ -26,10 +25,9 @@ pub fn PowerSeerPage1() -> Html {
<h1 class="intel">{"POWER SEER"}</h1> <h1 class="intel">{"POWER SEER"}</h1>
<div class="information intel faint"> <div class="information intel faint">
<h2>{"PICK A PLAYER"}</h2> <h2>{"PICK A PLAYER"}</h2>
<h4 class="icons"> <div class="info-icon-grow">
<Icon source={IconSource::Powerful} icon_type={IconType::Informational}/> <Icon source={IconSource::PowerSeer} />
<Icon source={IconSource::Powerful} icon_type={IconType::Informational} inactive={true}/> </div>
</h4>
<h3 class="yellow">{"YOU WILL CHECK IF THEY ARE POWERFUL"}</h3> <h3 class="yellow">{"YOU WILL CHECK IF THEY ARE POWERFUL"}</h3>
</div> </div>
</div> </div>

View File

@ -14,7 +14,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
use yew::prelude::*; use yew::prelude::*;
use crate::components::{Icon, IconSource, IconType}; use crate::components::{Icon, IconSource};
#[function_component] #[function_component]
pub fn ProtectorPage1() -> Html { pub fn ProtectorPage1() -> Html {
@ -23,9 +23,9 @@ pub fn ProtectorPage1() -> Html {
<h1 class="defensive">{"PROTECTOR"}</h1> <h1 class="defensive">{"PROTECTOR"}</h1>
<div class="information defensive faint"> <div class="information defensive faint">
<h2>{"PICK A PLAYER"}</h2> <h2>{"PICK A PLAYER"}</h2>
<h4 class="icons"> <div class="info-icon-grow">
<Icon source={IconSource::Shield} icon_type={IconType::Informational}/> <Icon source={IconSource::Shield} />
</h4> </div>
<h3 class="yellow">{"YOU WILL PROTECT THEM FROM A DEATH TONIGHT"}</h3> <h3 class="yellow">{"YOU WILL PROTECT THEM FROM A DEATH TONIGHT"}</h3>
</div> </div>
</div> </div>

View File

@ -14,7 +14,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
use yew::prelude::*; use yew::prelude::*;
use crate::components::{Icon, IconSource, IconType}; use crate::components::{Icon, IconSource};
#[function_component] #[function_component]
pub fn RoleblockPage() -> Html { pub fn RoleblockPage() -> Html {
@ -23,9 +23,9 @@ pub fn RoleblockPage() -> Html {
<h1 class="wolves">{"ROLE BLOCKED"}</h1> <h1 class="wolves">{"ROLE BLOCKED"}</h1>
<div class="information wolves faint"> <div class="information wolves faint">
<h2>{"YOU WERE ROLE BLOCKED"}</h2> <h2>{"YOU WERE ROLE BLOCKED"}</h2>
<h4 class="icons"> <div class="info-icon-grow">
<Icon source={IconSource::Roleblock} icon_type={IconType::Informational}/> <Icon source={IconSource::Roleblock} />
</h4> </div>
<h3 class="yellow">{"YOUR NIGHT ACTION DID NOT TAKE PLACE"}</h3> <h3 class="yellow">{"YOUR NIGHT ACTION DID NOT TAKE PLACE"}</h3>
</div> </div>
</div> </div>

View File

@ -0,0 +1,40 @@
// Copyright (C) 2025 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convert_case::{Case, Casing};
use werewolves_proto::{
game::SetupRole,
role::{Alignment, RoleTitle},
};
use yew::prelude::*;
use crate::components::{AssociatedIcon, Icon, IconSource, IconType, PartialAssociatedIcon};
#[function_component]
pub fn ShiftFailed() -> Html {
html! {
<div class="role-page">
<h1 class="wolves">{"SHIFT FAILED"}</h1>
<div class={classes!("information", "wolves", "faint")}>
<h2>{"YOUR SHIFT HAS FAILED"}</h2>
<div class="info-icon-grow">
<Icon source={IconSource::RedX}/>
</div>
<h3>
{"YOU RETAIN YOUR SHAPESHIFT ABILITY"}
</h3>
</div>
</div>
}
}

View File

@ -0,0 +1,18 @@
use yew::prelude::*;
#[function_component]
pub fn TraitorIntroPage() -> Html {
html! {
<div class="role-page">
<h1 class="traitor">{"TRAITOR"}</h1>
<div class="information traitor faint">
<h2>{"YOU ARE A TRAITOR"}</h2>
<h3>{"YOU RETAIN YOUR ROLE AND WIN IF EVIL WINS"}</h3>
<h4>{"HOWEVER"}</h4>
<h2 class="yellow inline-icons">
{"YOU CONTRIBUTE TO VILLAGE PARITY"}
</h2>
</div>
</div>
}
}