bloodletter implementation + aura logic + traitor

This commit is contained in:
emilis 2025-11-09 16:40:50 +00:00
parent ad29c3d59c
commit f193e4e691
No known key found for this signature in database
30 changed files with 874 additions and 163 deletions

View File

@ -0,0 +1,153 @@
use core::fmt::Display;
// 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 serde::{Deserialize, Serialize};
use werewolves_macros::ChecksAs;
use crate::{
game::{GameTime, Village},
role::{Alignment, Killer, Powerful},
team::Team,
};
const BLOODLET_DURATION_DAYS: u8 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ChecksAs)]
pub enum Aura {
Traitor,
#[checks("cleansible")]
Drunk,
Insane,
#[checks("cleansible")]
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::Insane => "Insane",
Aura::Bloodlet { .. } => "Bloodlet",
})
}
}
impl Aura {
pub const fn expired(&self, village: &Village) -> bool {
match self {
Aura::Traitor | Aura::Drunk | Aura::Insane => false,
Aura::Bloodlet {
night: applied_night,
} => match village.time() {
GameTime::Day { .. } => false,
GameTime::Night {
number: current_night,
} => current_night >= applied_night.saturating_add(BLOODLET_DURATION_DAYS),
},
}
}
pub const fn refreshes(&self, other: &Aura) -> bool {
matches!(
(self, other),
(Aura::Bloodlet { .. }, Aura::Bloodlet { .. })
)
}
pub fn refresh(&mut self, other: Aura) {
if let (Aura::Bloodlet { night }, Aura::Bloodlet { night: new_night }) = (self, other) {
*night = new_night
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Auras(Vec<Aura>);
impl Auras {
pub const fn new(auras: Vec<Aura>) -> Self {
Self(auras)
}
pub fn list(&self) -> &[Aura] {
&self.0
}
pub fn remove_aura(&mut self, aura: Aura) {
self.0.retain(|a| *a != aura);
}
/// purges expired [Aura]s and returns the ones that were removed
pub fn purge_expired(&mut self, village: &Village) -> Box<[Aura]> {
let mut auras = Vec::with_capacity(self.0.len());
core::mem::swap(&mut self.0, &mut auras);
let (expired, retained): (Vec<_>, Vec<_>) =
auras.into_iter().partition(|aura| aura.expired(village));
self.0 = retained;
expired.into_boxed_slice()
}
pub fn add(&mut self, aura: Aura) {
if let Some(existing) = self.0.iter_mut().find(|aura| aura.refreshes(aura)) {
existing.refresh(aura);
} else {
self.0.push(aura);
}
}
pub fn cleanse(&mut self) {
self.0.retain(|aura| !aura.cleansible());
}
/// returns [Some] if the auras override the player's [Team]
pub fn overrides_team(&self) -> Option<Team> {
if self.0.iter().any(|a| matches!(a, Aura::Traitor)) {
return Some(Team::AnyEvil);
}
None
}
/// returns [Some] if the auras override the player's [Alignment]
pub fn overrides_alignment(&self) -> Option<Alignment> {
for aura in self.0.iter() {
match aura {
Aura::Traitor => return Some(Alignment::Traitor),
Aura::Bloodlet { .. } => return Some(Alignment::Wolves),
Aura::Drunk | Aura::Insane => {}
}
}
None
}
/// returns [Some] if the auras override whether the player is a [Killer]
pub fn overrides_killer(&self) -> Option<Killer> {
self.0
.iter()
.any(|a| matches!(a, Aura::Bloodlet { .. }))
.then_some(Killer::Killer)
}
/// returns [Some] if the auras override whether the player is [Powerful]
pub fn overrides_powerful(&self) -> Option<Powerful> {
self.0
.iter()
.any(|a| matches!(a, Aura::Bloodlet { .. }))
.then_some(Powerful::Powerful)
}
}

View File

@ -22,11 +22,11 @@ use rand::seq::SliceRandom;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
aura::{Aura, Auras},
diedto::DiedTo, diedto::DiedTo,
error::GameError, error::GameError,
game::{GameTime, Village}, game::{GameTime, Village},
message::{CharacterIdentity, Identification, PublicIdentity, night::ActionPrompt}, message::{CharacterIdentity, Identification, PublicIdentity, night::ActionPrompt},
modifier::Modifier,
player::{PlayerId, RoleChange}, player::{PlayerId, RoleChange},
role::{ role::{
Alignment, Killer, KillingWolfOrder, MAPLE_WOLF_ABSTAIN_LIMIT, Powerful, Alignment, Killer, KillingWolfOrder, MAPLE_WOLF_ABSTAIN_LIMIT, Powerful,
@ -59,7 +59,7 @@ pub struct Character {
player_id: PlayerId, player_id: PlayerId,
identity: CharacterIdentity, identity: CharacterIdentity,
role: Role, role: Role,
modifier: Option<Modifier>, auras: Auras,
died_to: Option<DiedTo>, died_to: Option<DiedTo>,
role_changes: Vec<RoleChange>, role_changes: Vec<RoleChange>,
} }
@ -76,19 +76,20 @@ impl Character {
}, },
}: Identification, }: Identification,
role: Role, role: Role,
auras: Vec<Aura>,
) -> Option<Self> { ) -> Option<Self> {
Some(Self { Some(Self {
role, role,
player_id,
died_to: None,
auras: Auras::new(auras),
role_changes: Vec::new(),
identity: CharacterIdentity { identity: CharacterIdentity {
character_id: CharacterId::new(), character_id: CharacterId::new(),
name, name,
pronouns, pronouns,
number: number?, number: number?,
}, },
player_id,
modifier: None,
died_to: None,
role_changes: Vec::new(),
}) })
} }
@ -189,13 +190,6 @@ impl Character {
} }
} }
pub const fn alignment(&self) -> Alignment {
if let Role::Empath { cursed: true } = &self.role {
return Alignment::Wolves;
}
self.role.alignment()
}
pub fn elder_reveal(&mut self) { pub fn elder_reveal(&mut self) {
if let Role::Elder { if let Role::Elder {
woken_for_reveal, .. woken_for_reveal, ..
@ -205,6 +199,14 @@ impl Character {
} }
} }
pub fn purge_expired_auras(&mut self, village: &Village) -> Box<[Aura]> {
self.auras.purge_expired(village)
}
pub fn auras(&self) -> &[Aura] {
self.auras.list()
}
pub fn role_change(&mut self, new_role: RoleTitle, at: GameTime) -> Result<()> { pub fn role_change(&mut self, new_role: RoleTitle, at: GameTime) -> Result<()> {
let mut role = new_role.title_to_role_excl_apprentice(); let mut role = new_role.title_to_role_excl_apprentice();
core::mem::swap(&mut role, &mut self.role); core::mem::swap(&mut role, &mut self.role);
@ -298,6 +300,14 @@ impl Character {
AsCharacter(char) AsCharacter(char)
} }
pub fn apply_aura(&mut self, aura: Aura) {
self.auras.add(aura);
}
pub fn remove_aura(&mut self, aura: Aura) {
self.auras.remove_aura(aura);
}
pub fn night_action_prompts(&self, village: &Village) -> Result<Box<[ActionPrompt]>> { pub fn night_action_prompts(&self, village: &Village) -> Result<Box<[ActionPrompt]>> {
if self.mason_leader().is_ok() { if self.mason_leader().is_ok() {
return self.mason_prompts(village); return self.mason_prompts(village);
@ -345,6 +355,11 @@ impl Character {
return Ok(Box::new([])); return Ok(Box::new([]));
} }
} }
Role::Bloodletter => ActionPrompt::Bloodletter {
character_id: self.identity(),
living_players: village.living_villagers(),
marked: None,
},
Role::Seer => ActionPrompt::Seer { Role::Seer => ActionPrompt::Seer {
character_id: self.identity(), character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()), living_players: village.living_players_excluding(self.character_id()),
@ -574,14 +589,30 @@ impl Character {
self.role.killing_wolf_order() self.role.killing_wolf_order()
} }
pub const fn killer(&self) -> Killer { pub fn alignment(&self) -> Alignment {
if let Some(alignment) = self.auras.overrides_alignment() {
return alignment;
}
if let Role::Empath { cursed: true } = &self.role {
return Alignment::Wolves;
}
self.role.alignment()
}
pub fn killer(&self) -> Killer {
if let Some(killer) = self.auras.overrides_killer() {
return killer;
}
if let Role::Empath { cursed: true } = &self.role { if let Role::Empath { cursed: true } = &self.role {
return Killer::Killer; return Killer::Killer;
} }
self.role.killer() self.role.killer()
} }
pub const fn powerful(&self) -> Powerful { pub fn powerful(&self) -> Powerful {
if let Some(powerful) = self.auras.overrides_powerful() {
return powerful;
}
if let Role::Empath { cursed: true } = &self.role { if let Role::Empath { cursed: true } = &self.role {
return Powerful::Powerful; return Powerful::Powerful;
} }

View File

@ -30,7 +30,7 @@ use crate::{
kill::{self}, kill::{self},
night::changes::{ChangesLookup, NightChange}, night::changes::{ChangesLookup, NightChange},
}, },
message::night::{ActionPrompt, ActionResponse, ActionResult, Visits}, message::night::{ActionPrompt, ActionPromptTitle, ActionResponse, ActionResult, Visits},
role::RoleTitle, role::RoleTitle,
}; };
@ -71,7 +71,11 @@ impl ActionPrompt {
.. ..
} => Some(Unless::TargetsBlocked(*marked1, *marked2)), } => Some(Unless::TargetsBlocked(*marked1, *marked2)),
ActionPrompt::LoneWolfKill { ActionPrompt::Bloodletter {
marked: Some(marked),
..
}
| ActionPrompt::LoneWolfKill {
marked: Some(marked), marked: Some(marked),
.. ..
} }
@ -148,7 +152,8 @@ impl ActionPrompt {
.. ..
} => Some(Unless::TargetBlocked(*marked)), } => Some(Unless::TargetBlocked(*marked)),
ActionPrompt::LoneWolfKill { marked: None, .. } ActionPrompt::Bloodletter { .. }
| ActionPrompt::LoneWolfKill { marked: None, .. }
| ActionPrompt::Seer { marked: None, .. } | ActionPrompt::Seer { marked: None, .. }
| ActionPrompt::Protector { marked: None, .. } | ActionPrompt::Protector { marked: None, .. }
| ActionPrompt::Gravedigger { marked: None, .. } | ActionPrompt::Gravedigger { marked: None, .. }
@ -258,15 +263,29 @@ pub struct Night {
night: u8, night: u8,
action_queue: VecDeque<ActionPrompt>, action_queue: VecDeque<ActionPrompt>,
used_actions: Vec<(ActionPrompt, ActionResult, Vec<NightChange>)>, used_actions: Vec<(ActionPrompt, ActionResult, Vec<NightChange>)>,
start_of_night_changes: Vec<NightChange>,
night_state: NightState, night_state: NightState,
} }
impl Night { impl Night {
pub fn new(village: Village) -> Result<Self> { pub fn new(mut village: Village) -> Result<Self> {
let night = match village.time() { let night = match village.time() {
GameTime::Day { number: _ } => return Err(GameError::NotNight), GameTime::Day { number: _ } => return Err(GameError::NotNight),
GameTime::Night { number } => number, GameTime::Night { number } => number,
}; };
let mut start_of_night_changes = Self::start_of_night_changes(&village, night);
// purge expired auras
{
let village_clone = village.clone();
for char in village.characters_mut() {
for expired_aura in char.purge_expired_auras(&village_clone) {
start_of_night_changes.push(NightChange::LostAura {
character: char.character_id(),
aura: expired_aura,
});
}
}
}
let filter = if village.executed_known_elder() { let filter = if village.executed_known_elder() {
// there is a lynched elder, remove villager PRs from the prompts // there is a lynched elder, remove villager PRs from the prompts
@ -380,6 +399,7 @@ impl Night {
village, village,
night_state, night_state,
action_queue, action_queue,
start_of_night_changes,
used_actions: Vec::new(), used_actions: Vec::new(),
}) })
} }
@ -402,77 +422,37 @@ impl Night {
// for prompt in action_queue { // for prompt in action_queue {
while let Some(prompt) = action_queue.pop_front() { while let Some(prompt) = action_queue.pop_front() {
log::warn!("prompt: {:?}", prompt.title()); log::warn!("prompt: {:?}", prompt.title());
let (wolf_id, prompt) = match prompt { match prompt {
ActionPrompt::WolvesIntro { mut wolves } => { ActionPrompt::WolvesIntro { mut wolves } => {
if let Some(w) = wolves.iter_mut().find(|w| w.0.character_id == reverting) { if let Some(w) = wolves.iter_mut().find(|w| w.0.character_id == reverting) {
w.1 = reverting_into; w.1 = reverting_into;
} }
new_queue.push_back(ActionPrompt::WolvesIntro { wolves }); new_queue.push_back(ActionPrompt::WolvesIntro { wolves });
}
other => {
if let Some(char_id) = other.character_id()
&& char_id == reverting
&& !matches!(other.title(), ActionPromptTitle::RoleChange)
{
continue; continue;
} }
ActionPrompt::Shapeshifter { character_id } => (
character_id.character_id,
ActionPrompt::Shapeshifter { character_id },
),
ActionPrompt::AlphaWolf {
character_id,
living_villagers,
marked,
} => (
character_id.character_id,
ActionPrompt::AlphaWolf {
character_id,
living_villagers,
marked,
},
),
ActionPrompt::DireWolf {
character_id,
living_players,
marked,
} => (
character_id.character_id,
ActionPrompt::DireWolf {
character_id,
living_players,
marked,
},
),
ActionPrompt::LoneWolfKill {
character_id,
living_players,
marked,
} => (
character_id.character_id,
ActionPrompt::LoneWolfKill {
character_id,
living_players,
marked,
},
),
other => {
new_queue.push_back(other); new_queue.push_back(other);
continue;
} }
}; };
if wolf_id != reverting {
new_queue.push_back(prompt);
}
} }
new_queue new_queue
} }
/// changes that require no input (such as hunter firing) /// changes from the beginning of the night that require no input (such as hunter firing)
fn automatic_changes(&self) -> Vec<NightChange> { fn start_of_night_changes(village: &Village, night: u8) -> Vec<NightChange> {
let mut changes = Vec::new(); let mut changes = Vec::new();
let night = match NonZeroU8::new(self.night) { let night = match NonZeroU8::new(night) {
Some(night) => night, Some(night) => night,
None => return changes, None => return changes,
}; };
if !self.village.executed_known_elder() { if !village.executed_known_elder() {
self.village village
.dead_characters() .dead_characters()
.into_iter() .into_iter()
.filter_map(|c| c.died_to().map(|d| (c, d))) .filter_map(|c| c.died_to().map(|d| (c, d)))
@ -576,9 +556,13 @@ impl Night {
if !matches!(self.night_state, NightState::Complete) { if !matches!(self.night_state, NightState::Complete) {
return Err(GameError::NotEndOfNight); return Err(GameError::NotEndOfNight);
} }
let mut all_changes = self.automatic_changes(); Ok(self.current_changes())
}
pub fn current_changes(&self) -> Box<[NightChange]> {
let mut all_changes = self.start_of_night_changes.clone();
all_changes.append(&mut self.changes_from_actions().into_vec()); all_changes.append(&mut self.changes_from_actions().into_vec());
Ok(all_changes.into_boxed_slice()) all_changes.into_boxed_slice()
} }
fn apply_mason_recruit( fn apply_mason_recruit(
@ -1061,11 +1045,7 @@ impl Night {
/// resolves whether the target [CharacterId] dies tonight with the current /// resolves whether the target [CharacterId] dies tonight with the current
/// state of the night /// state of the night
fn dies_tonight(&self, character_id: CharacterId) -> Result<bool> { fn dies_tonight(&self, character_id: CharacterId) -> Result<bool> {
let ch = self let ch = self.current_changes();
.changes_from_actions()
.into_iter()
.chain(self.automatic_changes())
.collect::<Box<[_]>>();
let mut changes = ChangesLookup::new(&ch); let mut changes = ChangesLookup::new(&ch);
if let Some(died_to) = changes.killed(character_id) if let Some(died_to) = changes.killed(character_id)
&& kill::resolve_kill( && kill::resolve_kill(
@ -1083,6 +1063,23 @@ impl Night {
} }
} }
/// returns the matching [Character] with the current night's aura changes
/// applied
fn character_with_current_auras(&self, id: CharacterId) -> Result<Character> {
let mut character = self.village.character_by_id(id)?.clone();
for aura in self
.changes_from_actions()
.into_iter()
.filter_map(|c| match c {
NightChange::ApplyAura { target, aura, .. } => (target == id).then_some(aura),
_ => None,
})
{
character.apply_aura(aura);
}
Ok(character)
}
fn changes_from_actions(&self) -> Box<[NightChange]> { fn changes_from_actions(&self) -> Box<[NightChange]> {
self.used_actions self.used_actions
.iter() .iter()
@ -1109,7 +1106,12 @@ impl Night {
.then(|| self.village.killing_wolf().map(|c| c.identity())) .then(|| self.village.killing_wolf().map(|c| c.identity()))
.flatten(), .flatten(),
ActionPrompt::Seer { ActionPrompt::Bloodletter {
character_id,
marked: Some(marked),
..
}
| ActionPrompt::Seer {
character_id, character_id,
marked: Some(marked), marked: Some(marked),
.. ..
@ -1200,7 +1202,8 @@ impl Night {
.. ..
} => (*marked == visit_char).then(|| character_id.clone()), } => (*marked == visit_char).then(|| character_id.clone()),
ActionPrompt::WolfPackKill { marked: None, .. } ActionPrompt::Bloodletter { .. }
| ActionPrompt::WolfPackKill { marked: None, .. }
| ActionPrompt::Arcanist { marked: _, .. } | ActionPrompt::Arcanist { marked: _, .. }
| ActionPrompt::LoneWolfKill { marked: None, .. } | ActionPrompt::LoneWolfKill { marked: None, .. }
| ActionPrompt::Seer { marked: None, .. } | ActionPrompt::Seer { marked: None, .. }

View File

@ -18,6 +18,7 @@ use serde::{Deserialize, Serialize};
use werewolves_macros::Extract; use werewolves_macros::Extract;
use crate::{ use crate::{
aura::Aura,
character::CharacterId, character::CharacterId,
diedto::DiedTo, diedto::DiedTo,
player::Protection, player::Protection,
@ -59,6 +60,15 @@ pub enum NightChange {
empath: CharacterId, empath: CharacterId,
scapegoat: CharacterId, scapegoat: CharacterId,
}, },
ApplyAura {
source: CharacterId,
target: CharacterId,
aura: Aura,
},
LostAura {
character: CharacterId,
aura: Aura,
},
} }
pub struct ChangesLookup<'a>(&'a [NightChange], Vec<usize>); pub struct ChangesLookup<'a>(&'a [NightChange], Vec<usize>);

View File

@ -15,6 +15,7 @@
use core::num::NonZeroU8; use core::num::NonZeroU8;
use crate::{ use crate::{
aura::Aura,
diedto::DiedTo, diedto::DiedTo,
error::GameError, error::GameError,
game::night::{ game::night::{
@ -108,6 +109,19 @@ impl Night {
}; };
match current_prompt { match current_prompt {
ActionPrompt::Bloodletter {
character_id,
living_players,
marked: Some(marked),
} => Ok(ActionComplete {
result: ActionResult::GoBackToSleep,
change: Some(NightChange::ApplyAura {
source: character_id.character_id,
aura: Aura::Bloodlet { night: self.night },
target: *marked,
}),
}
.into()),
ActionPrompt::LoneWolfKill { ActionPrompt::LoneWolfKill {
character_id, character_id,
marked: Some(marked), marked: Some(marked),
@ -143,7 +157,7 @@ impl Night {
marked: Some(marked), marked: Some(marked),
.. ..
} => { } => {
let alignment = self.village.character_by_id(*marked)?.alignment(); let alignment = self.character_with_current_auras(*marked)?.alignment();
Ok(ResponseOutcome::ActionComplete(ActionComplete { Ok(ResponseOutcome::ActionComplete(ActionComplete {
result: ActionResult::Seer(alignment), result: ActionResult::Seer(alignment),
change: None, change: None,
@ -166,8 +180,8 @@ impl Night {
marked: (Some(marked1), Some(marked2)), marked: (Some(marked1), Some(marked2)),
.. ..
} => { } => {
let same = self.village.character_by_id(*marked1)?.alignment() let same = self.character_with_current_auras(*marked1)?.alignment()
== self.village.character_by_id(*marked2)?.alignment(); == self.character_with_current_auras(*marked2)?.alignment();
Ok(ResponseOutcome::ActionComplete(ActionComplete { Ok(ResponseOutcome::ActionComplete(ActionComplete {
result: ActionResult::Arcanist(AlignmentEq::new(same)), result: ActionResult::Arcanist(AlignmentEq::new(same)),
@ -178,7 +192,9 @@ impl Night {
marked: Some(marked), marked: Some(marked),
.. ..
} => { } => {
let dig_role = self.village.character_by_id(*marked)?.gravedigger_dig(); let dig_role = self
.character_with_current_auras(*marked)?
.gravedigger_dig();
Ok(ResponseOutcome::ActionComplete(ActionComplete { Ok(ResponseOutcome::ActionComplete(ActionComplete {
result: ActionResult::GraveDigger(dig_role), result: ActionResult::GraveDigger(dig_role),
change: None, change: None,
@ -359,7 +375,7 @@ impl Night {
.. ..
} => Ok(ActionComplete { } => Ok(ActionComplete {
result: ActionResult::Adjudicator { result: ActionResult::Adjudicator {
killer: self.village.character_by_id(*marked)?.killer(), killer: self.character_with_current_auras(*marked)?.killer(),
}, },
change: None, change: None,
} }
@ -369,7 +385,7 @@ impl Night {
.. ..
} => Ok(ActionComplete { } => Ok(ActionComplete {
result: ActionResult::PowerSeer { result: ActionResult::PowerSeer {
powerful: self.village.character_by_id(*marked)?.powerful(), powerful: self.character_with_current_auras(*marked)?.powerful(),
}, },
change: None, change: None,
} }
@ -489,7 +505,8 @@ impl Night {
} }
.into()), .into()),
ActionPrompt::Adjudicator { marked: None, .. } ActionPrompt::Bloodletter { marked: None, .. }
| ActionPrompt::Adjudicator { marked: None, .. }
| ActionPrompt::PowerSeer { marked: None, .. } | ActionPrompt::PowerSeer { marked: None, .. }
| ActionPrompt::Mortician { marked: None, .. } | ActionPrompt::Mortician { marked: None, .. }
| ActionPrompt::Beholder { marked: None, .. } | ActionPrompt::Beholder { marked: None, .. }

View File

@ -23,10 +23,10 @@ use uuid::Uuid;
use werewolves_macros::{All, ChecksAs, Titles}; use werewolves_macros::{All, ChecksAs, Titles};
use crate::{ use crate::{
aura::Aura,
character::Character, character::Character,
error::GameError, error::GameError,
message::Identification, message::Identification,
modifier::Modifier,
player::PlayerId, player::PlayerId,
role::{Role, RoleTitle}, role::{Role, RoleTitle},
}; };
@ -127,6 +127,8 @@ pub enum SetupRole {
Shapeshifter, Shapeshifter,
#[checks(Category::Wolves)] #[checks(Category::Wolves)]
LoneWolf, LoneWolf,
#[checks(Category::Wolves)]
Bloodletter,
#[checks(Category::Intel)] #[checks(Category::Intel)]
Adjudicator, Adjudicator,
@ -157,6 +159,7 @@ pub enum SetupRole {
impl SetupRoleTitle { impl SetupRoleTitle {
pub fn into_role(self) -> Role { pub fn into_role(self) -> Role {
match self { match self {
SetupRoleTitle::Bloodletter => Role::Bloodletter,
SetupRoleTitle::Insomniac => Role::Insomniac, SetupRoleTitle::Insomniac => Role::Insomniac,
SetupRoleTitle::LoneWolf => Role::LoneWolf, SetupRoleTitle::LoneWolf => Role::LoneWolf,
SetupRoleTitle::Villager => Role::Villager, SetupRoleTitle::Villager => Role::Villager,
@ -208,6 +211,7 @@ impl SetupRoleTitle {
impl Display for SetupRole { impl Display for SetupRole {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self { f.write_str(match self {
SetupRole::Bloodletter => "Bloodletter",
SetupRole::Insomniac => "Insomniac", SetupRole::Insomniac => "Insomniac",
SetupRole::LoneWolf => "Lone Wolf", SetupRole::LoneWolf => "Lone Wolf",
SetupRole::Villager => "Villager", SetupRole::Villager => "Villager",
@ -244,6 +248,7 @@ impl Display for SetupRole {
impl SetupRole { impl SetupRole {
pub fn into_role(self, roles_in_game: &[RoleTitle]) -> Result<Role, GameError> { pub fn into_role(self, roles_in_game: &[RoleTitle]) -> Result<Role, GameError> {
Ok(match self { Ok(match self {
Self::Bloodletter => Role::Bloodletter,
SetupRole::Insomniac => Role::Insomniac, SetupRole::Insomniac => Role::Insomniac,
SetupRole::LoneWolf => Role::LoneWolf, SetupRole::LoneWolf => Role::LoneWolf,
SetupRole::Villager => Role::Villager, SetupRole::Villager => Role::Villager,
@ -321,6 +326,7 @@ impl From<SetupRole> for RoleTitle {
impl From<RoleTitle> for SetupRole { impl From<RoleTitle> for SetupRole {
fn from(value: RoleTitle) -> Self { fn from(value: RoleTitle) -> Self {
match value { match value {
RoleTitle::Bloodletter => SetupRole::Bloodletter,
RoleTitle::Insomniac => SetupRole::Insomniac, RoleTitle::Insomniac => SetupRole::Insomniac,
RoleTitle::LoneWolf => SetupRole::LoneWolf, RoleTitle::LoneWolf => SetupRole::LoneWolf,
RoleTitle::Villager => SetupRole::Villager, RoleTitle::Villager => SetupRole::Villager,
@ -373,7 +379,7 @@ impl SlotId {
pub struct SetupSlot { pub struct SetupSlot {
pub slot_id: SlotId, pub slot_id: SlotId,
pub role: SetupRole, pub role: SetupRole,
pub modifiers: Vec<Modifier>, pub auras: Vec<Aura>,
pub assign_to: Option<PlayerId>, pub assign_to: Option<PlayerId>,
pub created_order: u32, pub created_order: u32,
} }
@ -384,7 +390,7 @@ impl SetupSlot {
created_order, created_order,
assign_to: None, assign_to: None,
role: title.into(), role: title.into(),
modifiers: Vec::new(), auras: Vec::new(),
slot_id: SlotId::new(), slot_id: SlotId::new(),
} }
} }
@ -394,7 +400,11 @@ impl SetupSlot {
ident: Identification, ident: Identification,
roles_in_game: &[RoleTitle], roles_in_game: &[RoleTitle],
) -> Result<Character, GameError> { ) -> Result<Character, GameError> {
Character::new(ident.clone(), self.role.into_role(roles_in_game)?) Character::new(
ident.clone(),
self.role.into_role(roles_in_game)?,
self.auras,
)
.ok_or(GameError::PlayerNotAssignedNumber(ident.to_string())) .ok_or(GameError::PlayerNotAssignedNumber(ident.to_string()))
} }
} }

View File

@ -199,11 +199,23 @@ pub enum StoryActionPrompt {
Insomniac { Insomniac {
character_id: CharacterId, character_id: CharacterId,
}, },
Bloodletter {
character_id: CharacterId,
chosen: CharacterId,
},
} }
impl StoryActionPrompt { impl StoryActionPrompt {
pub fn new(prompt: ActionPrompt) -> Option<Self> { pub fn new(prompt: ActionPrompt) -> Option<Self> {
Some(match prompt { Some(match prompt {
ActionPrompt::Bloodletter {
character_id,
marked: Some(marked),
..
} => Self::Bloodletter {
character_id: character_id.character_id,
chosen: marked,
},
ActionPrompt::Seer { ActionPrompt::Seer {
character_id, character_id,
marked: Some(marked), marked: Some(marked),
@ -378,7 +390,8 @@ impl StoryActionPrompt {
character_id: character_id.character_id, character_id: character_id.character_id,
}, },
ActionPrompt::Protector { .. } ActionPrompt::Bloodletter { .. }
| ActionPrompt::Protector { .. }
| ActionPrompt::Gravedigger { .. } | ActionPrompt::Gravedigger { .. }
| ActionPrompt::Hunter { .. } | ActionPrompt::Hunter { .. }
| ActionPrompt::Militia { .. } | ActionPrompt::Militia { .. }

View File

@ -20,6 +20,7 @@ use serde::{Deserialize, Serialize};
use super::Result; use super::Result;
use crate::{ use crate::{
aura::Aura,
character::{Character, CharacterId}, character::{Character, CharacterId},
diedto::DiedTo, diedto::DiedTo,
error::GameError, error::GameError,
@ -294,6 +295,7 @@ impl Village {
impl RoleTitle { impl RoleTitle {
pub fn title_to_role_excl_apprentice(self) -> Role { pub fn title_to_role_excl_apprentice(self) -> Role {
match self { match self {
RoleTitle::Bloodletter => Role::Bloodletter,
RoleTitle::Insomniac => Role::Insomniac, RoleTitle::Insomniac => Role::Insomniac,
RoleTitle::LoneWolf => Role::LoneWolf, RoleTitle::LoneWolf => Role::LoneWolf,
RoleTitle::Villager => Role::Villager, RoleTitle::Villager => Role::Villager,

View File

@ -15,6 +15,7 @@
use core::num::NonZeroU8; use core::num::NonZeroU8;
use crate::{ use crate::{
aura::Aura,
diedto::DiedTo, diedto::DiedTo,
error::GameError, error::GameError,
game::{ game::{
@ -52,6 +53,10 @@ impl Village {
let mut new_village = self.clone(); let mut new_village = self.clone();
for change in all_changes { for change in all_changes {
match change { match change {
NightChange::ApplyAura { target, aura, .. } => {
let target = new_village.character_by_id_mut(*target)?;
target.apply_aura(*aura);
}
NightChange::ElderReveal { elder } => { NightChange::ElderReveal { elder } => {
new_village.character_by_id_mut(*elder)?.elder_reveal() new_village.character_by_id_mut(*elder)?.elder_reveal()
} }
@ -125,7 +130,6 @@ impl Village {
.replace(*target); .replace(*target);
} }
NightChange::Protection { .. } => {}
NightChange::MasonRecruit { NightChange::MasonRecruit {
mason_leader, mason_leader,
recruiting, recruiting,
@ -150,6 +154,12 @@ impl Village {
.role_change(RoleTitle::Villager, GameTime::Night { number: night })?; .role_change(RoleTitle::Villager, GameTime::Night { number: night })?;
*new_village.character_by_id_mut(*empath)?.empath_mut()? = true; *new_village.character_by_id_mut(*empath)?.empath_mut()? = true;
} }
NightChange::LostAura { character, aura } => {
new_village
.character_by_id_mut(*character)?
.remove_aura(*aura);
}
NightChange::Protection { .. } => {}
} }
} }
// black knights death // black knights death

View File

@ -84,9 +84,13 @@ pub trait ActionPromptTitleExt {
fn power_seer(&self); fn power_seer(&self);
fn mortician(&self); fn mortician(&self);
fn elder_reveal(&self); fn elder_reveal(&self);
fn bloodletter(&self);
} }
impl ActionPromptTitleExt for ActionPromptTitle { impl ActionPromptTitleExt for ActionPromptTitle {
fn bloodletter(&self) {
assert_eq!(*self, ActionPromptTitle::Bloodletter);
}
fn elder_reveal(&self) { fn elder_reveal(&self) {
assert_eq!(*self, ActionPromptTitle::ElderReveal); assert_eq!(*self, ActionPromptTitle::ElderReveal);
} }
@ -402,7 +406,11 @@ impl GameExt for Game {
| ActionPrompt::Shapeshifter { .. } => panic!("expected a prompt with a mark"), | ActionPrompt::Shapeshifter { .. } => panic!("expected a prompt with a mark"),
ActionPrompt::Arcanist { .. } => panic!("wrong call for arcanist"), ActionPrompt::Arcanist { .. } => panic!("wrong call for arcanist"),
ActionPrompt::LoneWolfKill { ActionPrompt::Bloodletter {
marked: Some(marked),
..
}
| ActionPrompt::LoneWolfKill {
marked: Some(marked), marked: Some(marked),
.. ..
} }
@ -478,7 +486,8 @@ impl GameExt for Game {
marked: Some(marked), marked: Some(marked),
.. ..
} => assert_eq!(marked, mark, "marked character"), } => assert_eq!(marked, mark, "marked character"),
ActionPrompt::Seer { marked: None, .. } ActionPrompt::Bloodletter { marked: None, .. }
| ActionPrompt::Seer { marked: None, .. }
| ActionPrompt::Adjudicator { marked: None, .. } | ActionPrompt::Adjudicator { marked: None, .. }
| ActionPrompt::PowerSeer { marked: None, .. } | ActionPrompt::PowerSeer { marked: None, .. }
| ActionPrompt::Mortician { marked: None, .. } | ActionPrompt::Mortician { marked: None, .. }

View File

@ -0,0 +1,132 @@
// 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::{
aura::Aura,
game::{Game, GameSettings, SetupRole},
game_test::{
ActionPromptTitleExt, ActionResultExt, GameExt, SettingsExt, gen_players, init_log,
},
message::night::ActionPromptTitle,
role::{Alignment, Killer, Powerful},
};
#[test]
fn lasts_2_nights() {
init_log();
let players = gen_players(1..10);
let mut player_ids = players.iter().map(|p| p.player_id);
let target = player_ids.next().unwrap();
let seer = player_ids.next().unwrap();
let adjudicator = player_ids.next().unwrap();
let power_seer = player_ids.next().unwrap();
let wolf = player_ids.next().unwrap();
let bloodletter = player_ids.next().unwrap();
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::Villager, target);
settings.add_and_assign(SetupRole::Seer, seer);
settings.add_and_assign(SetupRole::Adjudicator, adjudicator);
settings.add_and_assign(SetupRole::PowerSeer, power_seer);
settings.add_and_assign(SetupRole::Werewolf, wolf);
settings.add_and_assign(SetupRole::Bloodletter, bloodletter);
settings.fill_remaining_slots_with_villagers(9);
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().bloodletter();
game.mark(game.character_by_player_id(target).character_id());
game.r#continue().sleep();
game.next().title().seer();
game.mark(game.character_by_player_id(target).character_id());
assert_eq!(game.r#continue().seer(), Alignment::Wolves);
game.r#continue().sleep();
game.next().title().adjudicator();
game.mark(game.character_by_player_id(target).character_id());
assert_eq!(game.r#continue().adjudicator(), Killer::Killer);
game.r#continue().sleep();
game.next().title().power_seer();
game.mark(game.character_by_player_id(target).character_id());
assert_eq!(game.r#continue().power_seer(), Powerful::Powerful);
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(
game.character_by_player_id(target).auras(),
&[Aura::Bloodlet { night: 0 }]
);
game.execute().title().wolf_pack_kill();
game.mark(game.living_villager_excl(target).character_id());
game.r#continue().r#continue();
game.next().title().bloodletter();
game.mark(game.character_by_player_id(seer).character_id());
game.r#continue().sleep();
game.next().title().seer();
game.mark(game.character_by_player_id(target).character_id());
assert_eq!(game.r#continue().seer(), Alignment::Wolves);
game.r#continue().sleep();
game.next().title().adjudicator();
game.mark(game.character_by_player_id(target).character_id());
assert_eq!(game.r#continue().adjudicator(), Killer::Killer);
game.r#continue().sleep();
game.next().title().power_seer();
game.mark(game.character_by_player_id(target).character_id());
assert_eq!(game.r#continue().power_seer(), Powerful::Powerful);
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(
game.character_by_player_id(target).auras(),
&[Aura::Bloodlet { night: 0 }]
);
game.mark_for_execution(game.character_by_player_id(bloodletter).character_id());
game.execute().title().wolf_pack_kill();
game.mark(game.living_villager_excl(target).character_id());
game.r#continue().sleep();
game.next().title().seer();
game.mark(game.character_by_player_id(target).character_id());
assert_eq!(game.r#continue().seer(), Alignment::Village);
game.r#continue().sleep();
game.next().title().adjudicator();
game.mark(game.character_by_player_id(target).character_id());
assert_eq!(game.r#continue().adjudicator(), Killer::NotKiller);
game.r#continue().sleep();
game.next().title().power_seer();
game.mark(game.character_by_player_id(target).character_id());
assert_eq!(game.r#continue().power_seer(), Powerful::NotPowerful);
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(game.character_by_player_id(target).auras(), &[]);
}

View File

@ -15,6 +15,7 @@
mod apprentice; mod apprentice;
mod beholder; mod beholder;
mod black_knight; mod black_knight;
mod bloodletter;
mod diseased; mod diseased;
mod elder; mod elder;
mod empath; mod empath;

View File

@ -14,6 +14,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
#![allow(clippy::new_without_default)] #![allow(clippy::new_without_default)]
pub mod aura;
pub mod character; pub mod character;
pub mod diedto; pub mod diedto;
pub mod error; pub mod error;
@ -21,7 +22,7 @@ pub mod game;
#[cfg(test)] #[cfg(test)]
mod game_test; mod game_test;
pub mod message; pub mod message;
pub mod modifier;
pub mod nonzero; pub mod nonzero;
pub mod player; pub mod player;
pub mod role; pub mod role;
pub mod team;

View File

@ -203,6 +203,12 @@ pub enum ActionPrompt {
}, },
#[checks(ActionType::Insomniac)] #[checks(ActionType::Insomniac)]
Insomniac { character_id: CharacterIdentity }, Insomniac { character_id: CharacterIdentity },
#[checks(ActionType::OtherWolf)]
Bloodletter {
character_id: CharacterIdentity,
living_players: Box<[CharacterIdentity]>,
marked: Option<CharacterId>,
},
} }
impl ActionPrompt { impl ActionPrompt {
@ -230,6 +236,7 @@ impl ActionPrompt {
| ActionPrompt::Empath { character_id, .. } | ActionPrompt::Empath { character_id, .. }
| ActionPrompt::Vindicator { character_id, .. } | ActionPrompt::Vindicator { character_id, .. }
| ActionPrompt::PyreMaster { character_id, .. } | ActionPrompt::PyreMaster { character_id, .. }
| ActionPrompt::Bloodletter { character_id, .. }
| ActionPrompt::DireWolf { character_id, .. } => Some(character_id.character_id), | ActionPrompt::DireWolf { character_id, .. } => Some(character_id.character_id),
ActionPrompt::WolvesIntro { .. } ActionPrompt::WolvesIntro { .. }
@ -241,6 +248,7 @@ impl ActionPrompt {
pub(crate) fn matches_beholding(&self, target: CharacterId) -> bool { pub(crate) fn matches_beholding(&self, target: CharacterId) -> bool {
match self { match self {
ActionPrompt::Insomniac { character_id, .. } ActionPrompt::Insomniac { character_id, .. }
| ActionPrompt::Bloodletter { character_id, .. }
| ActionPrompt::Seer { character_id, .. } | ActionPrompt::Seer { character_id, .. }
| ActionPrompt::Arcanist { character_id, .. } | ActionPrompt::Arcanist { character_id, .. }
| ActionPrompt::Gravedigger { character_id, .. } | ActionPrompt::Gravedigger { character_id, .. }
@ -344,7 +352,12 @@ impl ActionPrompt {
Ok(prompt) Ok(prompt)
} }
ActionPrompt::LoneWolfKill { ActionPrompt::Bloodletter {
living_players: targets,
marked,
..
}
| ActionPrompt::LoneWolfKill {
living_players: targets, living_players: targets,
marked, marked,
.. ..

View File

@ -271,6 +271,11 @@ pub enum Role {
#[checks(Powerful::Powerful)] #[checks(Powerful::Powerful)]
#[checks("wolf")] #[checks("wolf")]
LoneWolf, LoneWolf,
#[checks(Alignment::Wolves)]
#[checks(Killer::Killer)]
#[checks(Powerful::Powerful)]
#[checks("wolf")]
Bloodletter,
} }
impl Role { impl Role {
@ -313,6 +318,7 @@ impl Role {
Role::Werewolf => KillingWolfOrder::Werewolf, Role::Werewolf => KillingWolfOrder::Werewolf,
Role::AlphaWolf { .. } => KillingWolfOrder::AlphaWolf, Role::AlphaWolf { .. } => KillingWolfOrder::AlphaWolf,
Role::Bloodletter { .. } => KillingWolfOrder::Bloodletter,
Role::DireWolf { .. } => KillingWolfOrder::DireWolf, Role::DireWolf { .. } => KillingWolfOrder::DireWolf,
Role::Shapeshifter { .. } => KillingWolfOrder::Shapeshifter, Role::Shapeshifter { .. } => KillingWolfOrder::Shapeshifter,
Role::LoneWolf => KillingWolfOrder::LoneWolf, Role::LoneWolf => KillingWolfOrder::LoneWolf,
@ -325,7 +331,7 @@ impl Role {
| Role::Adjudicator | Role::Adjudicator
| Role::DireWolf { .. } | Role::DireWolf { .. }
| Role::Arcanist | Role::Arcanist
| Role::Seer => true, | Role::Seer | Role::Bloodletter => true,
Role::Insomniac // has to at least get one good night of sleep, right? Role::Insomniac // has to at least get one good night of sleep, right?
| Role::Beholder | Role::Beholder
@ -400,6 +406,7 @@ impl Role {
| Role::Militia { targeted: None } | Role::Militia { targeted: None }
| Role::MapleWolf { .. } | Role::MapleWolf { .. }
| Role::Guardian { .. } | Role::Guardian { .. }
| Role::Bloodletter
| Role::Seer => true, | Role::Seer => true,
Role::Apprentice(title) => village Role::Apprentice(title) => village
@ -444,6 +451,7 @@ impl RoleTitle {
pub enum Alignment { pub enum Alignment {
Village, Village,
Wolves, Wolves,
Traitor,
} }
impl Alignment { impl Alignment {
@ -461,6 +469,7 @@ impl Display for Alignment {
match self { match self {
Alignment::Village => f.write_str("Village"), Alignment::Village => f.write_str("Village"),
Alignment::Wolves => f.write_str("Wolves"), Alignment::Wolves => f.write_str("Wolves"),
Alignment::Traitor => f.write_str("Damned"),
} }
} }
} }

View File

@ -1,3 +1,6 @@
use serde::{Deserialize, Serialize};
use werewolves_macros::ChecksAs;
// Copyright (C) 2025 Emilis Bliūdžius // Copyright (C) 2025 Emilis Bliūdžius
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
@ -12,10 +15,10 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ChecksAs)]
pub enum Team {
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] Village,
pub enum Modifier { #[checks("evil")]
Drunk, Wolves,
Insane, AnyEvil,
} }

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64.542282mm"
height="80.020309mm"
viewBox="0 0 64.542282 80.020309"
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(-501.45474,-289.80731)"><g
id="g178-3"
transform="translate(19.691291,95.898306)"
style="fill:#ff0707;fill-opacity:0.694874"><path
id="path175-7-0-7-6"
style="fill:#ff0707;fill-opacity:0.694874;stroke:#c10000;stroke-width:1.99668;stroke-dasharray:none;stroke-opacity:1"
d="m 513.95892,195.94614 c -3.10481,5.52989 -13.31235,25.09794 -14.05905,44.58016 -0.40935,10.68044 10.72122,10.85122 14.09046,10.65924 3.36947,0.18812 14.50014,0.005 14.07863,-10.67474 -0.76889,-19.48135 -10.99894,-39.03835 -14.11004,-44.56466 z" /><g
id="g177-5"
style="fill:#ff0707;fill-opacity:0.694874"><path
id="path175-7-0-4-6"
style="fill:#ff0707;fill-opacity:0.694874;stroke:#c10000;stroke-width:2;stroke-dasharray:none;stroke-opacity:1"
d="m 496.88096,217.65547 c -3.11544,5.52938 -13.3579,25.09565 -14.10715,44.57609 -0.41075,10.67947 10.75789,10.85023 14.13867,10.65827 3.381,0.1881 14.54975,0.005 14.12679,-10.67377 -0.77152,-19.47957 -11.03656,-39.03478 -14.15831,-44.56059 z" /><path
id="path175-7-0-9-3"
style="fill:#ff0707;fill-opacity:0.694874;stroke:#c10000;stroke-width:2;stroke-dasharray:none;stroke-opacity:1"
d="m 531.13647,217.65547 c -3.11544,5.52938 -13.3579,25.09565 -14.10715,44.57609 -0.41075,10.67947 10.75789,10.85023 14.13867,10.65827 3.381,0.1881 14.54975,0.005 14.12679,-10.67377 -0.77152,-19.47957 -11.03656,-39.03478 -14.15831,-44.56059 z" /></g></g></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="63.999481mm"
height="58.622898mm"
viewBox="0 0 63.999481 58.622898"
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(-494.49485,-99.66878)"><g
id="g174"><path
id="path31-2"
style="fill:#d77fff;fill-opacity:1;stroke:#a300eb;stroke-width:1.561"
d="m 497.38435,113.0436 a 29.821014,23.755724 75 0 0 -0.8444,21.15361 29.821014,23.755724 75 0 0 30.66443,22.65638 l -2.71657,-10.13837 a 16.30047,20.596718 75 0 0 -9.05328,5.10293 9.0979366,10.867 75 0 1 7.93756,-9.26688 l -8.20534,-30.62273 a 14.278707,28.051973 75 0 1 -17.7824,1.11506 z m 9.24862,11.76284 a 9.3881665,4.5422098 75 0 1 5.81712,5.06988 16.820465,8.6090562 75 0 0 -4.41462,-0.85597 16.820465,8.6090562 75 0 0 -3.81591,3.81033 9.3881665,4.5422098 75 0 1 2.41341,-8.02424 z" /><path
id="path32-7"
style="fill:#d77fff;fill-opacity:1;stroke:#a300eb;stroke-width:1.561"
d="m 555.88352,102.49759 a 28.051973,14.278707 15 0 1 -17.08676,-1.58883 28.051973,14.278707 15 0 1 -0.97402,-0.27031 l -8.92704,33.31616 a 20.596718,16.30047 15 0 0 0.34214,0.0948 20.596718,16.30047 15 0 0 9.83564,0.65972 10.867,9.0979366 15 0 1 -11.28974,3.39538 l -1.99873,7.45939 a 23.755724,29.821014 15 0 0 30.66441,-22.65638 23.755724,29.821014 15 0 0 -0.56609,-20.40922 z m -9.43994,11.04123 a 4.5422098,9.3881665 15 0 1 2.50283,7.2992 8.6090562,16.820465 15 0 0 -3.39532,-2.9481 8.6090562,16.820465 15 0 0 -5.21033,1.39175 4.5422098,9.3881665 15 0 1 6.10282,-5.74285 z" /></g></g></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -773,7 +773,6 @@ clients {
} }
} }
.sp-ace { .sp-ace {
margin: 10px; margin: 10px;
} }
@ -1149,6 +1148,13 @@ input {
width: 48px; width: 48px;
} }
.icon-fit {
height: 1em;
}
.icon-15pct {
width: 15%;
}
.village { .village {
background-color: $village_color; background-color: $village_color;
@ -1762,12 +1768,6 @@ input {
font-size: 4vw; font-size: 4vw;
} }
.information {
font-size: 1.2rem;
padding-left: 5%;
padding-right: 5%;
}
.yellow { .yellow {
color: yellow; color: yellow;
} }
@ -1809,10 +1809,20 @@ input {
grid-template-columns: 3fr 2fr; grid-template-columns: 3fr 2fr;
} }
.seer-check {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
zoom: 90%;
}
.role-title-span { .role-title-span {
display: grid; // display: grid;
grid-template-columns: 1fr 100fr; // grid-template-columns: 1fr 100fr;
max-height: 2rem; display: flex;
flex-direction: row;
flex-wrap: nowrap;
height: 2rem;
width: fit-content; width: fit-content;
@ -1820,11 +1830,18 @@ input {
padding-bottom: 5px; padding-bottom: 5px;
padding-left: 5px; padding-left: 5px;
padding-right: 10px; padding-right: 10px;
width: 100%;
align-items: center;
img { img {
vertical-align: text-bottom; vertical-align: text-bottom;
max-height: 2rem; max-height: 2rem;
padding-left: 10px; padding-left: 10px;
flex-shrink: 1;
}
span {
flex-grow: 1;
} }
text-align: center; text-align: center;
@ -1834,6 +1851,7 @@ input {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-wrap: nowrap; flex-wrap: nowrap;
font-weight: bold;
gap: 10px; gap: 10px;
} }
@ -1897,3 +1915,34 @@ input {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.inline-icons {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
width: 100%;
}
:root.big-screen {
--information-height: 75vh;
}
:root:not(.big-screen) {
--information-height: auto;
}
.information {
font-size: 1.0rem;
padding-left: 5%;
padding-right: 5%;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
justify-content: center;
height: var(--information-height);
}

View File

@ -831,7 +831,11 @@ impl GameExt for Game {
| ActionPrompt::Shapeshifter { .. } => panic!("expected a prompt with a mark"), | ActionPrompt::Shapeshifter { .. } => panic!("expected a prompt with a mark"),
ActionPrompt::Arcanist { .. } => panic!("wrong call for arcanist"), ActionPrompt::Arcanist { .. } => panic!("wrong call for arcanist"),
ActionPrompt::LoneWolfKill { ActionPrompt::Bloodletter {
marked: Some(marked),
..
}
| ActionPrompt::LoneWolfKill {
marked: Some(marked), marked: Some(marked),
.. ..
} }
@ -907,7 +911,8 @@ impl GameExt for Game {
marked: Some(marked), marked: Some(marked),
.. ..
} => assert_eq!(marked, mark, "marked character"), } => assert_eq!(marked, mark, "marked character"),
ActionPrompt::Seer { marked: None, .. } ActionPrompt::Bloodletter { marked: None, .. }
| ActionPrompt::Seer { marked: None, .. }
| ActionPrompt::Adjudicator { marked: None, .. } | ActionPrompt::Adjudicator { marked: None, .. }
| ActionPrompt::PowerSeer { marked: None, .. } | ActionPrompt::PowerSeer { marked: None, .. }
| ActionPrompt::Mortician { marked: None, .. } | ActionPrompt::Mortician { marked: None, .. }

View File

@ -188,6 +188,16 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
}; };
} }
ActionPrompt::Bloodletter {
character_id,
living_players,
marked,
} => (
Some(character_id),
living_players,
marked.iter().cloned().collect::<Box<[CharacterId]>>(),
html! {{"bloodletter"}},
),
ActionPrompt::LoneWolfKill { ActionPrompt::LoneWolfKill {
character_id, character_id,
living_players, living_players,

View File

@ -31,6 +31,7 @@ pub fn AlignmentSpan(AlignmentSpanProps { alignment }: &AlignmentSpanProps) -> H
let class = match alignment { let class = match alignment {
role::Alignment::Village => "village", role::Alignment::Village => "village",
role::Alignment::Wolves => "wolves", role::Alignment::Wolves => "wolves",
role::Alignment::Traitor => "traitor",
}; };
html! { html! {
<span class={classes!("attribute-span", "faint", class)}> <span class={classes!("attribute-span", "faint", class)}>
@ -81,16 +82,18 @@ pub fn CategorySpan(
#[derive(Debug, Clone, Copy, PartialEq, Properties)] #[derive(Debug, Clone, Copy, PartialEq, Properties)]
pub struct RoleTitleSpanProps { pub struct RoleTitleSpanProps {
pub role: RoleTitle, pub role: RoleTitle,
#[prop_or(IconType::List)]
pub icon_type: IconType,
} }
#[function_component] #[function_component]
pub fn RoleTitleSpan(RoleTitleSpanProps { role }: &RoleTitleSpanProps) -> Html { pub fn RoleTitleSpan(RoleTitleSpanProps { role, icon_type }: &RoleTitleSpanProps) -> Html {
let class = Into::<SetupRole>::into(*role).category().class(); let class = Into::<SetupRole>::into(*role).category().class();
let icon = role.icon().unwrap_or(role.alignment().icon()); let icon = role.icon().unwrap_or(role.alignment().icon());
let text = role.to_string().to_case(Case::Title); let text = role.to_string().to_case(Case::Title);
html! { html! {
<span class={classes!("role-title-span", "faint", class)}> <span class={classes!("role-title-span", "faint", class)}>
<Icon source={icon} icon_type={IconType::List}/> <Icon source={icon} icon_type={*icon_type}/>
<span>{text}</span> <span>{text}</span>
</span> </span>
} }

View File

@ -0,0 +1,51 @@
// 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 werewolves_proto::aura;
use yew::prelude::*;
use crate::components::{Icon, IconType, PartialAssociatedIcon};
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct AuraProps {
pub aura: aura::Aura,
}
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",
})
}
#[function_component]
pub fn Aura(AuraProps { aura }: &AuraProps) -> Html {
let class = aura_class(aura);
let icon = aura.icon().map(|icon| {
html! {
<div>
<Icon source={icon} icon_type={IconType::Small}/>
</div>
}
});
html! {
<span class={classes!("attribute-span", "faint", class)}>
{icon}
{aura.to_string()}
</span>
}
}

View File

@ -23,6 +23,8 @@ use werewolves_proto::{
}; };
use yew::prelude::*; use yew::prelude::*;
use crate::components::{AssociatedIcon, Icon, IconSource, IconType};
#[derive(Debug, Clone, PartialEq, Properties)] #[derive(Debug, Clone, PartialEq, Properties)]
pub struct SetupProps { pub struct SetupProps {
pub settings: GameSettings, pub settings: GameSettings,
@ -140,10 +142,7 @@ pub fn SetupCategory(
}); });
let killer_inactive = as_role.killer().killer().not().then_some("inactive"); let killer_inactive = as_role.killer().killer().not().then_some("inactive");
let powerful_inactive = as_role.powerful().powerful().not().then_some("inactive"); let powerful_inactive = as_role.powerful().powerful().not().then_some("inactive");
let alignment = match as_role.alignment() { let alignment = as_role.alignment().icon();
Alignment::Village => "/img/village.svg",
Alignment::Wolves => "/img/wolf.svg",
};
html! { html! {
<div class={classes!("slot")}> <div class={classes!("slot")}>
{count} {count}
@ -152,13 +151,16 @@ pub fn SetupCategory(
</div> </div>
<div class="attributes"> <div class="attributes">
<div class="alignment"> <div class="alignment">
<img class="icon" src={alignment} alt={"alignment"}/> // <img class="icon" src={alignment} alt={"alignment"}/>
<Icon source={alignment} icon_type={IconType::Small}/>
</div> </div>
<div class={classes!("killer", killer_inactive)}> <div class={classes!("killer", killer_inactive)}>
<img class="icon" src="/img/killer.svg" alt="killer icon"/> <Icon source={IconSource::Killer} icon_type={IconType::Small}/>
// <img class="icon" src="/img/killer.svg" alt="killer icon"/>
</div> </div>
<div class={classes!("poweful", powerful_inactive)}> <div class={classes!("poweful", powerful_inactive)}>
<img class="icon" src="/img/powerful.svg" alt="powerful icon"/> <Icon source={IconSource::Powerful} icon_type={IconType::Small}/>
// <img class="icon" src="/img/powerful.svg" alt="powerful icon"/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -13,6 +13,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
use werewolves_proto::{ use werewolves_proto::{
aura::Aura,
diedto::DiedToTitle, diedto::DiedToTitle,
role::{Alignment, Killer, Powerful, RoleTitle}, role::{Alignment, Killer, Powerful, RoleTitle},
}; };
@ -76,6 +77,8 @@ decl_icon!(
NotEqual: "/img/not-equal.svg", NotEqual: "/img/not-equal.svg",
Equal: "/img/equal.svg", Equal: "/img/equal.svg",
RedX: "/img/red-x.svg", RedX: "/img/red-x.svg",
Traitor: "/img/traitor.svg",
Bloodlet: "/img/bloodlet.svg",
); );
impl IconSource { impl IconSource {
@ -93,6 +96,8 @@ pub enum IconType {
List, List,
Small, Small,
RoleAdd, RoleAdd,
Fit,
Icon15Pct,
Informational, Informational,
#[default] #[default]
RoleCheck, RoleCheck,
@ -101,6 +106,8 @@ pub enum IconType {
impl IconType { impl IconType {
pub const fn class(&self) -> &'static str { pub const fn class(&self) -> &'static str {
match self { match self {
IconType::Icon15Pct => "icon-15pct",
IconType::Fit => "icon-fit",
IconType::List => "icon-in-list", IconType::List => "icon-in-list",
IconType::Small => "icon", IconType::Small => "icon",
IconType::RoleAdd => "icon-role-add", IconType::RoleAdd => "icon-role-add",
@ -147,6 +154,7 @@ impl AssociatedIcon for Alignment {
match self { match self {
Alignment::Village => IconSource::Village, Alignment::Village => IconSource::Village,
Alignment::Wolves => IconSource::Wolves, Alignment::Wolves => IconSource::Wolves,
Alignment::Traitor => IconSource::Traitor,
} }
} }
} }
@ -168,6 +176,7 @@ impl PartialAssociatedIcon for RoleTitle {
Some(match self { Some(match self {
RoleTitle::AlphaWolf | RoleTitle::DireWolf => return None, RoleTitle::AlphaWolf | RoleTitle::DireWolf => return None,
RoleTitle::Bloodletter => IconSource::Bloodlet,
RoleTitle::MasonLeader => IconSource::Mason, RoleTitle::MasonLeader => IconSource::Mason,
RoleTitle::BlackKnight => IconSource::BlackKnight, RoleTitle::BlackKnight => IconSource::BlackKnight,
RoleTitle::Insomniac => IconSource::Insomniac, RoleTitle::Insomniac => IconSource::Insomniac,
@ -217,3 +226,14 @@ impl PartialAssociatedIcon for DiedToTitle {
} }
} }
} }
impl PartialAssociatedIcon for Aura {
fn icon(&self) -> Option<IconSource> {
match self {
Aura::Traitor => Some(IconSource::Traitor),
Aura::Drunk => todo!(),
Aura::Insane => todo!(),
Aura::Bloodlet { .. } => Some(IconSource::Bloodlet),
}
}
}

View File

@ -177,6 +177,25 @@ struct StoryNightChangeProps {
#[function_component] #[function_component]
fn StoryNightChange(StoryNightChangeProps { change, characters }: &StoryNightChangeProps) -> Html { fn StoryNightChange(StoryNightChangeProps { change, characters }: &StoryNightChangeProps) -> Html {
match change { match change {
NightChange::LostAura { character, aura } => characters.get(character).map(|character| html!{
<>
<CharacterCard faint=true char={character.clone()}/>
{"lost the"}
<crate::components::Aura aura={*aura}/>
{"aura"}
</>
}).unwrap_or_default(),
NightChange::ApplyAura { source, target, aura } => characters.get(source).and_then(|source| characters.get(target).map(|target| (source, target))).map(|(source, target)| {
html!{
<>
<CharacterCard faint=true char={target.clone()}/>
{"gained the"}
<crate::components::Aura aura={*aura}/>
{"aura from"}
<CharacterCard faint=true char={source.clone()}/>
</>
}
}).unwrap_or_default(),
NightChange::RoleChange(character_id, role_title) => characters NightChange::RoleChange(character_id, role_title) => characters
.get(character_id) .get(character_id)
.map(|char| { .map(|char| {
@ -434,6 +453,7 @@ fn StoryNightChoice(StoryNightChoiceProps { choice, characters }: &StoryNightCho
</> </>
} }
}), }),
StoryActionPrompt::Bloodletter { character_id, chosen } => generate(character_id, chosen, "spilt wolf blood on"),
StoryActionPrompt::Vindicator { StoryActionPrompt::Vindicator {
character_id, character_id,
chosen, chosen,

View File

@ -239,6 +239,12 @@ impl RolePage for ActionPrompt {
<LoneWolfPage1 /> <LoneWolfPage1 />
</> </>
}]), }]),
ActionPrompt::Bloodletter { character_id, .. } => Rc::new([html! {
<>
{ident(character_id)}
<BloodletterPage1 />
</>
}]),
_ => Rc::new([]), _ => Rc::new([]),
} }
} }

View File

@ -0,0 +1,40 @@
// Copyright (C) 2025 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use yew::prelude::*;
use crate::components::{Icon, IconSource, IconType};
#[function_component]
pub fn BloodletterPage1() -> Html {
html! {
<div class="role-page">
<h1 class="wolves">{"BLOODLETTER"}</h1>
<div class="information wolves faint">
<h2>{"PICK A PLAYER"}</h2>
<h3>{"THEY WILL BE COVERED IN WOLF BLOOD"}</h3>
<h4>{"AND"}</h4>
<h2 class="yellow inline-icons">
{"APPEAR AS A WOLF "}
<Icon source={IconSource::Wolves} icon_type={IconType::Fit}/>
{" KILLER "}
<Icon source={IconSource::Killer} icon_type={IconType::Fit}/>
{" AND POWERFUL "}
<Icon source={IconSource::Powerful} icon_type={IconType::Fit}/>
{" IN CHECKS"}
</h2>
</div>
</div>
}
}

View File

@ -46,44 +46,81 @@ pub fn SeerResult(SeerResultProps { alignment }: &SeerResultProps) -> Html {
let text = match alignment { let text = match alignment {
Alignment::Village => "VILLAGE", Alignment::Village => "VILLAGE",
Alignment::Wolves => "WOLFPACK", Alignment::Wolves => "WOLFPACK",
Alignment::Traitor => "TRAITOR",
};
let additional_info = match alignment {
Alignment::Village => html! {
<FalselyAppearsAs
roles={RoleTitle::falsely_appear_village()}
alignment_text={text}
/>
},
Alignment::Wolves => html! {
<FalselyAppearsAs
roles={RoleTitle::falsely_appear_wolf()}
alignment_text={text}
/>
},
Alignment::Traitor => html! {
<div class="bottom-bound">
<h1>
{"THIS PERSON IS A "}
<span class="yellow">{"TRAITOR"}</span>
</h1>
<h3>{"THEY WIN ALONGSIDE EVIL"}</h3>
</div>
},
}; };
let false_positives = match alignment {
Alignment::Village => RoleTitle::falsely_appear_village(),
Alignment::Wolves => RoleTitle::falsely_appear_wolf(),
}
.into_iter()
.map(|role| {
html! {
<RoleTitleSpan role={role}/>
}
})
.collect::<Html>();
html! { html! {
<div class="role-page"> <div class="role-page">
<h1 class="intel">{"SEER"}</h1> <h1 class="intel">{"SEER"}</h1>
<div class="information intel faint"> <div class="information intel faint">
<div class="two-column"> <div class="two-column">
<div> <div class="seer-check">
<h2>{"YOUR TARGET APPEARS AS"}</h2> <h2>{"YOUR TARGET APPEARS AS"}</h2>
<h4>
<Icon <Icon
source={alignment.icon()} source={alignment.icon()}
icon_type={IconType::Informational}
/> />
</h4>
<h3 class="yellow">{text}</h3> <h3 class="yellow">{text}</h3>
</div> </div>
<div class="bottom-bound"> {additional_info}
<h5>
{"ROLES THAT FALSELY APPEAR AS "}
<span class="yellow">{text}</span>
</h5>
<div class="false-positives yellow">
{false_positives}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
} }
} }
#[derive(Debug, Clone, PartialEq, Properties)]
struct FalselyAppearsAsProps {
roles: Box<[RoleTitle]>,
alignment_text: &'static str,
}
#[function_component]
fn FalselyAppearsAs(
FalselyAppearsAsProps {
roles,
alignment_text,
}: &FalselyAppearsAsProps,
) -> Html {
let false_positives = roles
.iter()
.copied()
.map(|role| {
html! {
<RoleTitleSpan role={role} icon_type={IconType::Icon15Pct}/>
}
})
.collect::<Html>();
html! {
<div class="bottom-bound">
<h5>
{"ROLES THAT FALSELY APPEAR AS "}
<span class="yellow">{alignment_text}</span>
</h5>
<div class="false-positives yellow">
{false_positives}
</div>
</div>
}
}