aura setup & impl, shift fail screen, bugfixes
This commit is contained in:
parent
f193e4e691
commit
ac4ce81638
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,49 +357,47 @@ 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) => {
|
||||
let current_night = match village.time() {
|
||||
GameTime::Day { number: _ } => return Ok(Box::new([])),
|
||||
GameTime::Night { number } => number,
|
||||
};
|
||||
return Ok(village
|
||||
}),
|
||||
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)
|
||||
|
|
@ -398,48 +406,45 @@ impl Character {
|
|||
GameTime::Day { number } => number.get() + 1 >= current_night,
|
||||
GameTime::Night { number } => number + 1 >= current_night,
|
||||
})
|
||||
.then(|| ActionPrompt::RoleChange {
|
||||
{
|
||||
prompts.push(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 {
|
||||
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: {
|
||||
}),
|
||||
Role::Mortician => {
|
||||
let dead = village.dead_targets();
|
||||
if dead.is_empty() {
|
||||
return Ok(Box::new([]));
|
||||
}
|
||||
dead
|
||||
},
|
||||
if !dead.is_empty() {
|
||||
prompts.push(ActionPrompt::Mortician {
|
||||
character_id: self.identity(),
|
||||
dead_players: dead,
|
||||
marked: None,
|
||||
},
|
||||
Role::Beholder => ActionPrompt::Beholder {
|
||||
});
|
||||
}
|
||||
}
|
||||
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
|
||||
if night != 0
|
||||
&& let Some(last_day) = NonZeroU8::new(night)
|
||||
&& village
|
||||
.executions_on_day(last_day)
|
||||
.iter()
|
||||
.any(|c| c.is_village())
|
||||
.then(|| ActionPrompt::Vindicator {
|
||||
{
|
||||
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> {
|
||||
|
|
|
|||
|
|
@ -97,4 +97,6 @@ pub enum GameError {
|
|||
NoPreviousDuringDay,
|
||||
#[error("militia already spent")]
|
||||
MilitiaSpent,
|
||||
#[error("this role doesn't mark anyone")]
|
||||
RoleDoesntMark,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
));
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
current_changes.push(change);
|
||||
Ok(())
|
||||
}
|
||||
NightState::Complete => Err(GameError::NightOver),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, .. }
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { .. }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]>);
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()))?;
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.0 KiB |
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()}/>
|
||||
</>
|
||||
|
|
@ -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! {
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue