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 = { version = "1.0", features = ["derive"] }
uuid = { version = "1.17", features = ["v4", "serde"] }
rand = { version = "0.9" }
rand = { version = "0.9", features = ["std_rng"] }
werewolves-macros = { path = "../werewolves-macros" }
[dev-dependencies]

View File

@ -1,5 +1,3 @@
use core::fmt::Display;
// Copyright (C) 2025 Emilis Bliūdžius
//
// 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
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use core::fmt::Display;
use serde::{Deserialize, Serialize};
use werewolves_macros::ChecksAs;
use werewolves_macros::{ChecksAs, Titles};
use crate::{
bag::DrunkBag,
game::{GameTime, Village},
role::{Alignment, Killer, Powerful},
team::Team,
};
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 {
#[checks("assignable")]
Traitor,
#[checks("assignable")]
#[checks("cleansible")]
Drunk,
Drunk(DrunkBag),
#[checks("assignable")]
Insane,
#[checks("cleansible")]
Bloodlet {
night: u8,
},
Bloodlet { night: u8 },
}
impl Display for Aura {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Aura::Traitor => "Traitor",
Aura::Drunk => "Drunk",
Aura::Drunk(_) => "Drunk",
Aura::Insane => "Insane",
Aura::Bloodlet { .. } => "Bloodlet",
})
@ -50,7 +52,7 @@ impl Display for Aura {
impl Aura {
pub const fn expired(&self, village: &Village) -> bool {
match self {
Aura::Traitor | Aura::Drunk | Aura::Insane => false,
Aura::Traitor | Aura::Drunk(_) | Aura::Insane => false,
Aura::Bloodlet {
night: applied_night,
} => match village.time() {
@ -88,8 +90,16 @@ impl Auras {
&self.0
}
pub fn remove_aura(&mut self, aura: Aura) {
self.0.retain(|a| *a != aura);
pub fn list_mut(&mut self) -> &mut [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
@ -129,7 +139,7 @@ impl Auras {
match aura {
Aura::Traitor => return Some(Alignment::Traitor),
Aura::Bloodlet { .. } => return Some(Alignment::Wolves),
Aura::Drunk | Aura::Insane => {}
Aura::Drunk(_) | Aura::Insane => {}
}
}
None
@ -151,3 +161,14 @@ impl Auras {
.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 crate::{
aura::{Aura, Auras},
aura::{Aura, AuraTitle, Auras},
diedto::DiedTo,
error::GameError,
game::{GameTime, Village},
@ -207,6 +207,10 @@ impl Character {
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<()> {
let mut role = new_role.title_to_role_excl_apprentice();
core::mem::swap(&mut role, &mut self.role);
@ -304,7 +308,7 @@ impl Character {
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);
}
@ -319,7 +323,13 @@ impl Character {
GameTime::Day { number: _ } => return Err(GameError::NotNight),
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::Diseased
| Role::Weightlifter
@ -334,11 +344,11 @@ impl Character {
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(),
},
}),
Role::Scapegoat { redeemed: true } => {
let mut dead = village.dead_characters();
@ -347,99 +357,94 @@ impl Character {
.into_iter()
.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(),
new_role: pr,
}
} else {
return Ok(Box::new([]));
});
}
}
Role::Bloodletter => ActionPrompt::Bloodletter {
Role::Bloodletter => prompts.push(ActionPrompt::Bloodletter {
character_id: self.identity(),
living_players: village.living_villagers(),
marked: None,
},
Role::Seer => ActionPrompt::Seer {
}),
Role::Seer => prompts.push(ActionPrompt::Seer {
character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()),
marked: None,
},
Role::Arcanist => ActionPrompt::Arcanist {
}),
Role::Arcanist => prompts.push(ActionPrompt::Arcanist {
character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()),
marked: (None, None),
},
}),
Role::Protector {
last_protected: Some(last_protected),
} => ActionPrompt::Protector {
} => prompts.push(ActionPrompt::Protector {
character_id: self.identity(),
targets: village.living_players_excluding(*last_protected),
marked: None,
},
}),
Role::Protector {
last_protected: None,
} => ActionPrompt::Protector {
} => prompts.push(ActionPrompt::Protector {
character_id: self.identity(),
targets: village.living_players_excluding(self.character_id()),
targets: village.living_players(),
marked: None,
}),
Role::Apprentice(role) => match village.time() {
GameTime::Day { number: _ } => {}
GameTime::Night {
number: current_night,
} => {
if village
.characters()
.into_iter()
.filter(|c| c.role_title() == *role)
.filter_map(|char| char.died_to)
.any(|died_to| match died_to.date_time() {
GameTime::Day { number } => number.get() + 1 >= current_night,
GameTime::Night { number } => number + 1 >= current_night,
})
{
prompts.push(ActionPrompt::RoleChange {
character_id: self.identity(),
new_role: *role,
});
}
}
},
Role::Apprentice(role) => {
let current_night = match village.time() {
GameTime::Day { number: _ } => return Ok(Box::new([])),
GameTime::Night { number } => number,
};
return Ok(village
.characters()
.into_iter()
.filter(|c| c.role_title() == *role)
.filter_map(|char| char.died_to)
.any(|died_to| match died_to.date_time() {
GameTime::Day { number } => number.get() + 1 >= current_night,
GameTime::Night { number } => number + 1 >= current_night,
})
.then(|| ActionPrompt::RoleChange {
character_id: self.identity(),
new_role: *role,
})
.into_iter()
.collect());
}
Role::Elder {
knows_on_night,
woken_for_reveal: false,
..
} => {
let current_night = match village.time() {
GameTime::Day { number: _ } => return Ok(Box::new([])),
GameTime::Night { number } => number,
};
return Ok((current_night >= knows_on_night.get())
.then_some({
ActionPrompt::ElderReveal {
} => match village.time() {
GameTime::Day { number: _ } => {}
GameTime::Night { number } => {
if number >= knows_on_night.get() {
prompts.push(ActionPrompt::ElderReveal {
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(),
living_players: village.living_players_excluding(self.character_id()),
marked: None,
},
Role::Werewolf => ActionPrompt::WolfPackKill {
}),
Role::Werewolf => prompts.push(ActionPrompt::WolfPackKill {
living_villagers: village.living_players(),
marked: None,
},
Role::AlphaWolf { killed: None } => ActionPrompt::AlphaWolf {
}),
Role::AlphaWolf { killed: None } => prompts.push(ActionPrompt::AlphaWolf {
character_id: self.identity(),
living_villagers: village.living_players_excluding(self.character_id()),
marked: None,
},
}),
Role::DireWolf {
last_blocked: Some(last_blocked),
} => ActionPrompt::DireWolf {
} => prompts.push(ActionPrompt::DireWolf {
character_id: self.identity(),
living_players: village
.living_players_excluding(self.character_id())
@ -447,137 +452,124 @@ impl Character {
.filter(|c| c.character_id != *last_blocked)
.collect(),
marked: None,
},
Role::DireWolf { .. } => ActionPrompt::DireWolf {
}),
Role::DireWolf { .. } => prompts.push(ActionPrompt::DireWolf {
character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()),
marked: None,
},
Role::Shapeshifter { shifted_into: None } => ActionPrompt::Shapeshifter {
}),
Role::Shapeshifter { shifted_into: None } => prompts.push(ActionPrompt::Shapeshifter {
character_id: self.identity(),
},
}),
Role::Gravedigger => {
let dead = village.dead_targets();
if dead.is_empty() {
return Ok(Box::new([]));
}
ActionPrompt::Gravedigger {
character_id: self.identity(),
dead_players: village.dead_targets(),
marked: None,
if !dead.is_empty() {
prompts.push(ActionPrompt::Gravedigger {
character_id: self.identity(),
dead_players: village.dead_targets(),
marked: None,
});
}
}
Role::Hunter { target } => ActionPrompt::Hunter {
Role::Hunter { target } => prompts.push(ActionPrompt::Hunter {
character_id: self.identity(),
current_target: target.as_ref().and_then(|t| village.target_by_id(*t).ok()),
living_players: village.living_players_excluding(self.character_id()),
marked: None,
},
Role::MapleWolf { last_kill_on_night } => ActionPrompt::MapleWolf {
}),
Role::MapleWolf { last_kill_on_night } => prompts.push(ActionPrompt::MapleWolf {
character_id: self.identity(),
kill_or_die: last_kill_on_night + MAPLE_WOLF_ABSTAIN_LIMIT.get() == night,
living_players: village.living_players_excluding(self.character_id()),
marked: None,
},
}),
Role::Guardian {
last_protected: Some(PreviousGuardianAction::Guard(prev_target)),
} => ActionPrompt::Guardian {
} => prompts.push(ActionPrompt::Guardian {
character_id: self.identity(),
previous: Some(PreviousGuardianAction::Guard(prev_target.clone())),
living_players: village.living_players_excluding(prev_target.character_id),
marked: None,
},
}),
Role::Guardian {
last_protected: Some(PreviousGuardianAction::Protect(prev_target)),
} => ActionPrompt::Guardian {
} => prompts.push(ActionPrompt::Guardian {
character_id: self.identity(),
previous: Some(PreviousGuardianAction::Protect(prev_target.clone())),
living_players: village.living_players(),
marked: None,
},
}),
Role::Guardian {
last_protected: None,
} => ActionPrompt::Guardian {
} => prompts.push(ActionPrompt::Guardian {
character_id: self.identity(),
previous: None,
living_players: village.living_players(),
marked: None,
},
Role::Adjudicator => ActionPrompt::Adjudicator {
}),
Role::Adjudicator => prompts.push(ActionPrompt::Adjudicator {
character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()),
marked: None,
},
Role::PowerSeer => ActionPrompt::PowerSeer {
}),
Role::PowerSeer => prompts.push(ActionPrompt::PowerSeer {
character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()),
marked: None,
},
Role::Mortician => ActionPrompt::Mortician {
character_id: self.identity(),
dead_players: {
let dead = village.dead_targets();
if dead.is_empty() {
return Ok(Box::new([]));
}
dead
},
marked: None,
},
Role::Beholder => ActionPrompt::Beholder {
}),
Role::Mortician => {
let dead = village.dead_targets();
if !dead.is_empty() {
prompts.push(ActionPrompt::Mortician {
character_id: self.identity(),
dead_players: dead,
marked: None,
});
}
}
Role::Beholder => prompts.push(ActionPrompt::Beholder {
character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()),
marked: None,
},
}),
Role::MasonLeader { .. } => {
log::error!(
"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(),
living_players: village.living_players_excluding(self.character_id()),
marked: None,
},
}),
Role::Vindicator => {
let last_day = match village.time() {
GameTime::Day { .. } => {
log::error!(
"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)
.iter()
.any(|c| c.is_village())
.then(|| ActionPrompt::Vindicator {
if night != 0
&& let Some(last_day) = NonZeroU8::new(night)
&& village
.executions_on_day(last_day)
.iter()
.any(|c| c.is_village())
{
prompts.push(ActionPrompt::Vindicator {
character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()),
marked: None,
})
.into_iter()
.collect());
});
}
}
Role::PyreMaster { .. } => ActionPrompt::PyreMaster {
Role::PyreMaster { .. } => prompts.push(ActionPrompt::PyreMaster {
character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()),
marked: None,
},
Role::LoneWolf => ActionPrompt::LoneWolfKill {
}),
Role::LoneWolf => prompts.push(ActionPrompt::LoneWolfKill {
character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()),
marked: None,
},
}]))
}),
}
Ok(prompts.into_boxed_slice())
}
#[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 {
self.role.initial_shown_role()
}
@ -872,6 +886,8 @@ decl_ref_and_mut!(
Guardian, GuardianMut: Option<PreviousGuardianAction>;
Direwolf, DirewolfMut: Option<CharacterId>;
Militia, MilitiaMut: Option<CharacterId>;
MapleWolf, MapleWolfMut: u8;
Protector, ProtectorMut: Option<CharacterId>;
);
pub struct BlackKnightKill<'a> {

View File

@ -97,4 +97,6 @@ pub enum GameError {
NoPreviousDuringDay,
#[error("militia already spent")]
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> {
match (&mut self.state, message) {
(GameState::Night { night }, HostGameMessage::Night(HostNightMessage::NextPage)) => {
@ -102,7 +111,6 @@ impl Game {
(GameState::Day { village, marked }, HostGameMessage::Day(HostDayMessage::Execute)) => {
let time = village.time();
if let Some(outcome) = village.execute(marked)? {
log::warn!("adding to history for {}", village.time());
self.history.add(
village.time(),
GameActions::DayDetails(
@ -112,7 +120,6 @@ impl Game {
return Ok(ServerToHostMessage::GameOver(outcome));
}
let night = Night::new(village.clone())?;
log::warn!("adding to history for {time}");
self.history.add(
time,
GameActions::DayDetails(
@ -163,7 +170,6 @@ impl Game {
Err(GameError::NightOver) => {
let changes = night.collect_changes()?;
let village = night.village().with_night_changes(&changes)?;
log::warn!("adding to history for {}", night.village().time());
self.history.add(
night.village().time(),
GameActions::NightDetails(NightDetails::new(

View File

@ -13,6 +13,7 @@
// 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 mod changes;
mod next;
mod process;
use core::num::NonZeroU8;
@ -58,7 +59,8 @@ impl From<ActionComplete> for ResponseOutcome {
impl ActionPrompt {
fn unless(&self) -> Option<Unless> {
match &self {
ActionPrompt::Insomniac { .. }
ActionPrompt::TraitorIntro { .. }
| ActionPrompt::Insomniac { .. }
| ActionPrompt::MasonsWake { .. }
| ActionPrompt::WolvesIntro { .. }
| ActionPrompt::RoleChange { .. }
@ -532,13 +534,7 @@ impl Night {
// role change associated with the shapeshift
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_changes = Vec::new();
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
.changes_from_actions()
.into_iter()
@ -616,7 +612,7 @@ impl Night {
_ => false,
}) {
// 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| {
@ -667,7 +663,7 @@ impl Night {
}
}
self.action_queue = new_queue;
Ok(())
Ok(None)
}
pub const fn page(&self) -> Option<usize> {
@ -681,8 +677,8 @@ impl Night {
match &mut self.night_state {
NightState::Active { current_result, .. } => match current_result {
CurrentResult::None => self.received_response(ActionResponse::Continue),
CurrentResult::Result(ActionResult::Continue)
| CurrentResult::GoBackToSleepAfterShown { .. }
CurrentResult::GoBackToSleepAfterShown { .. }
| CurrentResult::Result(ActionResult::Continue)
| CurrentResult::Result(ActionResult::GoBackToSleep) => {
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> {
match self.received_response_with_role_blocks(resp)? {
BlockResolvedOutcome::PromptUpdate(prompt) => match &mut self.night_state {
@ -715,18 +723,18 @@ impl Night {
NightState::Complete => Err(GameError::NightOver),
},
BlockResolvedOutcome::ActionComplete(mut result, Some(change)) => {
match &mut self.night_state {
NightState::Active {
current_prompt: _,
current_result,
..
} => *current_result = result.clone().into(),
NightState::Complete => return Err(GameError::NightOver),
};
self.set_current_result(result.clone().into())?;
if let NightChange::Shapeshift { source, .. } = &change {
// needs to be resolved _now_ so that the target can be woken
// 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(
self.action_queue
.iter()
@ -766,10 +774,10 @@ impl Night {
}
}
fn received_response_consecutive_same_player_no_sleep(
fn outcome_consecutive_same_player_no_sleep(
&self,
resp: ActionResponse,
) -> Result<ResponseOutcome> {
outcome: ResponseOutcome,
) -> ResponseOutcome {
let same_char = self
.current_character_id()
.and_then(|curr| {
@ -780,25 +788,31 @@ impl Night {
})
.unwrap_or_default();
match (
self.received_response_consecutive_wolves_dont_sleep(resp)?,
same_char,
) {
(ResponseOutcome::PromptUpdate(p), _) => Ok(ResponseOutcome::PromptUpdate(p)),
match (outcome, same_char) {
(ResponseOutcome::PromptUpdate(p), _) => ResponseOutcome::PromptUpdate(p),
(
ResponseOutcome::ActionComplete(ActionComplete {
result: ActionResult::GoBackToSleep,
change,
}),
true,
) => Ok(ResponseOutcome::ActionComplete(ActionComplete {
) => ResponseOutcome::ActionComplete(ActionComplete {
result: ActionResult::Continue,
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(
&self,
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::ActionComplete(ActionComplete {
@ -911,6 +929,12 @@ impl Night {
&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> {
match &self.night_state {
NightState::Active {
@ -957,6 +981,11 @@ impl Night {
.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 {
matches!(self.night_state, NightState::Complete)
}
@ -968,83 +997,21 @@ impl Night {
.collect()
}
#[allow(clippy::should_implement_trait)]
pub fn next(&mut self) -> Result<()> {
match &self.night_state {
pub fn append_change(&mut self, change: NightChange) -> Result<()> {
match &mut self.night_state {
NightState::Active {
current_prompt,
current_result: CurrentResult::Result(ActionResult::Continue),
current_changes,
..
current_changes, ..
} => {
self.used_actions.push((
current_prompt.clone(),
ActionResult::Continue,
current_changes.clone(),
));
current_changes.push(change);
Ok(())
}
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),
NightState::Complete => 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(())
}
/// resolves whether the target [CharacterId] dies tonight with the current
/// state of the night
fn dies_tonight(&self, character_id: CharacterId) -> Result<bool> {
/// state of the night and returns the [DiedTo] cause of death
fn died_to_tonight(&self, character_id: CharacterId) -> Result<Option<DiedTo>> {
let ch = self.current_changes();
let mut changes = ChangesLookup::new(&ch);
if let Some(died_to) = changes.killed(character_id)
@ -1057,9 +1024,9 @@ impl Night {
)?
.is_some()
{
Ok(true)
Ok(Some(died_to.clone()))
} else {
Ok(false)
Ok(None)
}
}
@ -1202,7 +1169,8 @@ impl Night {
..
} => (*marked == visit_char).then(|| character_id.clone()),
ActionPrompt::Bloodletter { .. }
ActionPrompt::TraitorIntro { .. }
| ActionPrompt::Bloodletter { .. }
| ActionPrompt::WolfPackKill { marked: None, .. }
| ActionPrompt::Arcanist { marked: _, .. }
| 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::{
aura::Aura,
bag::DrunkRoll,
diedto::DiedTo,
error::GameError,
game::night::{
@ -23,7 +24,7 @@ use crate::{
},
message::night::{ActionPrompt, ActionResponse, ActionResult},
player::Protection,
role::{AlignmentEq, PreviousGuardianAction, RoleBlock, RoleTitle},
role::{Alignment, AlignmentEq, PreviousGuardianAction, RoleBlock, RoleTitle},
};
type Result<T> = core::result::Result<T, GameError>;
@ -109,10 +110,15 @@ impl Night {
};
match current_prompt {
ActionPrompt::TraitorIntro { .. } => Ok(ActionComplete {
result: ActionResult::GoBackToSleep,
change: None,
}
.into()),
ActionPrompt::Bloodletter {
character_id,
living_players,
marked: Some(marked),
..
} => Ok(ActionComplete {
result: ActionResult::GoBackToSleep,
change: Some(NightChange::ApplyAura {
@ -236,12 +242,11 @@ impl Night {
..
} => Ok(ResponseOutcome::ActionComplete(ActionComplete {
result: ActionResult::GoBackToSleep,
change: Some(NightChange::Kill {
change: NonZeroU8::new(self.night).map(|night| NightChange::Kill {
target: *marked,
died_to: DiedTo::MapleWolf {
night,
source: character_id.character_id,
night: NonZeroU8::new(self.night)
.ok_or(GameError::InvalidMessageForGameState)?,
starves_if_fails: *kill_or_die,
},
}),
@ -410,10 +415,11 @@ impl Night {
} => {
if let Some(result) = self.used_actions.iter().find_map(|(prompt, result, _)| {
prompt.matches_beholding(*marked).then_some(result)
}) && self.dies_tonight(*marked)?
}) && self.died_to_tonight(*marked)?.is_some()
{
Ok(ActionComplete {
result: if matches!(result, ActionResult::RoleBlocked) {
result: if matches!(result, ActionResult::RoleBlocked | ActionResult::Drunk)
{
ActionResult::BeholderSawNothing
} else {
result.clone()
@ -534,4 +540,38 @@ impl Night {
| 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 crate::{
aura::Aura,
aura::{Aura, AuraTitle},
character::Character,
error::GameError,
message::Identification,
@ -157,6 +157,39 @@ pub enum SetupRole {
}
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 {
match self {
SetupRoleTitle::Bloodletter => Role::Bloodletter,
@ -379,7 +412,7 @@ impl SlotId {
pub struct SetupSlot {
pub slot_id: SlotId,
pub role: SetupRole,
pub auras: Vec<Aura>,
pub auras: Vec<AuraTitle>,
pub assign_to: Option<PlayerId>,
pub created_order: u32,
}
@ -403,7 +436,10 @@ impl SetupSlot {
Character::new(
ident.clone(),
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()))
}

View File

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

View File

@ -15,7 +15,7 @@
use core::num::NonZeroU8;
use crate::{
aura::Aura,
aura::{Aura, AuraTitle},
diedto::DiedTo,
error::GameError,
game::{
@ -51,11 +51,27 @@ impl Village {
let mut changes = ChangesLookup::new(all_changes);
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 {
match change {
NightChange::ApplyAura { target, aura, .. } => {
let target = new_village.character_by_id_mut(*target)?;
target.apply_aura(*aura);
target.apply_aura(aura.clone());
}
NightChange::ElderReveal { elder } => {
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.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 } => {
@ -157,9 +178,38 @@ impl Village {
NightChange::LostAura { character, aura } => {
new_village
.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

View File

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

View File

@ -21,7 +21,8 @@ use crate::{
diedto::DiedToTitle,
game::{Game, GameSettings, GameState, OrRandom, SetupRole},
game_test::{
ActionPromptTitleExt, ActionResultExt, GameExt, SettingsExt, gen_players, init_log,
ActionPromptTitleExt, ActionResultExt, GameExt, ServerToHostMessageExt, SettingsExt,
gen_players, init_log,
},
message::{
Identification, PublicIdentity,
@ -353,7 +354,9 @@ fn previous_prompt() {
game.r#continue().r#continue();
game.next().title().shapeshifter();
game.response(ActionResponse::Shapeshift).sleep();
game.response(ActionResponse::Shapeshift)
.shapeshift_failed();
game.r#continue().sleep();
game.next().title().seer();
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 insomniac;
mod lone_wolf;
mod maple_wolf;
mod mason;
mod militia;
mod mortician;
mod protector;
mod pyremaster;
mod scapegoat;
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,);
game.response(ActionResponse::Shapeshift);
game.response(ActionResponse::Shapeshift)
.shapeshift_failed();
game.r#continue().sleep();
game.next_expect_day();
@ -218,3 +220,60 @@ fn i_would_simply_refuse() {
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
// along with this program. If not, see <https://www.gnu.org/licenses/>.
#![allow(clippy::new_without_default)]
pub mod aura;
pub mod bag;
pub mod character;
pub mod diedto;
pub mod error;

View File

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

View File

@ -18,10 +18,12 @@ use serde::{Deserialize, Serialize};
use werewolves_macros::{ChecksAs, Titles};
use crate::{
character::CharacterId,
aura::Aura,
character::{Character, CharacterId},
diedto::DiedTo,
game::{GameTime, Village},
message::CharacterIdentity,
message::{CharacterIdentity, Identification, PublicIdentity, night::ActionType},
player::PlayerId,
};
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize, Default)]
@ -318,7 +320,7 @@ impl Role {
Role::Werewolf => KillingWolfOrder::Werewolf,
Role::AlphaWolf { .. } => KillingWolfOrder::AlphaWolf,
Role::Bloodletter { .. } => KillingWolfOrder::Bloodletter,
Role::Bloodletter => KillingWolfOrder::Bloodletter,
Role::DireWolf { .. } => KillingWolfOrder::DireWolf,
Role::Shapeshifter { .. } => KillingWolfOrder::Shapeshifter,
Role::LoneWolf => KillingWolfOrder::LoneWolf,
@ -469,7 +471,7 @@ impl Display for Alignment {
match self {
Alignment::Village => f.write_str("Village"),
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
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use colored::Colorize;
use tokio::sync::{broadcast::Sender, mpsc::Receiver};
use werewolves_proto::{
error::GameError,
@ -45,6 +46,10 @@ impl HostComms {
}
pub fn send(&mut self, message: ServerToHostMessage) -> Result<(), GameError> {
log::debug!(
"sending message to host: {}",
format!("{message:?}").dimmed()
);
self.send
.send(message)
.map_err(|err| GameError::GenericError(err.to_string()))?;

View File

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

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);
$starts_as_villager_color: color.adjust($offensive_color, $hue: 30deg);
$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);
$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);
$intel_border_faint: color.change($intel_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);
$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);
$intel_color_faint: color.change($intel_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() {
display: -webkit-box;
@ -280,7 +287,6 @@ nav.host-nav {
align-self: center;
margin-bottom: 30px;
font-size: 2rem;
// background-color: hsl(283, 100%, 80%);
border: 1px solid rgba(0, 255, 0, 0.7);
background-color: black;
color: rgba(0, 255, 0, 0.7);
@ -298,6 +304,7 @@ nav.host-nav {
border: 1px solid rgba(255, 0, 0, 1);
color: rgba(255, 0, 0, 1);
filter: none;
background-color: rgba(255, 0, 0, 0.1);
&:hover {
background-color: rgba(255, 0, 0, 0.3);
@ -1110,15 +1117,20 @@ input {
cursor: pointer;
}
display: flex;
flex-direction: column;
flex-wrap: nowrap;
align-items: center;
&>.submenu {
min-width: 30vw;
width: 30vw;
// position: absolute;
.assign-list {
// min-width: 5cm;
gap: 10px;
& .submenu button {
width: 5cm;
width: 10vw;
}
}
@ -1126,6 +1138,7 @@ input {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
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 {
display: flex;
flex-direction: row;
@ -1790,6 +1841,18 @@ input {
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 {
display: flex;
flex-direction: column;
@ -1943,6 +2006,39 @@ input {
flex-direction: column;
flex-wrap: nowrap;
justify-content: center;
align-items: center;
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) {
let prompt = self.mark(mark);
match prompt {
ActionPrompt::Insomniac { .. }
ActionPrompt::TraitorIntro { .. }
| ActionPrompt::Insomniac { .. }
| ActionPrompt::MasonsWake { .. }
| ActionPrompt::ElderReveal { .. }
| ActionPrompt::CoverOfDarkness

View File

@ -31,7 +31,7 @@ use crate::{
Button, CoverOfDarkness, Identity,
action::{BinaryChoice, TargetPicker, WolvesIntro},
},
pages::MasonsWake,
pages::{MasonsWake, TraitorIntroPage},
};
#[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 {
ActionPrompt::TraitorIntro { character_id } => {
return html! {
<div class="prompt">
{identity_html(props, Some(character_id))}
<TraitorIntroPage />
{cont}
</div>
};
}
ActionPrompt::CoverOfDarkness => {
return html! {
<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! {
<div class="role-page">
{identity_html(props, Some(character_id))}
<h1 class="wolves">{"SHAPESHIFTER"}</h1>
<div class="information wolves faint">
<h3>
<h2>
{"WOULD YOU LIKE TO USE YOUR "}
<span class="yellow">{"ONCE PER GAME"}</span>
{" SHAPESHIFT ABILITY?"}
</h3>
<h4>
</h2>
<h2>
<span class="yellow">{"YOU WILL DIE"}</span>{", AND THE "}
{"TARGET OF THE WOLFPACK KILL"}
{" SHALL INSTEAD BECOME A WOLF"}
</h4>
</h2>
</div>
<BinaryChoice on_chosen={on_select}>
</BinaryChoice>
{choice}
</div>
};
}

View File

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

View File

@ -12,28 +12,39 @@
//
// 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 werewolves_proto::aura;
use werewolves_proto::aura::{self, Aura, AuraTitle};
use yew::prelude::*;
use crate::components::{Icon, IconType, PartialAssociatedIcon};
use crate::{
class::Class,
components::{Icon, IconType, PartialAssociatedIcon},
};
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct AuraProps {
pub aura: aura::Aura,
impl Class for AuraTitle {
fn class(&self) -> Option<&'static str> {
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> {
Some(match aura {
aura::Aura::Traitor => "traitor",
aura::Aura::Drunk => "drunk",
aura::Aura::Insane => "insane",
aura::Aura::Bloodlet { .. } => "wolves",
})
impl Class for Aura {
fn class(&self) -> Option<&'static str> {
self.title().class()
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct AuraSpanProps {
pub aura: aura::AuraTitle,
}
#[function_component]
pub fn Aura(AuraProps { aura }: &AuraProps) -> Html {
let class = aura_class(aura);
pub fn AuraSpan(AuraSpanProps { aura }: &AuraSpanProps) -> Html {
let class = aura.class();
let icon = aura.icon().map(|icon| {
html! {
<div>

View File

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

View File

@ -13,7 +13,7 @@
// 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 werewolves_proto::{
aura::Aura,
aura::{Aura, AuraTitle},
diedto::DiedToTitle,
role::{Alignment, Killer, Powerful, RoleTitle},
};
@ -79,6 +79,8 @@ decl_icon!(
RedX: "/img/red-x.svg",
Traitor: "/img/traitor.svg",
Bloodlet: "/img/bloodlet.svg",
Drunk: "/img/drunk.svg",
Insane: "/img/insane.svg",
);
impl IconSource {
@ -95,7 +97,6 @@ impl IconSource {
pub enum IconType {
List,
Small,
RoleAdd,
Fit,
Icon15Pct,
Informational,
@ -110,7 +111,6 @@ impl IconType {
IconType::Fit => "icon-fit",
IconType::List => "icon-in-list",
IconType::Small => "icon",
IconType::RoleAdd => "icon-role-add",
IconType::Informational => "icon-info",
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> {
match self {
Aura::Traitor => Some(IconSource::Traitor),
Aura::Drunk => todo!(),
Aura::Insane => todo!(),
Aura::Bloodlet { .. } => Some(IconSource::Bloodlet),
AuraTitle::Traitor => Some(IconSource::Traitor),
AuraTitle::Drunk => Some(IconSource::Drunk),
AuraTitle::Insane => Some(IconSource::Insane),
AuraTitle::Bloodlet => Some(IconSource::Bloodlet),
}
}
}

View File

@ -17,6 +17,7 @@ use std::rc::Rc;
use convert_case::{Case, Casing};
use werewolves_proto::{
aura::{Aura, AuraTitle},
error::GameError,
game::{GameSettings, OrRandom, SetupRole, SetupSlot, SlotId},
message::{Identification, PlayerState, PublicIdentity},
@ -24,9 +25,11 @@ use werewolves_proto::{
};
use yew::prelude::*;
use crate::components::{
Button, ClickableField, Icon, IconSource, IconType, Identity, PartialAssociatedIcon,
client::Signin,
use crate::{
class::Class,
components::{
Button, ClickableField, Icon, IconType, Identity, PartialAssociatedIcon, client::Signin,
},
};
#[derive(Debug, PartialEq, Properties)]
@ -346,6 +349,7 @@ pub fn SettingsSlot(
}: &SettingsSlotProps,
) -> Html {
let open = use_state(|| false);
let aura_open = use_state(|| false);
let open_update = open.setter();
let update = update.clone();
let update = Callback::from(move |act| {
@ -362,8 +366,14 @@ pub fn SettingsSlot(
open_update.set(false);
});
let assign_to = assign_to_submenu(players_for_assign, slot, &update, &open.setter());
let options =
setup_options_for_slot(slot, &update, roles_in_setup, apprentice_open, open.clone());
let options = setup_options_for_slot(
slot,
&update,
roles_in_setup,
apprentice_open,
open.clone(),
aura_open.clone(),
);
let assign_text = slot
.assign_to
.as_ref()
@ -379,7 +389,7 @@ pub fn SettingsSlot(
.unwrap_or_else(|| html! {{"assign"}});
html! {
<>
<Button on_click={on_kick}>
<Button on_click={on_kick} classes={classes!("red")}>
{"remove"}
</Button>
<ClickableField
@ -447,7 +457,71 @@ fn setup_options_for_slot(
roles_in_setup: &[RoleTitle],
open_apprentice_assign: UseStateHandle<bool>,
slot_field_open: UseStateHandle<bool>,
open_aura_assign: UseStateHandle<bool>,
) -> 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 {
SetupRole::MasonLeader { recruits_available } => {
let next = {
@ -660,5 +734,10 @@ fn setup_options_for_slot(
_ => 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 werewolves_proto::{
character::{Character, CharacterId},
game::{
aura::AuraTitle, character::{Character, CharacterId}, game::{
GameTime, SetupRole,
night::changes::NightChange,
story::{
DayDetail, GameActions, GameStory, NightChoice, StoryActionPrompt, StoryActionResult,
},
},
role::Alignment,
}, role::Alignment
};
use yew::prelude::*;
use crate::components::{
CharacterCard, Icon, IconSource, IconType, PartialAssociatedIcon,
attributes::{
AuraSpan, CharacterCard, Icon, IconSource, IconType, PartialAssociatedIcon, attributes::{
AlignmentComparisonSpan, AlignmentSpan, CategorySpan, DiedToSpan, KillerSpan, PowerfulSpan,
},
}
};
#[derive(Debug, Clone, PartialEq, Properties)]
@ -177,11 +174,12 @@ struct StoryNightChangeProps {
#[function_component]
fn StoryNightChange(StoryNightChangeProps { change, characters }: &StoryNightChangeProps) -> Html {
match change {
NightChange::LostAura { character, aura } => characters.get(character).map(|character| html!{
<>
<CharacterCard faint=true char={character.clone()}/>
{"lost the"}
<crate::components::Aura aura={*aura}/>
<AuraSpan aura={aura.title()}/>
{"aura"}
</>
}).unwrap_or_default(),
@ -190,7 +188,7 @@ fn StoryNightChange(StoryNightChangeProps { change, characters }: &StoryNightCha
<>
<CharacterCard faint=true char={target.clone()}/>
{"gained the"}
<crate::components::Aura aura={*aura}/>
<AuraSpan aura={aura.title()}/>
{"aura from"}
<CharacterCard faint=true char={source.clone()}/>
</>
@ -278,7 +276,7 @@ fn StoryNightChange(StoryNightChangeProps { change, characters }: &StoryNightCha
})
.unwrap_or_default(),
NightChange::HunterTarget { .. }
NightChange::HunterTarget { .. }
| NightChange::MasonRecruit { .. }
| NightChange::Protection { .. } => html! {}, // sorted in prompt side
}
@ -293,6 +291,19 @@ struct StoryNightResultProps {
#[function_component]
fn StoryNightResult(StoryNightResultProps { result, characters }: &StoryNightResultProps) -> Html {
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!{
<span>{"but saw nothing"}</span>
},
@ -396,7 +407,7 @@ struct StoryNightChoiceProps {
}
#[function_component]
fn StoryNightChoice(StoryNightChoiceProps { choice, characters }: &StoryNightChoiceProps) -> Html {
fn StoryNightChoice(StoryNightChoiceProps { choice, characters}: &StoryNightChoiceProps) -> Html {
let generate = |character_id: &CharacterId, chosen: &CharacterId, action: &'static str| {
characters
.get(character_id)
@ -544,6 +555,9 @@ fn StoryNightChoice(StoryNightChoiceProps { choice, characters }: &StoryNightCho
})
}
StoryActionPrompt::Shapeshifter { character_id } => {
if choice.result.is_none() {
return html!{};
}
characters.get(character_id).map(|shifter| {
html! {
<>

View File

@ -13,6 +13,7 @@
// 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/>.
mod assets;
mod class;
mod clients;
mod storage;
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 icon = role.icon().map(|icon| {
html! {
<h4 class="icons">
<Icon source={icon} icon_type={IconType::Informational}/>
</h4>
<div class="info-icon-grow">
<Icon source={icon}/>
</div>
}
});
html! {
<div class="role-page">
<h1 class="intel">{"ROLE CHANGE"}</h1>
<h1 class={classes!(class)}>{"ROLE CHANGE"}</h1>
<div class={classes!("information", class, "faint")}>
<h2>{"YOUR ROLE HAS CHANGED"}</h2>
{icon}
<h3 class="yellow">{"YOUR NEW ROLE IS"}</h3>
<h3>{role.to_string().to_case(Case::Upper)}</h3>
<h3>
<span>{"YOUR NEW ROLE IS "}</span>
<span class="yellow">{role.to_string().to_case(Case::Upper)}</span>
</h3>
</div>
</div>
}

View File

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

View File

@ -12,12 +12,11 @@
//
// 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::ops::Not;
use werewolves_proto::role::Powerful;
use yew::prelude::*;
use crate::components::{AssociatedIcon, Icon, IconSource, IconType};
use crate::components::{Icon, IconSource, IconType};
#[function_component]
pub fn PowerSeerPage1() -> Html {
@ -26,10 +25,9 @@ pub fn PowerSeerPage1() -> Html {
<h1 class="intel">{"POWER SEER"}</h1>
<div class="information intel faint">
<h2>{"PICK A PLAYER"}</h2>
<h4 class="icons">
<Icon source={IconSource::Powerful} icon_type={IconType::Informational}/>
<Icon source={IconSource::Powerful} icon_type={IconType::Informational} inactive={true}/>
</h4>
<div class="info-icon-grow">
<Icon source={IconSource::PowerSeer} />
</div>
<h3 class="yellow">{"YOU WILL CHECK IF THEY ARE POWERFUL"}</h3>
</div>
</div>

View File

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

View File

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