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

View File

@ -10,4 +10,5 @@ proc-macro = true
proc-macro2 = "1"
quote = "1"
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::{
io,
path::{Path, PathBuf},
process::Command,
};
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);
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(
changes: &mut ChangesLookup<'_>,
changes: &ChangesLookup<'_>,
target: CharacterId,
died_to: &DiedTo,
night: u8,
@ -133,7 +133,7 @@ pub fn resolve_kill(
night,
starves_if_fails: true,
} = died_to
&& let Some(protection) = changes.protected_take(target)
&& let Some(protection) = changes.protected(target)
{
return Ok(Some(
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)?;
match changes.protected_take(target) {
match changes.protected(target) {
Some(protection) => {
return Ok(resolve_protection(
killing_wolf.character_id(),
@ -162,7 +162,7 @@ pub fn resolve_kill(
None => {
// Wolf kill went through -- can kill shifter
return Ok(Some(KillOutcome::Single(
*ss_source,
ss_source,
DiedTo::Shapeshift {
into: target,
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,
None => return Ok(Some(KillOutcome::Single(target, died_to.clone()))),
};
@ -187,7 +187,7 @@ pub fn resolve_kill(
.ok_or(GameError::GuardianInvalidOriginalKill)?,
original_target: target,
original_kill: died_to.clone(),
guardian: source,
guardian: *source,
night: NonZeroU8::new(night).unwrap(),
})),
Protection::Guardian {

View File

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

View File

@ -12,7 +12,7 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use core::ops::Not;
use core::{num::NonZeroU8, ops::Not};
use super::Result;
use serde::{Deserialize, Serialize};
@ -22,7 +22,10 @@ use crate::{
aura::Aura,
character::CharacterId,
diedto::DiedTo,
game::{Village, kill},
game::{
Village,
kill::{self, KillOutcome},
},
player::Protection,
role::{RoleBlock, RoleTitle},
};
@ -112,20 +115,57 @@ impl<'a> ChangesLookup<'a> {
.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(
&mut self,
&self,
character_id: CharacterId,
night: u8,
village: &Village,
) -> Result<Option<DiedTo>> {
if let Some(died_to) = self.killed(character_id)
&& kill::resolve_kill(self, character_id, died_to, night, village)?.is_some()
{
Ok(Some(died_to.clone()))
let kill_outcomes = self.kill_outcomes(night, village)?;
Ok(kill_outcomes.into_iter().find_map(|outcome| match outcome {
KillOutcome::Single(target, died_to) => (target == character_id).then_some(died_to),
KillOutcome::Guarding {
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 {
Ok(None)
None
}
}
}))
}
pub fn killed(&self, target: CharacterId) -> Option<&'a DiedTo> {
self.0.iter().enumerate().find_map(|(idx, c)| {
@ -160,7 +200,7 @@ impl<'a> ChangesLookup<'a> {
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.1
.contains(&idx)
@ -169,20 +209,27 @@ impl<'a> ChangesLookup<'a> {
NightChange::Protection {
target: t,
protection,
} => (t == target).then_some(protection),
} => (*t == target).then_some(protection),
_ => None,
})
.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.1
.contains(&idx)
.not()
.then_some(match c {
NightChange::Shapeshift { source, .. } => Some(source),
NightChange::Shapeshift { .. } => Some(c.clone()),
_ => None,
})
.flatten()

View File

@ -21,7 +21,7 @@ use crate::{
error::GameError,
game::night::{CurrentResult, Night, NightState, changes::NightChange},
message::night::{ActionPrompt, ActionResult},
role::RoleBlock,
role::{RoleBlock, RoleTitle},
};
use super::Result;
@ -82,7 +82,7 @@ impl Night {
} => return Err(GameError::AwaitingResponse),
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
&& self.get_visits_for(character_id.character_id).is_empty()
{
@ -188,4 +188,25 @@ impl Night {
*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::Night { number } => number,
};
let mut changes = ChangesLookup::new(all_changes);
let changes = ChangesLookup::new(all_changes);
let mut new_village = self.clone();
@ -93,7 +93,7 @@ impl Village {
.died_to(hunter_character.character_id(), night, self)?
.is_some()
&& let Some(kill) = kill::resolve_kill(
&mut changes,
&changes,
*target,
&DiedTo::Hunter {
killer: *source,
@ -108,8 +108,7 @@ impl Village {
}
}
NightChange::Kill { target, died_to } => {
if let Some(kill) =
kill::resolve_kill(&mut changes, *target, died_to, night, self)?
if let Some(kill) = kill::resolve_kill(&changes, *target, died_to, night, self)?
{
if let KillOutcome::Guarding {
guardian,
@ -135,7 +134,7 @@ impl Village {
}
NightChange::Shapeshift { source, into } => {
if let Some(target) = changes.wolf_pack_kill_target()
&& changes.protected(target).is_none()
&& changes.protected(*target).is_none()
{
if *target != *into {
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
// along with this program. If not, see <https://www.gnu.org/licenses/>.
mod changes;
mod night_order;
mod previous;
mod revert;

View File

@ -100,10 +100,6 @@ fn redeemed_scapegoat_role_changes() {
game.mark_and_check(seer);
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();
assert_eq!(

View File

@ -820,12 +820,9 @@ clients {
}
.client-nav {
// position: absolute;
// left: 0;
// top: 0;
max-width: 100%;
padding: 10px;
// background-color: rgba(255, 107, 255, 0.2);
height: 37px;
display: flex;
flex-direction: row;
justify-content: baseline;
@ -957,6 +954,11 @@ input {
font-size: 2rem;
align-content: stretch;
margin: 0;
position: absolute;
left: 0;
z-index: 3;
background-color: #000;
min-width: 2cm;
& * {
margin: 0;
@ -2106,3 +2108,105 @@ li.choice {
.story-text {
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},
components::{
Button, CoverOfDarkness, Identity, Story,
client::{ClientNav, Signin},
client::{ClientFooter, ClientNav, Signin},
},
storage::StorageKey,
};
@ -253,6 +253,7 @@ pub fn Client2(ClientProps { auto_join }: &ClientProps) -> Html {
<>
{nav}
{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 current_value = use_state(String::new);
let message_callback = message_callback.clone();
@ -63,8 +66,7 @@ pub fn ClientNav(
.number
.map(|v| v.to_string())
.unwrap_or_else(|| String::from("???"));
let open = use_state(|| false);
let open_set = open.setter();
let open_set = number_open.setter();
let on_submit = {
let val = current_value.clone();
Callback::from(move |_| {
@ -85,19 +87,27 @@ pub fn ClientNav(
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! {
<ClickableNumberEdit
value={current_value.clone()}
field_name="number"
on_submit={on_submit}
state={open}
state={number_open.clone()}
on_open={close_others}
>
<div class="number">{current_num}</div>
</ClickableNumberEdit>
}
};
let name = {
let open = use_state(|| false);
let name = use_state(String::new);
let on_submit = {
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! {
<ClickableTextEdit
value={name.clone()}
submit_ident={identity.clone()}
field_name="pronouns"
on_submit={on_submit}
state={open}
state={name_open.clone()}
on_open={close_others}
>
<div class="name">{identity.1.name.as_str()}</div>
</ClickableTextEdit>
}
};
let pronouns = {
let pronouns_state = use_state(String::new);
let pronuns_state = use_state(String::new);
let on_submit = {
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! {
<ClickableTextEdit
value={pronouns_state}
value={pronuns_state}
submit_ident={identity.clone()}
field_name="pronouns"
on_submit={on_submit}
state={open}
state={pronouns_open}
on_open={close_others}
>
{pronouns}
</ClickableTextEdit>
@ -199,6 +226,8 @@ struct ClickableTextEditProps {
pub state: UseStateHandle<bool>,
#[prop_or(100)]
pub max_length: usize,
#[prop_or_default]
pub on_open: Option<Callback<()>>,
}
#[function_component]
@ -211,6 +240,7 @@ fn ClickableTextEdit(
on_submit,
state,
max_length,
on_open,
}: &ClickableTextEditProps,
) -> Html {
let on_input = crate::components::input_element_string_oninput(value.setter(), *max_length);
@ -237,7 +267,7 @@ fn ClickableTextEdit(
</div>
};
html! {
<ClickableField options={options} state={state.clone()}>
<ClickableField options={options} state={state.clone()} on_open={on_open.clone()}>
{children.clone()}
</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]
pub with_backdrop_exit: bool,
pub state: UseStateHandle<bool>,
#[prop_or_default]
pub on_open: Option<Callback<()>>,
}
#[function_component]
@ -39,11 +41,20 @@ pub fn ClickableField(
button_class,
with_backdrop_exit,
state,
on_open,
}: &ClickableFieldProps,
) -> Html {
let open = state.clone();
let on_click_open = open.clone();
let open_close = Callback::from(move |_| on_click_open.set(!(*on_click_open)));
let open_close = {
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.clone().then(|| {
let backdrop = with_backdrop_exit.then(|| {
@ -79,6 +90,8 @@ pub struct ClickableNumberEditProps {
pub on_submit: Callback<()>,
pub field_name: &'static str,
pub state: UseStateHandle<bool>,
#[prop_or_default]
pub on_open: Option<Callback<()>>,
}
#[function_component]
@ -89,6 +102,7 @@ pub fn ClickableNumberEdit(
field_name,
on_submit,
state,
on_open,
}: &ClickableNumberEditProps,
) -> Html {
let on_input = crate::components::input_element_string_oninput(value.setter(), 20);
@ -101,7 +115,7 @@ pub fn ClickableNumberEdit(
</div>
};
html! {
<ClickableField options={options} state={state.clone()}>
<ClickableField options={options} state={state.clone()} on_open={on_open.clone()}>
{children.clone()}
</ClickableField>
}

View File

@ -40,6 +40,11 @@ use pages::{ErrorComponent, WerewolfError};
use web_sys::Url;
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::{
client::{Client2, ClientContext},
host::{Host, HostEvent},
@ -47,6 +52,7 @@ use crate::clients::{
fn main() {
wasm_logger::init(wasm_logger::Config::new(log::Level::Trace));
log::debug!("starting werewolves build {BUILD_ID}");
let document = gloo::utils::document();
let url = document.document_uri().expect("get uri");
let url_obj = Url::new(&url).unwrap();