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 crate::{
aura::{Aura, Auras},
diedto::DiedTo,
error::GameError,
game::{GameTime, Village},
message::{CharacterIdentity, Identification, PublicIdentity, night::ActionPrompt},
modifier::Modifier,
player::{PlayerId, RoleChange},
role::{
Alignment, Killer, KillingWolfOrder, MAPLE_WOLF_ABSTAIN_LIMIT, Powerful,
@ -59,7 +59,7 @@ pub struct Character {
player_id: PlayerId,
identity: CharacterIdentity,
role: Role,
modifier: Option<Modifier>,
auras: Auras,
died_to: Option<DiedTo>,
role_changes: Vec<RoleChange>,
}
@ -76,19 +76,20 @@ impl Character {
},
}: Identification,
role: Role,
auras: Vec<Aura>,
) -> Option<Self> {
Some(Self {
role,
player_id,
died_to: None,
auras: Auras::new(auras),
role_changes: Vec::new(),
identity: CharacterIdentity {
character_id: CharacterId::new(),
name,
pronouns,
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) {
if let Role::Elder {
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<()> {
let mut role = new_role.title_to_role_excl_apprentice();
core::mem::swap(&mut role, &mut self.role);
@ -298,6 +300,14 @@ impl Character {
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]>> {
if self.mason_leader().is_ok() {
return self.mason_prompts(village);
@ -345,6 +355,11 @@ impl Character {
return Ok(Box::new([]));
}
}
Role::Bloodletter => ActionPrompt::Bloodletter {
character_id: self.identity(),
living_players: village.living_villagers(),
marked: None,
},
Role::Seer => ActionPrompt::Seer {
character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()),
@ -574,14 +589,30 @@ impl Character {
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 {
return Killer::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 {
return Powerful::Powerful;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,6 @@
use serde::{Deserialize, Serialize};
use werewolves_macros::ChecksAs;
// Copyright (C) 2025 Emilis Bliūdžius
//
// 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
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum Modifier {
Drunk,
Insane,
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ChecksAs)]
pub enum Team {
Village,
#[checks("evil")]
Wolves,
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 {
margin: 10px;
}
@ -1149,6 +1148,13 @@ input {
width: 48px;
}
.icon-fit {
height: 1em;
}
.icon-15pct {
width: 15%;
}
.village {
background-color: $village_color;
@ -1762,12 +1768,6 @@ input {
font-size: 4vw;
}
.information {
font-size: 1.2rem;
padding-left: 5%;
padding-right: 5%;
}
.yellow {
color: yellow;
}
@ -1809,10 +1809,20 @@ input {
grid-template-columns: 3fr 2fr;
}
.seer-check {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
zoom: 90%;
}
.role-title-span {
display: grid;
grid-template-columns: 1fr 100fr;
max-height: 2rem;
// display: grid;
// grid-template-columns: 1fr 100fr;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
height: 2rem;
width: fit-content;
@ -1820,11 +1830,18 @@ input {
padding-bottom: 5px;
padding-left: 5px;
padding-right: 10px;
width: 100%;
align-items: center;
img {
vertical-align: text-bottom;
max-height: 2rem;
padding-left: 10px;
flex-shrink: 1;
}
span {
flex-grow: 1;
}
text-align: center;
@ -1834,6 +1851,7 @@ input {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
font-weight: bold;
gap: 10px;
}
@ -1897,3 +1915,34 @@ input {
align-items: 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::Arcanist { .. } => panic!("wrong call for arcanist"),
ActionPrompt::LoneWolfKill {
ActionPrompt::Bloodletter {
marked: Some(marked),
..
}
| ActionPrompt::LoneWolfKill {
marked: Some(marked),
..
}
@ -907,7 +911,8 @@ impl GameExt for Game {
marked: Some(marked),
..
} => assert_eq!(marked, mark, "marked character"),
ActionPrompt::Seer { marked: None, .. }
ActionPrompt::Bloodletter { marked: None, .. }
| ActionPrompt::Seer { marked: None, .. }
| ActionPrompt::Adjudicator { marked: None, .. }
| ActionPrompt::PowerSeer { 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 {
character_id,
living_players,

View File

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

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 crate::components::{AssociatedIcon, Icon, IconSource, IconType};
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct SetupProps {
pub settings: GameSettings,
@ -140,10 +142,7 @@ pub fn SetupCategory(
});
let killer_inactive = as_role.killer().killer().not().then_some("inactive");
let powerful_inactive = as_role.powerful().powerful().not().then_some("inactive");
let alignment = match as_role.alignment() {
Alignment::Village => "/img/village.svg",
Alignment::Wolves => "/img/wolf.svg",
};
let alignment = as_role.alignment().icon();
html! {
<div class={classes!("slot")}>
{count}
@ -152,13 +151,16 @@ pub fn SetupCategory(
</div>
<div class="attributes">
<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 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 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>

View File

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

View File

@ -239,6 +239,12 @@ impl RolePage for ActionPrompt {
<LoneWolfPage1 />
</>
}]),
ActionPrompt::Bloodletter { character_id, .. } => Rc::new([html! {
<>
{ident(character_id)}
<BloodletterPage1 />
</>
}]),
_ => 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 {
Alignment::Village => "VILLAGE",
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! {
<div class="role-page">
<h1 class="intel">{"SEER"}</h1>
<div class="information intel faint">
<div class="two-column">
<div>
<div class="seer-check">
<h2>{"YOUR TARGET APPEARS AS"}</h2>
<h4>
<Icon
source={alignment.icon()}
icon_type={IconType::Informational}
/>
</h4>
<h3 class="yellow">{text}</h3>
</div>
<div class="bottom-bound">
<h5>
{"ROLES THAT FALSELY APPEAR AS "}
<span class="yellow">{text}</span>
</h5>
<div class="false-positives yellow">
{false_positives}
</div>
</div>
{additional_info}
</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>
}
}