diff --git a/werewolves-proto/src/aura.rs b/werewolves-proto/src/aura.rs
new file mode 100644
index 0000000..2b19a54
--- /dev/null
+++ b/werewolves-proto/src/aura.rs
@@ -0,0 +1,53 @@
+// 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 .
+use serde::{Deserialize, Serialize};
+
+use crate::game::{GameTime, Village};
+const BLOODLET_DURATION_DAYS: u8 = 2;
+
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+pub enum Aura {
+ Drunk,
+ Insane,
+ Bloodlet { night: u8 },
+}
+
+impl Aura {
+ pub const fn expired(&self, village: &Village) -> bool {
+ match self {
+ Aura::Drunk | Aura::Insane => false,
+ Aura::Bloodlet { night } => match village.time() {
+ GameTime::Day { .. } => false,
+ GameTime::Night { number } => {
+ night.saturating_add(BLOODLET_DURATION_DAYS) >= number
+ }
+ },
+ }
+ }
+
+ pub const fn refreshes(&self, other: &Aura) -> bool {
+ match (self, other) {
+ (Aura::Bloodlet { .. }, Aura::Bloodlet { .. }) => true,
+ _ => false,
+ }
+ }
+
+ pub fn refresh(&mut self, other: Aura) {
+ match (self, other) {
+ (Aura::Bloodlet { night }, Aura::Bloodlet { night: new_night }) => *night = new_night,
+ _ => {}
+ }
+ }
+}
diff --git a/werewolves-proto/src/character.rs b/werewolves-proto/src/character.rs
index eb92566..3695ca1 100644
--- a/werewolves-proto/src/character.rs
+++ b/werewolves-proto/src/character.rs
@@ -22,11 +22,11 @@ use rand::seq::SliceRandom;
use serde::{Deserialize, Serialize};
use crate::{
+ aura::Aura,
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,
+ auras: Vec,
died_to: Option,
role_changes: Vec,
}
@@ -76,19 +76,20 @@ impl Character {
},
}: Identification,
role: Role,
+ auras: Vec,
) -> Option {
Some(Self {
role,
+ auras,
+ player_id,
+ died_to: None,
+ 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(),
})
}
@@ -298,6 +299,14 @@ impl Character {
AsCharacter(char)
}
+ pub fn apply_aura(&mut self, aura: Aura) {
+ if let Some(existing) = self.auras.iter_mut().find(|aura| aura.refreshes(&aura)) {
+ existing.refresh(aura);
+ } else {
+ self.auras.push(aura);
+ }
+ }
+
pub fn night_action_prompts(&self, village: &Village) -> Result> {
if self.mason_leader().is_ok() {
return self.mason_prompts(village);
@@ -345,6 +354,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()),
diff --git a/werewolves-proto/src/game/night.rs b/werewolves-proto/src/game/night.rs
index f0bc422..8290ea5 100644
--- a/werewolves-proto/src/game/night.rs
+++ b/werewolves-proto/src/game/night.rs
@@ -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, .. }
@@ -1083,6 +1088,12 @@ impl Night {
}
}
+ /// returns the matching [Character] with the current night's aura changes
+ /// applied
+ fn character_with_current_auras(&self, id: CharacterId) -> Result {
+ todo!()
+ }
+
fn changes_from_actions(&self) -> Box<[NightChange]> {
self.used_actions
.iter()
@@ -1109,7 +1120,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 +1216,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, .. }
diff --git a/werewolves-proto/src/game/night/changes.rs b/werewolves-proto/src/game/night/changes.rs
index d4febd2..46807dc 100644
--- a/werewolves-proto/src/game/night/changes.rs
+++ b/werewolves-proto/src/game/night/changes.rs
@@ -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,11 @@ pub enum NightChange {
empath: CharacterId,
scapegoat: CharacterId,
},
+ ApplyAura {
+ source: CharacterId,
+ target: CharacterId,
+ aura: Aura,
+ },
}
pub struct ChangesLookup<'a>(&'a [NightChange], Vec);
diff --git a/werewolves-proto/src/game/night/process.rs b/werewolves-proto/src/game/night/process.rs
index d13dc7e..6afb33d 100644
--- a/werewolves-proto/src/game/night/process.rs
+++ b/werewolves-proto/src/game/night/process.rs
@@ -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, .. }
diff --git a/werewolves-proto/src/game/settings/settings_role.rs b/werewolves-proto/src/game/settings/settings_role.rs
index 053fed1..bf3f138 100644
--- a/werewolves-proto/src/game/settings/settings_role.rs
+++ b/werewolves-proto/src/game/settings/settings_role.rs
@@ -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 {
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 for RoleTitle {
impl From 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,
+ pub auras: Vec,
pub assign_to: Option,
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,8 +400,12 @@ impl SetupSlot {
ident: Identification,
roles_in_game: &[RoleTitle],
) -> Result {
- Character::new(ident.clone(), self.role.into_role(roles_in_game)?)
- .ok_or(GameError::PlayerNotAssignedNumber(ident.to_string()))
+ Character::new(
+ ident.clone(),
+ self.role.into_role(roles_in_game)?,
+ self.auras,
+ )
+ .ok_or(GameError::PlayerNotAssignedNumber(ident.to_string()))
}
}
diff --git a/werewolves-proto/src/game/story.rs b/werewolves-proto/src/game/story.rs
index de210d5..dc6c5c0 100644
--- a/werewolves-proto/src/game/story.rs
+++ b/werewolves-proto/src/game/story.rs
@@ -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 {
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 { .. }
diff --git a/werewolves-proto/src/game/village.rs b/werewolves-proto/src/game/village.rs
index fd9efc4..6427e0c 100644
--- a/werewolves-proto/src/game/village.rs
+++ b/werewolves-proto/src/game/village.rs
@@ -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,
diff --git a/werewolves-proto/src/game/village/apply.rs b/werewolves-proto/src/game/village/apply.rs
index 71024e7..5b77563 100644
--- a/werewolves-proto/src/game/village/apply.rs
+++ b/werewolves-proto/src/game/village/apply.rs
@@ -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()
}
diff --git a/werewolves-proto/src/lib.rs b/werewolves-proto/src/lib.rs
index 28415f1..07ad5dd 100644
--- a/werewolves-proto/src/lib.rs
+++ b/werewolves-proto/src/lib.rs
@@ -14,6 +14,7 @@
// along with this program. If not, see .
#![allow(clippy::new_without_default)]
+pub mod aura;
pub mod character;
pub mod diedto;
pub mod error;
@@ -21,7 +22,6 @@ pub mod game;
#[cfg(test)]
mod game_test;
pub mod message;
-pub mod modifier;
pub mod nonzero;
pub mod player;
pub mod role;
diff --git a/werewolves-proto/src/message/night.rs b/werewolves-proto/src/message/night.rs
index 1185c57..9ea2b5b 100644
--- a/werewolves-proto/src/message/night.rs
+++ b/werewolves-proto/src/message/night.rs
@@ -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,
+ },
}
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,
..
diff --git a/werewolves-proto/src/modifier.rs b/werewolves-proto/src/modifier.rs
deleted file mode 100644
index ad2aae9..0000000
--- a/werewolves-proto/src/modifier.rs
+++ /dev/null
@@ -1,21 +0,0 @@
-// 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 .
-use serde::{Deserialize, Serialize};
-
-#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
-pub enum Modifier {
- Drunk,
- Insane,
-}
diff --git a/werewolves-proto/src/role.rs b/werewolves-proto/src/role.rs
index 4d576dd..b792a33 100644
--- a/werewolves-proto/src/role.rs
+++ b/werewolves-proto/src/role.rs
@@ -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