if you die tonight you might not get woken up

about footer
This commit is contained in:
emilis 2025-11-14 18:26:16 +00:00
parent ad7ffaac31
commit f711ca0a55
No known key found for this signature in database
18 changed files with 927 additions and 69 deletions

14
Cargo.lock generated
View File

@ -306,6 +306,15 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "convert_case"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db05ffb6856bf0ecdf6367558a76a0e8a77b1713044eb92845c692100ed50190"
dependencies = [
"unicode-segmentation",
]
[[package]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
@ -2415,7 +2424,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"ciborium", "ciborium",
"convert_case", "convert_case 0.8.0",
"futures", "futures",
"getrandom 0.3.3", "getrandom 0.3.3",
"gloo 0.11.0", "gloo 0.11.0",
@ -2441,7 +2450,8 @@ dependencies = [
name = "werewolves-macros" name = "werewolves-macros"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"convert_case", "chrono",
"convert_case 0.9.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn 2.0.106",

View File

@ -10,4 +10,5 @@ proc-macro = true
proc-macro2 = "1" proc-macro2 = "1"
quote = "1" quote = "1"
syn = { version = "2", features = ["full", "extra-traits"] } syn = { version = "2", features = ["full", "extra-traits"] }
convert_case = { version = "0.8" } convert_case = { version = "0.9" }
chrono = { version = "0.4" }

View File

@ -16,6 +16,7 @@ use core::error::Error;
use std::{ use std::{
io, io,
path::{Path, PathBuf}, path::{Path, PathBuf},
process::Command,
}; };
use convert_case::Casing; use convert_case::Casing;
@ -574,3 +575,73 @@ pub fn ref_and_mut(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let ref_and_mut = parse_macro_input!(input as RefAndMut); let ref_and_mut = parse_macro_input!(input as RefAndMut);
quote! {#ref_and_mut}.into() quote! {#ref_and_mut}.into()
} }
#[proc_macro]
pub fn build_dirty(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
if !input.is_empty() {
panic!("build_dirty doesn't take arguments");
}
let git_state = Command::new("git")
.arg("diff")
.arg("--stat")
.output()
.unwrap();
if !git_state.status.success() {
panic!("git diff --stat failed");
}
let dirty = !git_state.stdout.is_empty();
quote! {#dirty}.into()
}
#[proc_macro]
pub fn build_id(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
if !input.is_empty() {
panic!("build_id doesn't take arguments");
}
let output = Command::new("git")
.arg("rev-parse")
.arg("--short")
.arg("HEAD")
.output()
.unwrap();
if !output.status.success() {
panic!("git rev-parse --short HEAD failed");
}
let output = String::from_utf8(output.stdout).unwrap();
let git_ref = output.trim();
quote! {#git_ref}.into()
}
#[proc_macro]
pub fn build_id_long(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
if !input.is_empty() {
panic!("build_id doesn't take arguments");
}
let output = Command::new("git")
.arg("rev-parse")
.arg("HEAD")
.output()
.unwrap();
if !output.status.success() {
panic!("git rev-parse --short HEAD failed");
}
let output = String::from_utf8(output.stdout).unwrap();
let git_ref = output.trim();
quote! {#git_ref}.into()
}
#[proc_macro]
pub fn build_time(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
if !input.is_empty() {
panic!("build_time doesn't take arguments");
}
let time = chrono::Utc::now().format("%d %b %Y %T UTC").to_string();
quote! {#time}.into()
}

View File

@ -122,7 +122,7 @@ fn resolve_protection(
} }
pub fn resolve_kill( pub fn resolve_kill(
changes: &mut ChangesLookup<'_>, changes: &ChangesLookup<'_>,
target: CharacterId, target: CharacterId,
died_to: &DiedTo, died_to: &DiedTo,
night: u8, night: u8,
@ -133,7 +133,7 @@ pub fn resolve_kill(
night, night,
starves_if_fails: true, starves_if_fails: true,
} = died_to } = died_to
&& let Some(protection) = changes.protected_take(target) && let Some(protection) = changes.protected(target)
{ {
return Ok(Some( return Ok(Some(
resolve_protection(*source, died_to, target, &protection, *night).unwrap_or( resolve_protection(*source, died_to, target, &protection, *night).unwrap_or(
@ -149,7 +149,7 @@ pub fn resolve_kill(
{ {
let killing_wolf = village.character_by_id(*killing_wolf)?; let killing_wolf = village.character_by_id(*killing_wolf)?;
match changes.protected_take(target) { match changes.protected(target) {
Some(protection) => { Some(protection) => {
return Ok(resolve_protection( return Ok(resolve_protection(
killing_wolf.character_id(), killing_wolf.character_id(),
@ -162,7 +162,7 @@ pub fn resolve_kill(
None => { None => {
// Wolf kill went through -- can kill shifter // Wolf kill went through -- can kill shifter
return Ok(Some(KillOutcome::Single( return Ok(Some(KillOutcome::Single(
*ss_source, ss_source,
DiedTo::Shapeshift { DiedTo::Shapeshift {
into: target, into: target,
night: *night, night: *night,
@ -172,7 +172,7 @@ pub fn resolve_kill(
}; };
} }
let protection = match changes.protected_take(target) { let protection = match changes.protected(target) {
Some(prot) => prot, Some(prot) => prot,
None => return Ok(Some(KillOutcome::Single(target, died_to.clone()))), None => return Ok(Some(KillOutcome::Single(target, died_to.clone()))),
}; };
@ -187,7 +187,7 @@ pub fn resolve_kill(
.ok_or(GameError::GuardianInvalidOriginalKill)?, .ok_or(GameError::GuardianInvalidOriginalKill)?,
original_target: target, original_target: target,
original_kill: died_to.clone(), original_kill: died_to.clone(),
guardian: source, guardian: *source,
night: NonZeroU8::new(night).unwrap(), night: NonZeroU8::new(night).unwrap(),
})), })),
Protection::Guardian { Protection::Guardian {

View File

@ -28,7 +28,7 @@ use crate::{
error::GameError, error::GameError,
game::{ game::{
GameTime, Village, GameTime, Village,
kill::{self}, kill::{self, KillOutcome},
night::changes::{ChangesLookup, NightChange}, night::changes::{ChangesLookup, NightChange},
}, },
message::night::{ActionPrompt, ActionPromptTitle, ActionResponse, ActionResult, Visits}, message::night::{ActionPrompt, ActionPromptTitle, ActionResponse, ActionResult, Visits},
@ -1019,20 +1019,7 @@ impl Night {
fn died_to_tonight(&self, character_id: CharacterId) -> Result<Option<DiedTo>> { fn died_to_tonight(&self, character_id: CharacterId) -> Result<Option<DiedTo>> {
let ch = self.current_changes(); let ch = self.current_changes();
let mut changes = ChangesLookup::new(&ch); let mut changes = ChangesLookup::new(&ch);
if let Some(died_to) = changes.killed(character_id) changes.died_to(character_id, self.night, &self.village)
&& kill::resolve_kill(
&mut changes,
character_id,
died_to,
self.night,
&self.village,
)?
.is_some()
{
Ok(Some(died_to.clone()))
} else {
Ok(None)
}
} }
/// returns the matching [Character] with the current night's aura changes /// returns the matching [Character] with the current night's aura changes
@ -1057,6 +1044,16 @@ impl Night {
.iter() .iter()
.flat_map(|(_, _, act)| act.iter()) .flat_map(|(_, _, act)| act.iter())
.cloned() .cloned()
.chain(
match &self.night_state {
NightState::Active {
current_changes, ..
} => Some(current_changes.iter().cloned()),
NightState::Complete => None,
}
.into_iter()
.flatten(),
)
.collect() .collect()
} }

View File

@ -12,7 +12,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 core::ops::Not; use core::{num::NonZeroU8, ops::Not};
use super::Result; use super::Result;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -22,7 +22,10 @@ use crate::{
aura::Aura, aura::Aura,
character::CharacterId, character::CharacterId,
diedto::DiedTo, diedto::DiedTo,
game::{Village, kill}, game::{
Village,
kill::{self, KillOutcome},
},
player::Protection, player::Protection,
role::{RoleBlock, RoleTitle}, role::{RoleBlock, RoleTitle},
}; };
@ -112,19 +115,56 @@ impl<'a> ChangesLookup<'a> {
.collect() .collect()
} }
pub fn kill_outcomes(&self, night: u8, village: &Village) -> Result<Box<[KillOutcome]>> {
self.0
.iter()
.filter_map(|c| match c {
NightChange::Kill { target, died_to } => Some((*target, died_to.clone())),
_ => None,
})
.map(|(kill_target, died_to)| {
kill::resolve_kill(self, kill_target, &died_to, night, village)
})
.filter_map(|result| match result {
Ok(Some(outcome)) => Some(Ok(outcome)),
Ok(None) => None,
Err(err) => Some(Err(err)),
})
.collect::<Result<Box<[_]>>>()
}
pub fn died_to( pub fn died_to(
&mut self, &self,
character_id: CharacterId, character_id: CharacterId,
night: u8, night: u8,
village: &Village, village: &Village,
) -> Result<Option<DiedTo>> { ) -> Result<Option<DiedTo>> {
if let Some(died_to) = self.killed(character_id) let kill_outcomes = self.kill_outcomes(night, village)?;
&& kill::resolve_kill(self, character_id, died_to, night, village)?.is_some()
{ Ok(kill_outcomes.into_iter().find_map(|outcome| match outcome {
Ok(Some(died_to.clone())) KillOutcome::Single(target, died_to) => (target == character_id).then_some(died_to),
} else { KillOutcome::Guarding {
Ok(None) original_killer,
} original_target,
original_kill,
guardian,
night,
} => {
if original_killer == character_id {
Some(DiedTo::GuardianProtecting {
source: guardian,
protecting: original_target,
protecting_from: original_killer,
protecting_from_cause: Box::new(original_kill),
night,
})
} else if guardian == character_id {
Some(original_kill)
} else {
None
}
}
}))
} }
pub fn killed(&self, target: CharacterId) -> Option<&'a DiedTo> { pub fn killed(&self, target: CharacterId) -> Option<&'a DiedTo> {
@ -160,7 +200,7 @@ impl<'a> ChangesLookup<'a> {
None None
} }
} }
pub fn protected(&self, target: &CharacterId) -> Option<&'a Protection> { pub fn protected(&self, target: CharacterId) -> Option<&'a Protection> {
self.0.iter().enumerate().find_map(|(idx, c)| { self.0.iter().enumerate().find_map(|(idx, c)| {
self.1 self.1
.contains(&idx) .contains(&idx)
@ -169,20 +209,27 @@ impl<'a> ChangesLookup<'a> {
NightChange::Protection { NightChange::Protection {
target: t, target: t,
protection, protection,
} => (t == target).then_some(protection), } => (*t == target).then_some(protection),
_ => None, _ => None,
}) })
.flatten() .flatten()
}) })
} }
pub fn shapeshifter(&self) -> Option<&'a CharacterId> { pub fn shapeshifter(&self) -> Option<CharacterId> {
self.shapeshift_change().and_then(|c| match c {
NightChange::Shapeshift { source, .. } => Some(source),
_ => None,
})
}
pub fn shapeshift_change(&self) -> Option<NightChange> {
self.0.iter().enumerate().find_map(|(idx, c)| { self.0.iter().enumerate().find_map(|(idx, c)| {
self.1 self.1
.contains(&idx) .contains(&idx)
.not() .not()
.then_some(match c { .then_some(match c {
NightChange::Shapeshift { source, .. } => Some(source), NightChange::Shapeshift { .. } => Some(c.clone()),
_ => None, _ => None,
}) })
.flatten() .flatten()

View File

@ -21,7 +21,7 @@ use crate::{
error::GameError, error::GameError,
game::night::{CurrentResult, Night, NightState, changes::NightChange}, game::night::{CurrentResult, Night, NightState, changes::NightChange},
message::night::{ActionPrompt, ActionResult}, message::night::{ActionPrompt, ActionResult},
role::RoleBlock, role::{RoleBlock, RoleTitle},
}; };
use super::Result; use super::Result;
@ -82,7 +82,7 @@ impl Night {
} => return Err(GameError::AwaitingResponse), } => return Err(GameError::AwaitingResponse),
NightState::Complete => return Err(GameError::NightOver), NightState::Complete => return Err(GameError::NightOver),
} }
if let Some(prompt) = self.action_queue.pop_front() { if let Some(prompt) = self.pull_next_prompt_with_dead_ignore()? {
if let ActionPrompt::Insomniac { character_id } = &prompt if let ActionPrompt::Insomniac { character_id } = &prompt
&& self.get_visits_for(character_id.character_id).is_empty() && self.get_visits_for(character_id.character_id).is_empty()
{ {
@ -188,4 +188,25 @@ impl Night {
*result = ActionResult::RoleBlocked; *result = ActionResult::RoleBlocked;
}); });
} }
fn pull_next_prompt_with_dead_ignore(&mut self) -> Result<Option<ActionPrompt>> {
let has_living_beholder = self
.village
.characters()
.into_iter()
.any(|c| matches!(c.role_title(), RoleTitle::Beholder));
while let Some(prompt) = self.action_queue.pop_front() {
let Some(char_id) = prompt.character_id() else {
return Ok(Some(prompt));
};
match (self.died_to_tonight(char_id)?, has_living_beholder) {
(Some(_), false) => {}
(Some(DiedTo::Shapeshift { .. }), _) | (Some(_), true) | (None, _) => {
return Ok(Some(prompt));
}
}
}
Ok(None)
}
} }

View File

@ -52,7 +52,7 @@ impl Village {
GameTime::Day { .. } => return Err(GameError::NotNight), GameTime::Day { .. } => return Err(GameError::NotNight),
GameTime::Night { number } => number, GameTime::Night { number } => number,
}; };
let mut changes = ChangesLookup::new(all_changes); let changes = ChangesLookup::new(all_changes);
let mut new_village = self.clone(); let mut new_village = self.clone();
@ -93,7 +93,7 @@ impl Village {
.died_to(hunter_character.character_id(), night, self)? .died_to(hunter_character.character_id(), night, self)?
.is_some() .is_some()
&& let Some(kill) = kill::resolve_kill( && let Some(kill) = kill::resolve_kill(
&mut changes, &changes,
*target, *target,
&DiedTo::Hunter { &DiedTo::Hunter {
killer: *source, killer: *source,
@ -108,8 +108,7 @@ impl Village {
} }
} }
NightChange::Kill { target, died_to } => { NightChange::Kill { target, died_to } => {
if let Some(kill) = if let Some(kill) = kill::resolve_kill(&changes, *target, died_to, night, self)?
kill::resolve_kill(&mut changes, *target, died_to, night, self)?
{ {
if let KillOutcome::Guarding { if let KillOutcome::Guarding {
guardian, guardian,
@ -135,7 +134,7 @@ impl Village {
} }
NightChange::Shapeshift { source, into } => { NightChange::Shapeshift { source, into } => {
if let Some(target) = changes.wolf_pack_kill_target() if let Some(target) = changes.wolf_pack_kill_target()
&& changes.protected(target).is_none() && changes.protected(*target).is_none()
{ {
if *target != *into { if *target != *into {
log::error!("shapeshift into({into}) != target({target})"); log::error!("shapeshift into({into}) != target({target})");

View File

@ -0,0 +1,311 @@
// Copyright (C) 2025 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use core::num::NonZeroU8;
#[allow(unused)]
use pretty_assertions::{assert_eq, assert_ne, assert_str_eq};
use crate::{
diedto::DiedTo,
game::{
Game, GameSettings, SetupRole,
night::changes::{ChangesLookup, NightChange},
},
game_test::{GameExt, SettingsExt, gen_players, init_log},
player::Protection,
};
#[test]
fn shapeshift() {
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 shapeshifter = player_ids.next().unwrap();
let wolf = player_ids.next().unwrap();
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::Villager, target);
settings.add_and_assign(SetupRole::Shapeshifter, shapeshifter);
settings.add_and_assign(SetupRole::Werewolf, wolf);
settings.fill_remaining_slots_with_villagers(9);
let game = Game::new(&players, settings).unwrap();
let all_changes = [
NightChange::Kill {
target: game.character_by_player_id(target).character_id(),
died_to: DiedTo::Wolfpack {
killing_wolf: game.character_by_player_id(wolf).character_id(),
night: NonZeroU8::new(1).unwrap(),
},
},
NightChange::Shapeshift {
source: game.character_by_player_id(shapeshifter).character_id(),
into: game.character_by_player_id(target).character_id(),
},
];
let changes = ChangesLookup::new(&all_changes);
assert_eq!(
changes.died_to(
game.character_by_player_id(target).character_id(),
1,
game.village()
),
Ok(None)
);
assert_eq!(
changes.died_to(
game.character_by_player_id(shapeshifter).character_id(),
1,
game.village()
),
Ok(Some(DiedTo::Shapeshift {
into: game.character_by_player_id(target).character_id(),
night: NonZeroU8::new(1).unwrap()
}))
);
}
#[test]
fn guardian_protect() {
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 guardian = player_ids.next().unwrap();
let wolf = player_ids.next().unwrap();
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::Villager, target);
settings.add_and_assign(SetupRole::Guardian, guardian);
settings.add_and_assign(SetupRole::Werewolf, wolf);
settings.fill_remaining_slots_with_villagers(9);
let game = Game::new(&players, settings).unwrap();
let all_changes = [
NightChange::Kill {
target: game.character_by_player_id(target).character_id(),
died_to: DiedTo::Wolfpack {
killing_wolf: game.character_by_player_id(wolf).character_id(),
night: NonZeroU8::new(1).unwrap(),
},
},
NightChange::Protection {
target: game.character_by_player_id(target).character_id(),
protection: Protection::Guardian {
source: game.character_by_player_id(guardian).character_id(),
guarding: false,
},
},
];
let changes = ChangesLookup::new(&all_changes);
assert_eq!(
changes.died_to(
game.character_by_player_id(target).character_id(),
1,
game.village()
),
Ok(None)
);
assert_eq!(
changes.died_to(
game.character_by_player_id(guardian).character_id(),
1,
game.village()
),
Ok(None)
);
}
#[test]
fn guardian_guard() {
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 guardian = player_ids.next().unwrap();
let wolf = player_ids.next().unwrap();
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::Villager, target);
settings.add_and_assign(SetupRole::Guardian, guardian);
settings.add_and_assign(SetupRole::Werewolf, wolf);
settings.fill_remaining_slots_with_villagers(9);
let game = Game::new(&players, settings).unwrap();
let all_changes = [
NightChange::Kill {
target: game.character_by_player_id(target).character_id(),
died_to: DiedTo::Wolfpack {
killing_wolf: game.character_by_player_id(wolf).character_id(),
night: NonZeroU8::new(1).unwrap(),
},
},
NightChange::Protection {
target: game.character_by_player_id(target).character_id(),
protection: Protection::Guardian {
source: game.character_by_player_id(guardian).character_id(),
guarding: true,
},
},
];
let changes = ChangesLookup::new(&all_changes);
assert_eq!(
changes.died_to(
game.character_by_player_id(target).character_id(),
1,
game.village()
),
Ok(None)
);
assert_eq!(
changes.died_to(
game.character_by_player_id(guardian).character_id(),
1,
game.village()
),
Ok(Some(DiedTo::Wolfpack {
killing_wolf: game.character_by_player_id(wolf).character_id(),
night: NonZeroU8::new(1).unwrap()
}))
);
assert_eq!(
changes.died_to(
game.character_by_player_id(wolf).character_id(),
1,
game.village()
),
Ok(Some(DiedTo::GuardianProtecting {
source: game.character_by_player_id(guardian).character_id(),
protecting: game.character_by_player_id(target).character_id(),
protecting_from: game.character_by_player_id(wolf).character_id(),
protecting_from_cause: Box::new(DiedTo::Wolfpack {
killing_wolf: game.character_by_player_id(wolf).character_id(),
night: NonZeroU8::new(1).unwrap(),
}),
night: NonZeroU8::new(1).unwrap(),
}))
);
}
#[test]
fn guardian_protect_from_multiple_attackers() {
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 guardian = player_ids.next().unwrap();
let wolf = player_ids.next().unwrap();
let militia = player_ids.next().unwrap();
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::Villager, target);
settings.add_and_assign(SetupRole::Villager, militia);
settings.add_and_assign(SetupRole::Guardian, guardian);
settings.add_and_assign(SetupRole::Werewolf, wolf);
settings.fill_remaining_slots_with_villagers(9);
let game = Game::new(&players, settings).unwrap();
let all_changes = [
NightChange::Kill {
target: game.character_by_player_id(target).character_id(),
died_to: DiedTo::Wolfpack {
killing_wolf: game.character_by_player_id(wolf).character_id(),
night: NonZeroU8::new(1).unwrap(),
},
},
NightChange::Kill {
target: game.character_by_player_id(target).character_id(),
died_to: DiedTo::Militia {
killer: game.character_by_player_id(militia).character_id(),
night: NonZeroU8::new(1).unwrap(),
},
},
NightChange::Protection {
target: game.character_by_player_id(target).character_id(),
protection: Protection::Guardian {
source: game.character_by_player_id(guardian).character_id(),
guarding: false,
},
},
];
let changes = ChangesLookup::new(&all_changes);
assert_eq!(
changes.died_to(
game.character_by_player_id(target).character_id(),
1,
game.village()
),
Ok(None)
);
assert_eq!(
changes.died_to(
game.character_by_player_id(guardian).character_id(),
1,
game.village()
),
Ok(None)
);
}
#[test]
fn guardian_protect_someone_else_unprotected_dies() {
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 guardian = player_ids.next().unwrap();
let wolf = player_ids.next().unwrap();
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::Seer, target);
settings.add_and_assign(SetupRole::Guardian, guardian);
settings.add_and_assign(SetupRole::Werewolf, wolf);
settings.fill_remaining_slots_with_villagers(9);
let game = Game::new(&players, settings).unwrap();
let all_changes = [
NightChange::Kill {
target: game.character_by_player_id(target).character_id(),
died_to: DiedTo::Wolfpack {
killing_wolf: game.character_by_player_id(wolf).character_id(),
night: NonZeroU8::new(1).unwrap(),
},
},
NightChange::Protection {
target: game.living_villager().character_id(),
protection: Protection::Guardian {
source: game.character_by_player_id(guardian).character_id(),
guarding: false,
},
},
];
let changes = ChangesLookup::new(&all_changes);
assert_eq!(
changes.died_to(
game.character_by_player_id(target).character_id(),
1,
game.village()
),
Ok(Some(DiedTo::Wolfpack {
killing_wolf: game.character_by_player_id(wolf).character_id(),
night: NonZeroU8::new(1).unwrap()
}))
);
assert_eq!(
changes.died_to(
game.character_by_player_id(guardian).character_id(),
1,
game.village()
),
Ok(None)
);
}

View File

@ -12,6 +12,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/>.
mod changes;
mod night_order; mod night_order;
mod previous; mod previous;
mod revert; mod revert;

View File

@ -100,10 +100,6 @@ fn redeemed_scapegoat_role_changes() {
game.mark_and_check(seer); game.mark_and_check(seer);
game.r#continue().sleep(); game.r#continue().sleep();
assert_eq!(game.next().title(), ActionPromptTitle::Seer);
game.mark_and_check(wolf_char_id);
assert_eq!(game.r#continue().seer(), Alignment::Wolves);
game.r#continue().sleep();
game.next_expect_day(); game.next_expect_day();
assert_eq!( assert_eq!(

View File

@ -820,12 +820,9 @@ clients {
} }
.client-nav { .client-nav {
// position: absolute;
// left: 0;
// top: 0;
max-width: 100%; max-width: 100%;
padding: 10px; padding: 10px;
// background-color: rgba(255, 107, 255, 0.2); height: 37px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: baseline; justify-content: baseline;
@ -957,6 +954,11 @@ input {
font-size: 2rem; font-size: 2rem;
align-content: stretch; align-content: stretch;
margin: 0; margin: 0;
position: absolute;
left: 0;
z-index: 3;
background-color: #000;
min-width: 2cm;
& * { & * {
margin: 0; margin: 0;
@ -2106,3 +2108,105 @@ li.choice {
.story-text { .story-text {
font-size: 1.7em; font-size: 1.7em;
} }
.dialog {
z-index: 5;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
position: fixed;
top: 0;
left: 0;
align-items: center;
justify-content: center;
.dialog-box {
border: 1px solid white;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
align-items: center;
padding-left: 30px;
padding-right: 30px;
padding-top: 10px;
padding-bottom: 10px;
background-color: black;
.options {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 20px;
&>button {
min-width: 4cm;
font-size: 1em;
}
}
}
}
.about {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
justify-content: center;
gap: 10px;
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
}
p {
margin: 0;
text-align: center;
}
a {
text-decoration: underline dotted;
color: white;
}
.links {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
align-items: center;
width: 100%;
gap: 10px;
}
.build-info {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
justify-content: center;
text-align: center;
label {
color: hsl(280, 65%, 43%);
}
}
}
.footer {
width: 100vw;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
overflow: hidden;
max-height: 1cm;
justify-content: center;
background-color: #000;
z-index: 3;
padding: 10px 0 10px 0;
border-top: 1px solid white;
}

View File

@ -30,7 +30,7 @@ use crate::{
clients::client::connection::{Connection2, ConnectionError}, clients::client::connection::{Connection2, ConnectionError},
components::{ components::{
Button, CoverOfDarkness, Identity, Story, Button, CoverOfDarkness, Identity, Story,
client::{ClientNav, Signin}, client::{ClientFooter, ClientNav, Signin},
}, },
storage::StorageKey, storage::StorageKey,
}; };
@ -251,8 +251,9 @@ pub fn Client2(ClientProps { auto_join }: &ClientProps) -> Html {
html! { html! {
<> <>
{nav} {nav}
{content} {content}
<ClientFooter />
</> </>
} }
} }

View File

@ -0,0 +1,110 @@
// 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::{Dialog, WithConfirmation};
const SOURCE_CODE_URL: &str = "https://sectorinf.com/emilis/werewolves";
#[function_component]
pub fn ClientFooter() -> Html {
let about_dialog_state = use_state(|| false);
let about_dialog = about_dialog_state.then(|| {
let cancel_signout = {
let dialog = about_dialog_state.clone();
Callback::from(move |_| {
dialog.set(false);
})
};
let callback = Callback::from(move |_| ());
let options: Box<[String]> = Box::new([]);
html! {
<Dialog
options={options}
cancel_callback={Some(cancel_signout)}
callback={callback}
>
<About />
</Dialog>
}
});
let about_click = {
let dialog_set = about_dialog_state.setter();
move |_| {
dialog_set.set(true);
}
};
html! {
<nav class="footer">
<button class="default-button solid" onclick={about_click}>{"about"}</button>
{about_dialog}
</nav>
}
}
#[function_component]
pub fn About() -> Html {
let confirm_state = use_state(|| false);
let source_code_confirm = {
let confirm_callback = {
move |_| {
let _ = gloo::utils::window().location().set_href(SOURCE_CODE_URL);
}
};
let message = html! {
<>
<h1>{"this will take you away from the game"}</h1>
<h3>{"make sure this isn't an oopsie"}</h3>
</>
};
html! {
<WithConfirmation
state={confirm_state}
confirm_callback={confirm_callback}
message={message}
>
{"source code"}
</WithConfirmation>
}
};
let dirty = crate::BUILD_DIRTY.then_some(html! {
<>
{" "}
<span class="dirty">{"(dirty)"}</span>
</>
});
html! {
<div class="about">
<h1>{"werewolves"}</h1>
<div class="build-info">
<p class="build-id">
<label>{"build: "}</label>
<a href={format!("{SOURCE_CODE_URL}/commit/{}", crate::BUILD_ID_LONG)}>
{crate::BUILD_ID}
{dirty}
</a>
</p>
<p class="build-time">
<label>{"built at: "}</label>
<span class="time">{crate::BUILD_TIME}</span>
</p>
</div>
<nav class="links">
{source_code_confirm}
</nav>
</div>
}
}

View File

@ -53,6 +53,9 @@ pub fn ClientNav(
} }
}); });
let number_open = use_state(|| false);
let name_open = use_state(|| false);
let pronouns_open = use_state(|| false);
let number = { let number = {
let current_value = use_state(String::new); let current_value = use_state(String::new);
let message_callback = message_callback.clone(); let message_callback = message_callback.clone();
@ -63,8 +66,7 @@ pub fn ClientNav(
.number .number
.map(|v| v.to_string()) .map(|v| v.to_string())
.unwrap_or_else(|| String::from("???")); .unwrap_or_else(|| String::from("???"));
let open = use_state(|| false); let open_set = number_open.setter();
let open_set = open.setter();
let on_submit = { let on_submit = {
let val = current_value.clone(); let val = current_value.clone();
Callback::from(move |_| { Callback::from(move |_| {
@ -85,19 +87,27 @@ pub fn ClientNav(
open_set.set(false); open_set.set(false);
}) })
}; };
let close_others = {
let name_open = name_open.clone();
let pronouns_open = pronouns_open.clone();
Callback::from(move |_| {
name_open.set(false);
pronouns_open.set(false);
})
};
html! { html! {
<ClickableNumberEdit <ClickableNumberEdit
value={current_value.clone()} value={current_value.clone()}
field_name="number" field_name="number"
on_submit={on_submit} on_submit={on_submit}
state={open} state={number_open.clone()}
on_open={close_others}
> >
<div class="number">{current_num}</div> <div class="number">{current_num}</div>
</ClickableNumberEdit> </ClickableNumberEdit>
} }
}; };
let name = { let name = {
let open = use_state(|| false);
let name = use_state(String::new); let name = use_state(String::new);
let on_submit = { let on_submit = {
let ident = identity.clone(); let ident = identity.clone();
@ -115,20 +125,29 @@ pub fn ClientNav(
}) })
}) })
}; };
let close_others = {
let number_open = number_open.clone();
let pronouns_open = pronouns_open.clone();
Callback::from(move |_| {
number_open.set(false);
pronouns_open.set(false);
})
};
html! { html! {
<ClickableTextEdit <ClickableTextEdit
value={name.clone()} value={name.clone()}
submit_ident={identity.clone()} submit_ident={identity.clone()}
field_name="pronouns" field_name="pronouns"
on_submit={on_submit} on_submit={on_submit}
state={open} state={name_open.clone()}
on_open={close_others}
> >
<div class="name">{identity.1.name.as_str()}</div> <div class="name">{identity.1.name.as_str()}</div>
</ClickableTextEdit> </ClickableTextEdit>
} }
}; };
let pronouns = { let pronouns = {
let pronouns_state = use_state(String::new); let pronuns_state = use_state(String::new);
let on_submit = { let on_submit = {
let ident = identity.clone(); let ident = identity.clone();
@ -145,14 +164,22 @@ pub fn ClientNav(
}) })
}) })
}; };
let open = use_state(|| false); let close_others = {
let number_open = number_open.clone();
let name_open = name_open.clone();
Callback::from(move |_| {
number_open.set(false);
name_open.set(false);
})
};
html! { html! {
<ClickableTextEdit <ClickableTextEdit
value={pronouns_state} value={pronuns_state}
submit_ident={identity.clone()} submit_ident={identity.clone()}
field_name="pronouns" field_name="pronouns"
on_submit={on_submit} on_submit={on_submit}
state={open} state={pronouns_open}
on_open={close_others}
> >
{pronouns} {pronouns}
</ClickableTextEdit> </ClickableTextEdit>
@ -199,6 +226,8 @@ struct ClickableTextEditProps {
pub state: UseStateHandle<bool>, pub state: UseStateHandle<bool>,
#[prop_or(100)] #[prop_or(100)]
pub max_length: usize, pub max_length: usize,
#[prop_or_default]
pub on_open: Option<Callback<()>>,
} }
#[function_component] #[function_component]
@ -211,6 +240,7 @@ fn ClickableTextEdit(
on_submit, on_submit,
state, state,
max_length, max_length,
on_open,
}: &ClickableTextEditProps, }: &ClickableTextEditProps,
) -> Html { ) -> Html {
let on_input = crate::components::input_element_string_oninput(value.setter(), *max_length); let on_input = crate::components::input_element_string_oninput(value.setter(), *max_length);
@ -237,7 +267,7 @@ fn ClickableTextEdit(
</div> </div>
}; };
html! { html! {
<ClickableField options={options} state={state.clone()}> <ClickableField options={options} state={state.clone()} on_open={on_open.clone()}>
{children.clone()} {children.clone()}
</ClickableField> </ClickableField>
} }

View File

@ -0,0 +1,139 @@
// 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 web_sys::Element;
use yew::prelude::*;
use crate::components::Button;
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct DialogProps {
#[prop_or_default]
pub children: Html,
pub options: Box<[String]>,
#[prop_or_default]
pub cancel_callback: Option<Callback<()>>,
pub callback: Callback<String>,
}
#[function_component]
pub fn Dialog(
DialogProps {
children,
options,
cancel_callback,
callback,
}: &DialogProps,
) -> Html {
let options = options
.iter()
.map(|opt| {
let callback = callback.clone();
let option = opt.clone();
let cb = Callback::from(move |_| {
callback.emit(option.clone());
});
html! {
<Button on_click={cb}>{opt.clone()}</Button>
}
})
.collect::<Html>();
let backdrop_click = cancel_callback.clone().map(|cancel_callback| {
Callback::from(move |ev: MouseEvent| {
if let Some(div) = ev.target_dyn_into::<Element>()
&& div.class_name() == "dialog"
{
ev.stop_propagation();
cancel_callback.emit(());
}
})
});
html! {
<div class="click-backdrop" onclick={backdrop_click}>
<div class="dialog">
<div class="dialog-box">
<div class="message">
{children.clone()}
</div>
<div class="options">
{options}
</div>
</div>
</div>
</div>
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct WithConfirmationProps {
pub state: UseStateHandle<bool>,
#[prop_or_default]
pub children: Html,
pub confirm_callback: Callback<()>,
pub message: Html,
}
#[function_component]
pub fn WithConfirmation(
WithConfirmationProps {
state,
children,
confirm_callback,
message,
}: &WithConfirmationProps,
) -> Html {
let about_dialog_state = state.clone();
let confirmation_dialog = about_dialog_state.then(|| {
let cancel_signout = {
let dialog = about_dialog_state.clone();
Callback::from(move |_| {
dialog.set(false);
})
};
let confirm_callback = confirm_callback.clone();
let callback = {
let dialog = about_dialog_state.clone();
Callback::from(move |opt: String| {
if opt == "ok" {
confirm_callback.emit(());
} else {
dialog.set(false);
}
})
};
let options: Box<[String]> = Box::new([String::from("ok"), String::from("take me back")]);
html! {
<Dialog
options={options}
cancel_callback={Some(cancel_signout)}
callback={callback}
>
{message.clone()}
</Dialog>
}
});
let confirmation_click = {
let dialog_set = about_dialog_state.setter();
move |_| {
dialog_set.set(true);
}
};
html! {
<>
<Button on_click={confirmation_click}>{children.clone()}</Button>
{confirmation_dialog}
</>
}
}

View File

@ -28,6 +28,8 @@ pub struct ClickableFieldProps {
#[prop_or_default] #[prop_or_default]
pub with_backdrop_exit: bool, pub with_backdrop_exit: bool,
pub state: UseStateHandle<bool>, pub state: UseStateHandle<bool>,
#[prop_or_default]
pub on_open: Option<Callback<()>>,
} }
#[function_component] #[function_component]
@ -39,11 +41,20 @@ pub fn ClickableField(
button_class, button_class,
with_backdrop_exit, with_backdrop_exit,
state, state,
on_open,
}: &ClickableFieldProps, }: &ClickableFieldProps,
) -> Html { ) -> Html {
let open = state.clone(); let open = state.clone();
let on_click_open = open.clone(); let open_close = {
let open_close = Callback::from(move |_| on_click_open.set(!(*on_click_open))); let open = open.clone();
let on_open = on_open.clone();
Callback::from(move |_| {
if !*open && let Some(on_open) = on_open.as_ref() {
on_open.emit(());
}
open.set(!(*open));
})
};
let submenu_open_close = open_close.clone(); let submenu_open_close = open_close.clone();
let submenu = open.clone().then(|| { let submenu = open.clone().then(|| {
let backdrop = with_backdrop_exit.then(|| { let backdrop = with_backdrop_exit.then(|| {
@ -79,6 +90,8 @@ pub struct ClickableNumberEditProps {
pub on_submit: Callback<()>, pub on_submit: Callback<()>,
pub field_name: &'static str, pub field_name: &'static str,
pub state: UseStateHandle<bool>, pub state: UseStateHandle<bool>,
#[prop_or_default]
pub on_open: Option<Callback<()>>,
} }
#[function_component] #[function_component]
@ -89,6 +102,7 @@ pub fn ClickableNumberEdit(
field_name, field_name,
on_submit, on_submit,
state, state,
on_open,
}: &ClickableNumberEditProps, }: &ClickableNumberEditProps,
) -> Html { ) -> Html {
let on_input = crate::components::input_element_string_oninput(value.setter(), 20); let on_input = crate::components::input_element_string_oninput(value.setter(), 20);
@ -101,7 +115,7 @@ pub fn ClickableNumberEdit(
</div> </div>
}; };
html! { html! {
<ClickableField options={options} state={state.clone()}> <ClickableField options={options} state={state.clone()} on_open={on_open.clone()}>
{children.clone()} {children.clone()}
</ClickableField> </ClickableField>
} }

View File

@ -40,6 +40,11 @@ use pages::{ErrorComponent, WerewolfError};
use web_sys::Url; use web_sys::Url;
use yew::{context::ContextProviderProps, prelude::*}; use yew::{context::ContextProviderProps, prelude::*};
const BUILD_ID: &str = werewolves_macros::build_id!();
const BUILD_ID_LONG: &str = werewolves_macros::build_id_long!();
const BUILD_DIRTY: bool = werewolves_macros::build_dirty!();
const BUILD_TIME: &str = werewolves_macros::build_time!();
use crate::clients::{ use crate::clients::{
client::{Client2, ClientContext}, client::{Client2, ClientContext},
host::{Host, HostEvent}, host::{Host, HostEvent},
@ -47,6 +52,7 @@ use crate::clients::{
fn main() { fn main() {
wasm_logger::init(wasm_logger::Config::new(log::Level::Trace)); wasm_logger::init(wasm_logger::Config::new(log::Level::Trace));
log::debug!("starting werewolves build {BUILD_ID}");
let document = gloo::utils::document(); let document = gloo::utils::document();
let url = document.document_uri().expect("get uri"); let url = document.document_uri().expect("get uri");
let url_obj = Url::new(&url).unwrap(); let url_obj = Url::new(&url).unwrap();