Compare commits

...

3 Commits

Author SHA1 Message Date
emilis 3388894504
daytime parity/dead info clarity 2025-11-30 14:32:39 +00:00
emilis c2f33e670c
small fixes, see commit message
killing roles no longer get skipped if they die
actually, no roles except intel + mason recruit get skipped
if their source dies that night
scapegoats can no longer redeem into apprentices
limited killers (militia/alpha wolf): protection causes them to expend
their shot (shapeshifter still safe)
insomniac: test for dire wolf block (blocks don't count as visits)
2025-11-19 15:22:10 +00:00
emilis bc7cb5b2cb
vindicator: activate on alignment, not team 2025-11-19 10:36:46 +00:00
23 changed files with 812 additions and 369 deletions

View File

@ -570,9 +570,9 @@ pub fn all(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
quote! {#all}.into()
}
#[proc_macro]
#[proc_macro_derive(RefAndMut)]
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 = ref_and_mut::RefAndMut::parse(parse_macro_input!(input)).unwrap();
quote! {#ref_and_mut}.into()
}
#[proc_macro]

View File

@ -1,3 +1,4 @@
use convert_case::Casing;
// Copyright (C) 2025 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
@ -13,24 +14,237 @@
// 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 quote::{ToTokens, quote};
use syn::parse::Parse;
use syn::{Fields, FieldsNamed, FieldsUnnamed, Ident, spanned::Spanned};
#[allow(unused)]
pub struct RefAndMut {
name: syn::Ident,
variants: Vec<(Ident, Fields)>,
}
impl Parse for RefAndMut {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
// let type_path = input.parse::<syn::TypePath>()?;
// let matching = input.parse::<syn::PatStruct>()?;
let name = input.parse::<syn::Ident>()?;
// panic!("{type_path:?}\n\n{matching:?}");
Ok(Self { name })
impl RefAndMut {
pub fn parse(input: syn::DeriveInput) -> syn::Result<Self> {
let variants = match input.data {
syn::Data::Enum(data) => data.variants,
syn::Data::Struct(_) | syn::Data::Union(_) => {
return Err(syn::Error::new(
input.span(),
"RefAndMut can only be used on enums",
));
}
};
Ok(Self {
name: input.ident,
variants: variants.into_iter().map(|v| (v.ident, v.fields)).collect(),
})
}
}
impl ToTokens for RefAndMut {
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
for (field, value) in self.variants.iter().cloned() {
match value {
Fields::Named(named) => named_fields(self.name.clone(), field, named, tokens),
Fields::Unnamed(unnamed) => {
unnamed_fields(self.name.clone(), field, unnamed, tokens)
}
Fields::Unit => continue,
}
}
tokens.extend(quote! {});
}
}
fn named_fields(
struct_name: Ident,
name: Ident,
fields: FieldsNamed,
tokens: &mut proc_macro2::TokenStream,
) {
let base_name = name.to_string().to_case(convert_case::Case::Pascal);
let name_ref = Ident::new(format!("{base_name}Ref").as_str(), name.span());
let fn_name_ref = Ident::new(
format!(
"{}_ref",
name.to_string().to_case(convert_case::Case::Snake)
)
.as_str(),
name.span(),
);
let fn_name_mut = Ident::new(
format!(
"{}_mut",
name.to_string().to_case(convert_case::Case::Snake)
)
.as_str(),
name.span(),
);
let name_mut = Ident::new(format!("{base_name}Mut").as_str(), name.span());
let struct_fields_ref = fields
.named
.iter()
.map(|c| {
let name = c.ident.as_ref().expect("this is a named field??");
let ty = &c.ty;
quote! {
pub #name: &'a #ty,
}
})
.collect::<Box<[_]>>();
let struct_fields_mut = fields
.named
.iter()
.map(|c| {
let name = c.ident.as_ref().expect("this is a named field??");
let ty = &c.ty;
quote! {
pub #name: &'a mut #ty,
}
})
.collect::<Box<[_]>>();
let fields_idents = fields
.named
.iter()
.map(|c| c.ident.as_ref().expect("this is a named field??"))
.collect::<Box<[_]>>();
let for_character = (struct_name == "Role").then_some(quote!{
impl crate::character::Character {
pub fn #fn_name_ref<'a>(&'a self) -> core::result::Result<#name_ref<'a>, crate::error::GameError> {
let got = self.role_title();
self.role().#fn_name_ref().ok_or(crate::error::GameError::InvalidRole {
got,
expected: RoleTitle::#name,
})
}
pub fn #fn_name_mut<'a>(&'a mut self) -> core::result::Result<#name_mut<'a>, crate::error::GameError> {
let got = self.role_title();
self.role_mut().#fn_name_mut().ok_or(crate::error::GameError::InvalidRole {
got,
expected: RoleTitle::#name,
})
}
}
});
tokens.extend(quote! {
pub struct #name_ref<'a> {
#(#struct_fields_ref)*
}
pub struct #name_mut<'a> {
#(#struct_fields_mut)*
}
#for_character
impl #struct_name {
pub fn #fn_name_ref<'a>(&'a self) -> Option<#name_ref<'a>> {
match self {
Self::#name{ #(#fields_idents),* } => Some(#name_ref {#(#fields_idents),* }),
_ => None,
}
}
pub fn #fn_name_mut<'a>(&'a mut self) -> Option<#name_mut<'a>> {
match self {
Self::#name{ #(#fields_idents),* } => Some(#name_mut {#(#fields_idents),* }),
_ => None,
}
}
}
});
}
fn unnamed_fields(
struct_name: Ident,
name: Ident,
fields: FieldsUnnamed,
tokens: &mut proc_macro2::TokenStream,
) {
let base_name = name.to_string().to_case(convert_case::Case::Pascal);
let name_ref = Ident::new(format!("{base_name}Ref").as_str(), name.span());
let fn_name_ref = Ident::new(
format!(
"{}_ref",
name.to_string().to_case(convert_case::Case::Snake)
)
.as_str(),
name.span(),
);
let fn_name_mut = Ident::new(
format!(
"{}_mut",
name.to_string().to_case(convert_case::Case::Snake)
)
.as_str(),
name.span(),
);
let name_mut = Ident::new(format!("{base_name}Mut").as_str(), name.span());
let fields_ref = fields
.unnamed
.iter()
.map(|field| {
let ty = &field.ty;
quote! {
pub &'a #ty
}
})
.collect::<Box<[_]>>();
let fields_mut = fields
.unnamed
.iter()
.map(|field| {
let ty = &field.ty;
quote! {
pub &'a mut #ty
}
})
.collect::<Box<[_]>>();
let fields_names = fields
.unnamed
.iter()
.enumerate()
.map(|(idx, field)| Ident::new(format!("field{idx}").as_str(), field.span()))
.collect::<Box<[_]>>();
let for_character = (struct_name == "Role").then_some(quote! {
impl crate::character::Character {
pub fn #fn_name_ref<'a>(&'a self) -> core::result::Result<#name_ref<'a>, crate::error::GameError> {
let got = self.role_title();
self.role().#fn_name_ref().ok_or(crate::error::GameError::InvalidRole {
got,
expected: RoleTitle::#name,
})
}
pub fn #fn_name_mut<'a>(&'a mut self) -> core::result::Result<#name_mut<'a>, crate::error::GameError> {
let got = self.role_title();
self.role_mut().#fn_name_mut().ok_or(crate::error::GameError::InvalidRole {
got,
expected: RoleTitle::#name,
})
}
}
});
tokens.extend(quote! {
pub struct #name_ref<'a>(#(#fields_ref),*);
pub struct #name_mut<'a>(#(#fields_mut),*);
#for_character
impl #struct_name {
pub fn #fn_name_ref<'a>(&'a self) -> Option<#name_ref<'a>> {
match self {
Self::#name(#(#fields_names),*) => Some(#name_ref(#(#fields_names),*)),
_ => None,
}
}
pub fn #fn_name_mut<'a>(&'a mut self) -> Option<#name_mut<'a>> {
match self {
Self::#name(#(#fields_names),*) => Some(#name_mut(#(#fields_names),*)),
_ => None,
}
}
}
});
}

View File

@ -29,8 +29,8 @@ use crate::{
message::{CharacterIdentity, Identification, PublicIdentity, night::ActionPrompt},
player::{PlayerId, RoleChange},
role::{
Alignment, Killer, KillingWolfOrder, MAPLE_WOLF_ABSTAIN_LIMIT, Powerful,
PreviousGuardianAction, Role, RoleTitle,
Alignment, HunterMut, HunterRef, Killer, KillingWolfOrder, MAPLE_WOLF_ABSTAIN_LIMIT,
Powerful, PreviousGuardianAction, Role, RoleTitle,
},
};
@ -93,8 +93,12 @@ impl Character {
})
}
pub const fn is_power_role(&self) -> bool {
!matches!(&self.role, Role::Scapegoat { .. } | Role::Villager)
pub const fn scapegoat_can_redeem_into(&self) -> bool {
!self.role.wolf()
&& !matches!(
&self.role,
Role::Scapegoat { .. } | Role::Villager | Role::Apprentice(_)
)
}
pub fn identity(&self) -> CharacterIdentity {
@ -314,7 +318,7 @@ impl Character {
pub fn night_action_prompts(&self, village: &Village) -> Result<Box<[ActionPrompt]>> {
let mut prompts = Vec::new();
if self.mason_leader().is_ok() {
if let Role::MasonLeader { .. } = &self.role {
// add them here so masons wake up even with a dead leader
prompts.append(&mut self.mason_prompts(village)?);
}
@ -358,7 +362,7 @@ impl Character {
dead.shuffle(&mut rand::rng());
if let Some(pr) = dead
.into_iter()
.find_map(|d| (d.is_village() && d.is_power_role()).then_some(d.role_title()))
.find_map(|d| d.scapegoat_can_redeem_into().then_some(d.role_title()))
{
prompts.push(ActionPrompt::RoleChange {
character_id: self.identity(),
@ -552,7 +556,7 @@ impl Character {
&& village
.executions_on_day(last_day)
.iter()
.any(|c| c.is_village())
.any(|c| c.alignment().village())
{
prompts.push(ActionPrompt::Vindicator {
character_id: self.identity(),
@ -576,15 +580,21 @@ impl Character {
Ok(prompts.into_boxed_slice())
}
#[cfg(test)]
pub const fn role(&self) -> &Role {
&self.role
}
pub const fn killing_wolf_order(&self) -> Option<KillingWolfOrder> {
self.role.killing_wolf_order()
}
pub fn black_knight_kill(&mut self) -> Result<()> {
let attacked = self.black_knight_ref()?.attacked;
if let Some(attacked) = attacked.as_ref()
&& let Some(next) = attacked.next_night()
&& self.died_to.is_none()
{
self.died_to = Some(next);
}
Ok(())
}
pub fn alignment(&self) -> Alignment {
if let Some(alignment) = self.auras.overrides_alignment() {
return alignment;
@ -615,323 +625,28 @@ impl Character {
self.role.powerful()
}
pub const fn hunter<'a>(&'a self) -> Result<Hunter<'a>> {
match &self.role {
Role::Hunter { target } => Ok(Hunter(target)),
_ => Err(GameError::InvalidRole {
expected: RoleTitle::Hunter,
got: self.role_title(),
}),
}
}
pub const fn hunter_mut<'a>(&'a mut self) -> Result<HunterMut<'a>> {
let title = self.role.title();
match &mut self.role {
Role::Hunter { target } => Ok(HunterMut(target)),
_ => Err(GameError::InvalidRole {
expected: RoleTitle::Hunter,
got: title,
}),
}
}
pub const fn shapeshifter<'a>(&'a self) -> Result<Shapeshifter<'a>> {
match &self.role {
Role::Shapeshifter { shifted_into } => Ok(Shapeshifter(shifted_into)),
_ => Err(GameError::InvalidRole {
expected: RoleTitle::Shapeshifter,
got: self.role_title(),
}),
}
}
pub const fn shapeshifter_mut<'a>(&'a mut self) -> Result<ShapeshifterMut<'a>> {
let title = self.role.title();
match &mut self.role {
Role::Shapeshifter { shifted_into } => Ok(ShapeshifterMut(shifted_into)),
_ => Err(GameError::InvalidRole {
expected: RoleTitle::Shapeshifter,
got: title,
}),
}
}
pub const fn mason_leader<'a>(&'a self) -> Result<MasonLeader<'a>> {
match &self.role {
Role::MasonLeader {
recruits_available,
recruits,
} => Ok(MasonLeader(recruits_available, recruits)),
_ => Err(GameError::InvalidRole {
expected: RoleTitle::MasonLeader,
got: self.role_title(),
}),
}
}
pub const fn mason_leader_mut<'a>(&'a mut self) -> Result<MasonLeaderMut<'a>> {
let title = self.role.title();
match &mut self.role {
Role::MasonLeader {
recruits_available,
recruits,
} => Ok(MasonLeaderMut(recruits_available, recruits)),
_ => Err(GameError::InvalidRole {
expected: RoleTitle::MasonLeader,
got: title,
}),
}
}
pub const fn scapegoat<'a>(&'a self) -> Result<Scapegoat<'a>> {
match &self.role {
Role::Scapegoat { redeemed } => Ok(Scapegoat(redeemed)),
_ => Err(GameError::InvalidRole {
expected: RoleTitle::Scapegoat,
got: self.role_title(),
}),
}
}
pub const fn scapegoat_mut<'a>(&'a mut self) -> Result<ScapegoatMut<'a>> {
let title = self.role.title();
match &mut self.role {
Role::Scapegoat { redeemed } => Ok(ScapegoatMut(redeemed)),
_ => Err(GameError::InvalidRole {
expected: RoleTitle::Scapegoat,
got: title,
}),
}
}
pub const fn empath<'a>(&'a self) -> Result<Empath<'a>> {
match &self.role {
Role::Empath { cursed } => Ok(Empath(cursed)),
_ => Err(GameError::InvalidRole {
expected: RoleTitle::Empath,
got: self.role_title(),
}),
}
}
pub const fn empath_mut<'a>(&'a mut self) -> Result<EmpathMut<'a>> {
let title = self.role.title();
match &mut self.role {
Role::Empath { cursed } => Ok(EmpathMut(cursed)),
_ => Err(GameError::InvalidRole {
expected: RoleTitle::Empath,
got: title,
}),
}
}
pub const fn black_knight<'a>(&'a self) -> Result<BlackKnight<'a>> {
match &self.role {
Role::BlackKnight { attacked } => Ok(BlackKnight(attacked)),
_ => Err(GameError::InvalidRole {
expected: RoleTitle::BlackKnight,
got: self.role_title(),
}),
}
}
pub const fn black_knight_kill<'a>(&'a mut self) -> Result<BlackKnightKill<'a>> {
let title = self.role.title();
match &self.role {
Role::BlackKnight { attacked } => Ok(BlackKnightKill {
attacked,
died_to: &mut self.died_to,
}),
_ => Err(GameError::InvalidRole {
expected: RoleTitle::BlackKnight,
got: title,
}),
}
}
pub const fn black_knight_mut<'a>(&'a mut self) -> Result<BlackKnightMut<'a>> {
let title = self.role.title();
match &mut self.role {
Role::BlackKnight { attacked } => Ok(BlackKnightMut(attacked)),
_ => Err(GameError::InvalidRole {
expected: RoleTitle::BlackKnight,
got: title,
}),
}
}
pub const fn guardian<'a>(&'a self) -> Result<Guardian<'a>> {
let title = self.role.title();
match &self.role {
Role::Guardian { last_protected } => Ok(Guardian(last_protected)),
_ => Err(GameError::InvalidRole {
expected: RoleTitle::Guardian,
got: title,
}),
}
}
pub const fn guardian_mut<'a>(&'a mut self) -> Result<GuardianMut<'a>> {
let title = self.role.title();
match &mut self.role {
Role::Guardian { last_protected } => Ok(GuardianMut(last_protected)),
_ => Err(GameError::InvalidRole {
expected: RoleTitle::Guardian,
got: title,
}),
}
}
pub const fn direwolf<'a>(&'a self) -> Result<Direwolf<'a>> {
let title = self.role.title();
match &self.role {
Role::DireWolf { last_blocked } => Ok(Direwolf(last_blocked)),
_ => Err(GameError::InvalidRole {
expected: RoleTitle::DireWolf,
got: title,
}),
}
}
pub const fn direwolf_mut<'a>(&'a mut self) -> Result<DirewolfMut<'a>> {
let title = self.role.title();
match &mut self.role {
Role::DireWolf { last_blocked } => Ok(DirewolfMut(last_blocked)),
_ => Err(GameError::InvalidRole {
expected: RoleTitle::DireWolf,
got: title,
}),
}
}
pub const fn militia<'a>(&'a self) -> Result<Militia<'a>> {
let title = self.role.title();
match &self.role {
Role::Militia { targeted } => Ok(Militia(targeted)),
_ => Err(GameError::InvalidRole {
expected: RoleTitle::Militia,
got: title,
}),
}
}
pub const fn militia_mut<'a>(&'a mut self) -> Result<MilitiaMut<'a>> {
let title = self.role.title();
match &mut self.role {
Role::Militia { targeted } => Ok(MilitiaMut(targeted)),
_ => Err(GameError::InvalidRole {
expected: RoleTitle::Militia,
got: title,
}),
}
}
pub const fn maple_wolf_mut<'a>(&'a mut self) -> Result<MapleWolfMut<'a>> {
let title = self.role.title();
match &mut self.role {
Role::MapleWolf { last_kill_on_night } => Ok(MapleWolfMut(last_kill_on_night)),
_ => Err(GameError::InvalidRole {
expected: RoleTitle::MapleWolf,
got: title,
}),
}
}
pub const fn protector_mut<'a>(&'a mut self) -> Result<ProtectorMut<'a>> {
let title = self.role.title();
match &mut self.role {
Role::Protector { last_protected } => Ok(ProtectorMut(last_protected)),
_ => Err(GameError::InvalidRole {
expected: RoleTitle::Protector,
got: title,
}),
}
}
pub const fn initial_shown_role(&self) -> RoleTitle {
self.role.initial_shown_role()
}
}
macro_rules! decl_ref_and_mut {
($($name:ident, $name_mut:ident: $contains:ty;)*) => {
$(
pub struct $name<'a>(&'a $contains);
impl core::ops::Deref for $name<'_> {
type Target = $contains;
#[doc(hidden)]
pub fn role(&self) -> &Role {
&self.role
}
fn deref(&self) -> &Self::Target {
self.0
}
}
pub struct $name_mut<'a>(&'a mut $contains);
impl core::ops::Deref for $name_mut<'_> {
type Target = $contains;
fn deref(&self) -> &Self::Target {
self.0
}
}
impl core::ops::DerefMut for $name_mut<'_> {
fn deref_mut(&mut self) -> &mut Self::Target {
self.0
}
}
)*
};
}
decl_ref_and_mut!(
Hunter, HunterMut: Option<CharacterId>;
Shapeshifter, ShapeshifterMut: Option<CharacterId>;
Scapegoat, ScapegoatMut: bool;
Empath, EmpathMut: bool;
BlackKnight, BlackKnightMut: Option<DiedTo>;
Guardian, GuardianMut: Option<PreviousGuardianAction>;
Direwolf, DirewolfMut: Option<CharacterId>;
Militia, MilitiaMut: Option<CharacterId>;
MapleWolf, MapleWolfMut: u8;
Protector, ProtectorMut: Option<CharacterId>;
);
pub struct BlackKnightKill<'a> {
attacked: &'a Option<DiedTo>,
died_to: &'a mut Option<DiedTo>,
}
impl BlackKnightKill<'_> {
pub fn kill(self) {
if let Some(attacked) = self.attacked.as_ref().and_then(|a| a.next_night())
&& self.died_to.is_none()
{
self.died_to.replace(attacked.clone());
}
#[doc(hidden)]
pub fn role_mut(&mut self) -> &mut Role {
&mut self.role
}
}
pub struct MasonLeader<'a>(&'a u8, &'a [CharacterId]);
impl MasonLeader<'_> {
pub const fn remaining_recruits(&self) -> u8 {
*self.0
}
pub const fn recruits(&self) -> usize {
self.1.len()
}
}
pub struct MasonLeaderMut<'a>(&'a mut u8, &'a mut Box<[CharacterId]>);
impl MasonLeaderMut<'_> {
pub const fn remaining_recruits(&self) -> u8 {
*self.0
}
// pub struct MasonLeaderMut<'a>(&'a mut u8, &'a mut Box<[CharacterId]>);
impl crate::role::MasonLeaderMut<'_> {
pub fn recruit(self, target: CharacterId) {
let mut recruits = self.1.to_vec();
let mut recruits = self.recruits.to_vec();
recruits.push(target);
*self.1 = recruits.into_boxed_slice();
if let Some(new) = self.0.checked_sub(1) {
*self.0 = new;
}
*self.recruits = recruits.into_boxed_slice();
*self.recruits_available = self.recruits_available.saturating_sub(1);
}
}

View File

@ -53,6 +53,7 @@ impl KillOutcome {
&& let Some(existing) = village
.character_by_id_mut(killer)?
.militia_mut()?
.targeted
.replace(character_id)
{
log::error!("militia kill after already recording a kill on {existing}");

View File

@ -458,7 +458,12 @@ impl Night {
.dead_characters()
.into_iter()
.filter_map(|c| c.died_to().map(|d| (c, d)))
.filter_map(|(c, d)| c.hunter().ok().and_then(|h| *h).map(|t| (c, t, d)))
.filter_map(|(c, d)| {
c.hunter_ref()
.ok()
.and_then(|h| *h.target)
.map(|t| (c, t, d))
})
.filter_map(|(c, t, d)| match d.date_time() {
GameTime::Day { number } => (number.get() == night.get()).then_some((c, t)),
GameTime::Night { number: _ } => None,

View File

@ -20,7 +20,7 @@ use crate::{
diedto::DiedTo,
error::GameError,
game::night::{CurrentResult, Night, NightState, changes::NightChange},
message::night::{ActionPrompt, ActionResult},
message::night::{ActionPrompt, ActionResult, ActionType},
role::{RoleBlock, RoleTitle},
};
@ -194,8 +194,14 @@ impl Night {
.village
.characters()
.into_iter()
.any(|c| matches!(c.role_title(), RoleTitle::Beholder));
.any(|c| matches!(c.role_title(), RoleTitle::Beholder | RoleTitle::Insomniac));
while let Some(prompt) = self.action_queue.pop_front() {
if !matches!(
prompt.action_type(),
ActionType::Intel | ActionType::Insomniac
) {
return Ok(Some(prompt));
}
let Some(char_id) = prompt.character_id() else {
return Ok(Some(prompt));
};

View File

@ -88,7 +88,7 @@ impl Village {
.role_change(*role_title, GameTime::Night { number: night })?,
NightChange::HunterTarget { source, target } => {
let hunter_character = new_village.character_by_id_mut(*source).unwrap();
hunter_character.hunter_mut()?.replace(*target);
hunter_character.hunter_mut()?.target.replace(*target);
if changes
.died_to(hunter_character.character_id(), night, self)?
.is_some()
@ -126,11 +126,28 @@ impl Village {
if let DiedTo::MapleWolf { source, .. } = died_to
&& let Ok(maple) = new_village.character_by_id_mut(*source)
{
*maple.maple_wolf_mut()? = night;
*maple.maple_wolf_mut()?.last_kill_on_night = night;
}
} else {
recorded_changes.retain(|c| c != change);
}
match died_to {
DiedTo::Militia { killer, .. } => {
new_village
.character_by_id_mut(*killer)?
.militia_mut()?
.targeted
.replace(*target);
}
DiedTo::AlphaWolf { killer, .. } => {
new_village
.character_by_id_mut(*killer)?
.alpha_wolf_mut()?
.killed
.replace(*target);
}
_ => {}
};
}
NightChange::Shapeshift { source, into } => {
if let Some(target) = changes.wolf_pack_kill_target()
@ -141,7 +158,7 @@ impl Village {
continue;
}
let ss = new_village.character_by_id_mut(*source).unwrap();
ss.shapeshifter_mut().unwrap().replace(*target);
ss.shapeshifter_mut().unwrap().shifted_into.replace(*target);
ss.kill(DiedTo::Shapeshift {
into: *target,
night: NonZeroU8::new(night).unwrap(),
@ -160,6 +177,7 @@ impl Village {
new_village
.character_by_id_mut(*source)?
.guardian_mut()?
.last_protected
.replace(if *guarding {
PreviousGuardianAction::Guard(target)
} else {
@ -174,7 +192,8 @@ impl Village {
} => {
new_village
.character_by_id_mut(*source)?
.direwolf_mut()?
.dire_wolf_mut()?
.last_blocked
.replace(*target);
recorded_changes.retain(|c| {
@ -206,7 +225,10 @@ impl Village {
new_village
.character_by_id_mut(*scapegoat)?
.role_change(RoleTitle::Villager, GameTime::Night { number: night })?;
*new_village.character_by_id_mut(*empath)?.empath_mut()? = true;
*new_village
.character_by_id_mut(*empath)?
.empath_mut()?
.cursed = true;
}
NightChange::LostAura { character, aura } => {
new_village
@ -223,6 +245,7 @@ impl Village {
new_village
.character_by_id_mut(*source)?
.guardian_mut()?
.last_protected
.replace(PreviousGuardianAction::Guard(target_ident));
}
Protection::Guardian {
@ -232,12 +255,14 @@ impl Village {
new_village
.character_by_id_mut(*source)?
.guardian_mut()?
.last_protected
.replace(PreviousGuardianAction::Protect(target_ident));
}
Protection::Protector { source } => {
new_village
.character_by_id_mut(*source)?
.protector_mut()?
.last_protected
.replace(*target);
}
Protection::Vindicator { .. } => {}
@ -250,10 +275,15 @@ impl Village {
.characters_mut()
.into_iter()
.filter(|k| k.alive())
.filter(|k| k.black_knight().ok().and_then(|t| (*t).clone()).is_some())
.filter(|k| {
k.black_knight_ref()
.ok()
.and_then(|t| (*t.attacked).clone())
.is_some()
})
.filter(|k| changes.killed(k.character_id()).is_none())
{
knight.black_knight_kill()?.kill();
knight.black_knight_kill()?;
}
// pyre masters death

View File

@ -934,6 +934,10 @@ fn big_game_test_based_on_story_test() {
game.mark(protect.character_id());
game.r#continue().sleep();
game.next().title().vindicator();
game.mark_villager();
game.r#continue().sleep();
game.next().title().wolf_pack_kill();
game.mark(protect.character_id());
game.r#continue().r#continue();

View File

@ -348,6 +348,10 @@ fn previous_prompt() {
game.mark(protect.character_id());
game.r#continue().sleep();
game.next().title().vindicator();
game.mark_villager();
game.r#continue().sleep();
game.next().title().wolf_pack_kill();
game.mark(protect.character_id());
game.r#continue().r#continue();

View File

@ -115,8 +115,9 @@ fn block_on_guardian_target_prevents_the_visit() {
assert_eq!(
game.character_by_player_id(guardian)
.guardian()
.guardian_ref()
.unwrap()
.last_protected
.clone(),
None
);

View File

@ -266,6 +266,8 @@ fn protects_from_militia() {
);
assert_eq!(
game.character_by_player_id(militia).role().clone(),
Role::Militia { targeted: None }
Role::Militia {
targeted: Some(protected.character_id())
}
);
}

View File

@ -89,3 +89,128 @@ fn sees_visits() {
]))
);
}
#[test]
fn direwolf_block_prevents_visits_so_they_are_not_seen() {
let players = gen_players(1..21);
let mut player_ids = players.iter().map(|p| p.player_id);
let insomniac = player_ids.next().unwrap();
let wolf = player_ids.next().unwrap();
let seer = player_ids.next().unwrap();
let arcanist = player_ids.next().unwrap();
let direwolf = player_ids.next().unwrap();
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::Insomniac, insomniac);
settings.add_and_assign(SetupRole::Werewolf, wolf);
settings.add_and_assign(SetupRole::Seer, seer);
settings.add_and_assign(SetupRole::Arcanist, arcanist);
settings.add_and_assign(SetupRole::DireWolf, direwolf);
settings.fill_remaining_slots_with_villagers(players.len());
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().direwolf();
game.mark(game.character_by_player_id(seer).character_id());
game.r#continue().sleep();
game.next().title().seer();
game.mark(game.living_villager().character_id());
game.r#continue().seer().village();
game.r#continue().sleep();
game.next().title().arcanist();
let mut villagers = game.villager_character_ids().into_iter();
game.mark(villagers.next().unwrap());
game.mark(villagers.next().unwrap());
assert_eq!(game.r#continue().arcanist(), true);
game.r#continue().sleep();
game.next_expect_day();
game.execute().title().wolf_pack_kill();
game.mark(game.living_villager().character_id());
game.r#continue().r#continue();
game.next().title().direwolf();
game.mark(game.character_by_player_id(insomniac).character_id());
game.r#continue().sleep();
game.next().title().seer();
game.mark(game.character_by_player_id(insomniac).character_id());
game.r#continue().role_blocked();
game.r#continue().sleep();
game.next().title().arcanist();
game.mark(game.character_by_player_id(seer).character_id());
game.mark(game.character_by_player_id(insomniac).character_id());
game.r#continue().role_blocked();
game.r#continue().sleep();
game.next().title().insomniac();
assert_eq!(
game.r#continue().insomniac(),
Visits::new(Box::new(
[game.character_by_player_id(direwolf).identity(),]
))
);
}
#[test]
fn dead_people_still_get_prompts_to_trigger_visits() {
let players = gen_players(1..21);
let mut player_ids = players.iter().map(|p| p.player_id);
let insomniac = player_ids.next().unwrap();
let wolf = player_ids.next().unwrap();
let seer = player_ids.next().unwrap();
let arcanist = player_ids.next().unwrap();
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::Insomniac, insomniac);
settings.add_and_assign(SetupRole::Werewolf, wolf);
settings.add_and_assign(SetupRole::Seer, seer);
settings.add_and_assign(SetupRole::Arcanist, arcanist);
settings.fill_remaining_slots_with_villagers(players.len());
let mut game = Game::new(&players, settings).unwrap();
game.r#continue().r#continue();
assert_eq!(game.next().title(), ActionPromptTitle::WolvesIntro);
game.r#continue().sleep();
game.next().title().seer();
game.mark(game.living_villager().character_id());
game.r#continue().seer().village();
game.r#continue().sleep();
game.next().title().arcanist();
let mut villagers = game.villager_character_ids().into_iter();
game.mark(villagers.next().unwrap());
game.mark(villagers.next().unwrap());
assert_eq!(game.r#continue().arcanist(), true);
game.r#continue().sleep();
game.next_expect_day();
game.execute().title().wolf_pack_kill();
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(insomniac).character_id());
game.r#continue().seer().village();
game.r#continue().sleep();
game.next().title().arcanist();
game.mark(game.character_by_player_id(seer).character_id());
game.mark(game.character_by_player_id(insomniac).character_id());
assert!(game.r#continue().arcanist());
game.r#continue().sleep();
game.next().title().insomniac();
assert_eq!(
game.r#continue().insomniac(),
Visits::new(Box::new([
game.character_by_player_id(seer).identity(),
game.character_by_player_id(arcanist).identity()
]))
);
}

View File

@ -56,7 +56,11 @@ fn maple_starves() {
game.next_expect_day();
assert_eq!(
*game.character_by_player_id(maple).maple_wolf_mut().unwrap(),
*game
.character_by_player_id(maple)
.maple_wolf_mut()
.unwrap()
.last_kill_on_night,
0
);
@ -111,7 +115,11 @@ fn maple_last_eat_counter_increments() {
game.next_expect_day();
assert_eq!(
*game.character_by_player_id(maple).maple_wolf_mut().unwrap(),
*game
.character_by_player_id(maple)
.maple_wolf_mut()
.unwrap()
.last_kill_on_night,
0
);
@ -134,7 +142,11 @@ fn maple_last_eat_counter_increments() {
game.next_expect_day();
assert_eq!(
*game.character_by_player_id(maple).maple_wolf_mut().unwrap(),
*game
.character_by_player_id(maple)
.maple_wolf_mut()
.unwrap()
.last_kill_on_night,
1
);
@ -157,7 +169,11 @@ fn maple_last_eat_counter_increments() {
game.next_expect_day();
assert_eq!(
*game.character_by_player_id(maple).maple_wolf_mut().unwrap(),
*game
.character_by_player_id(maple)
.maple_wolf_mut()
.unwrap()
.last_kill_on_night,
2
);
@ -180,7 +196,11 @@ fn maple_last_eat_counter_increments() {
game.next_expect_day();
assert_eq!(
*game.character_by_player_id(maple).maple_wolf_mut().unwrap(),
*game
.character_by_player_id(maple)
.maple_wolf_mut()
.unwrap()
.last_kill_on_night,
3
);
let (maple_kill, wolf_kill) = {
@ -202,7 +222,11 @@ fn maple_last_eat_counter_increments() {
game.next_expect_day();
assert_eq!(
*game.character_by_player_id(maple).maple_wolf_mut().unwrap(),
*game
.character_by_player_id(maple)
.maple_wolf_mut()
.unwrap()
.last_kill_on_night,
4
);
@ -234,7 +258,11 @@ fn drunk_maple_doesnt_eat() {
game.next_expect_day();
assert_eq!(
*game.character_by_player_id(maple).maple_wolf_mut().unwrap(),
*game
.character_by_player_id(maple)
.maple_wolf_mut()
.unwrap()
.last_kill_on_night,
0
);
@ -258,7 +286,11 @@ fn drunk_maple_doesnt_eat() {
game.next_expect_day();
assert_eq!(
*game.character_by_player_id(maple).maple_wolf_mut().unwrap(),
*game
.character_by_player_id(maple)
.maple_wolf_mut()
.unwrap()
.last_kill_on_night,
0
);
assert_eq!(
@ -289,7 +321,11 @@ fn drunk_maple_doesnt_eat() {
game.next_expect_day();
assert_eq!(
*game.character_by_player_id(maple).maple_wolf_mut().unwrap(),
*game
.character_by_player_id(maple)
.maple_wolf_mut()
.unwrap()
.last_kill_on_night,
0
);
assert_eq!(
@ -320,7 +356,11 @@ fn drunk_maple_doesnt_eat() {
game.next_expect_day();
assert_eq!(
*game.character_by_player_id(maple).maple_wolf_mut().unwrap(),
*game
.character_by_player_id(maple)
.maple_wolf_mut()
.unwrap()
.last_kill_on_night,
0
);

View File

@ -54,9 +54,63 @@ fn spent_shot() {
assert_eq!(
game.character_by_player_id(militia)
.militia()
.militia_ref()
.unwrap()
.deref()
.targeted
.clone(),
Some(game.character_by_player_id(target_wolf).character_id())
);
assert_eq!(
game.character_by_player_id(target_wolf).died_to().cloned(),
Some(DiedTo::Militia {
killer: game.character_by_player_id(militia).character_id(),
night: NonZeroU8::new(1).unwrap()
})
);
game.execute().title().wolf_pack_kill();
game.mark(game.living_villager().character_id());
game.r#continue().sleep();
game.next_expect_day();
}
#[test]
fn being_killed_doesnt_stop_them() {
let players = gen_players(1..10);
let mut player_ids = players.iter().map(|p| p.player_id);
let militia = player_ids.next().unwrap();
let target_wolf = player_ids.next().unwrap();
let other_wolf = player_ids.next().unwrap();
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::Militia, militia);
settings.add_and_assign(SetupRole::Werewolf, target_wolf);
settings.add_and_assign(SetupRole::Werewolf, other_wolf);
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().sleep();
game.next_expect_day();
game.execute().title().wolf_pack_kill();
game.mark(game.character_by_player_id(militia).character_id());
game.r#continue().sleep();
game.next().title().militia();
game.mark(game.character_by_player_id(target_wolf).character_id());
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(
game.character_by_player_id(militia)
.militia_ref()
.unwrap()
.targeted
.clone(),
Some(game.character_by_player_id(target_wolf).character_id())
);

View File

@ -32,4 +32,5 @@ mod protector;
mod pyremaster;
mod scapegoat;
mod shapeshifter;
mod vindicator;
mod weightlifter;

View File

@ -55,6 +55,7 @@ fn cannot_protect_same_target() {
game.character_by_player_id(protector)
.protector_mut()
.unwrap()
.last_protected
.clone(),
Some(prot.character_id())
);

View File

@ -0,0 +1,109 @@
// 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},
game_test::{
ActionPromptTitleExt, ActionResultExt, GameExt, SettingsExt, gen_players, init_log,
},
message::night::ActionPromptTitle,
};
#[test]
fn direwolf_kill_activates() {
init_log();
let players = gen_players(1..21);
let mut player_ids = players.iter().map(|p| p.player_id);
let vindicator = player_ids.next().unwrap();
let wolf = player_ids.next().unwrap();
let direwolf = player_ids.next().unwrap();
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::Werewolf, wolf);
settings.add_and_assign(SetupRole::DireWolf, direwolf);
settings.add_and_assign(SetupRole::Vindicator, vindicator);
settings.fill_remaining_slots_with_villagers(players.len());
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().direwolf();
game.mark_villager();
game.r#continue().sleep();
game.next_expect_day();
game.mark_for_execution(game.character_by_player_id(direwolf).character_id());
game.execute().title().vindicator();
let prot = game.living_villager();
game.mark(prot.character_id());
game.r#continue().sleep();
game.next().title().wolf_pack_kill();
game.mark(prot.character_id());
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(
game.character_by_player_id(prot.player_id())
.died_to()
.cloned(),
None
);
}
#[test]
fn maplewolf_kill_does_not_activate() {
init_log();
let players = gen_players(1..21);
let mut player_ids = players.iter().map(|p| p.player_id);
let vindicator = player_ids.next().unwrap();
let wolf = player_ids.next().unwrap();
let maplewolf = player_ids.next().unwrap();
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::Werewolf, wolf);
settings.add_and_assign(SetupRole::MapleWolf, maplewolf);
settings.add_and_assign(SetupRole::Vindicator, vindicator);
settings.fill_remaining_slots_with_villagers(players.len());
let mut game = Game::new(&players, settings).unwrap();
game.r#continue().r#continue();
assert_eq!(game.next().title(), ActionPromptTitle::WolvesIntro);
game.r#continue().sleep();
game.next_expect_day();
game.mark_for_execution(game.character_by_player_id(maplewolf).character_id());
game.execute().title().wolf_pack_kill();
let target = game.living_villager();
game.mark(target.character_id());
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(
game.character_by_player_id(target.player_id())
.died_to()
.cloned(),
Some(DiedTo::Wolfpack {
killing_wolf: game.character_by_player_id(wolf).character_id(),
night: NonZeroU8::new(1).unwrap()
})
);
}

View File

@ -15,7 +15,7 @@
use core::{fmt::Display, num::NonZeroU8, ops::Not};
use serde::{Deserialize, Serialize};
use werewolves_macros::{All, ChecksAs, Titles};
use werewolves_macros::{All, ChecksAs, RefAndMut, Titles};
use crate::{
character::CharacterId,
@ -117,7 +117,7 @@ impl AlignmentEq {
}
}
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, ChecksAs, Titles)]
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, ChecksAs, Titles, RefAndMut)]
pub enum Role {
#[checks(Alignment::Village)]
#[checks(Killer::NotKiller)]

View File

@ -11,11 +11,11 @@ $village_border: color.change($village_color, $alpha: 1.0);
$wolves_border: color.change($wolves_color, $alpha: 1.0);
$intel_color: color.adjust($village_color, $hue: -30deg);
$intel_border: color.change($intel_color, $alpha: 1.0);
$defensive_color: color.adjust($intel_color, $hue: -30deg);
$defensive_color: color.adjust($village_color, $hue: -60deg);
$defensive_border: color.change($defensive_color, $alpha: 1.0);
$offensive_color: color.adjust($village_color, $hue: 30deg);
$offensive_border: color.change($offensive_color, $alpha: 1.0);
$starts_as_villager_color: color.adjust($offensive_color, $hue: 30deg);
$starts_as_villager_color: color.adjust($village_color, $hue: 60deg);
$starts_as_villager_border: color.change($starts_as_villager_color, $alpha: 1.0);
$traitor_color: color.adjust($village_color, $hue: 45deg);
$traitor_border: color.change($traitor_color, $alpha: 1.0);
@ -1433,6 +1433,7 @@ input {
}
& .title {
text-shadow: black 3px 2px;
margin-bottom: 10px;
}
@ -1468,6 +1469,8 @@ input {
}
.role {
text-shadow: black 3px 2px;
width: 100%;
filter: saturate(40%);
padding-left: 10px;
@ -2414,3 +2417,58 @@ li.choice {
flex-grow: 1;
}
}
.top-of-day-info {
width: 100%;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: flex-start;
padding: 10px;
gap: 10vw;
.info-tidbit {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
align-items: center;
gap: 5px;
label {
font-size: 1.5em;
opacity: 70%;
}
.parity {
font-size: 2em;
}
.parity-pct {
font-size: 1.25em;
}
.last-nights-kills {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 2cm;
.identity {
.number {
color: red;
font-size: 1.5em;
}
text-align: center;
font-size: 1.25em;
}
}
.current-day {
color: red;
font-size: 3em;
flex-grow: 1;
}
}
}

View File

@ -297,7 +297,7 @@ impl Component for Host {
type Properties = ();
fn create(ctx: &Context<Self>) -> Self {
gloo::utils::document().set_title("Werewolves Host");
gloo::utils::document().set_title(format!("{} — host", crate::TITLE).as_str());
if let Some(clients) = gloo::utils::document()
.query_selector("clients")
.ok()
@ -759,8 +759,8 @@ impl Host {
(None, false)
}
},
HostEvent::Error(err) => (None, false),
HostEvent::SetBigScreenState(state) => (None, true),
HostEvent::Error(_) => (None, false),
HostEvent::SetBigScreenState(_) => (None, true),
HostEvent::Continue => (None, false),
}
}

View File

@ -49,6 +49,43 @@ pub fn DaytimePlayerList(
) -> Html {
let on_select = big_screen.not().then(|| on_mark.clone());
let mut characters = characters.clone();
let last_nights_kills = {
let kills = characters
.iter()
.filter_map(|c| {
c.died_to
.as_ref()
.and_then(|died_to| match died_to.date_time() {
GameTime::Day { .. } => None,
GameTime::Night { number } => {
if let Some(day) = day.as_ref()
&& number == day.get() - 1
{
Some(c)
} else {
None
}
}
})
})
.map(|killed| {
let ident = killed.identity.clone().into_public();
html! {
<span>
<Identity ident={ident} />
</span>
}
})
.collect::<Box<[_]>>();
kills.is_empty().not().then_some(html! {
<div class="info-tidbit">
<label>{"died last night"}</label>
<div class="last-nights-kills">
{kills.into_iter().collect::<Html>()}
</div>
</div>
})
};
characters.sort_by(|l, r| l.identity.number.cmp(&r.identity.number));
let chars = characters
.iter()
@ -84,6 +121,29 @@ pub fn DaytimePlayerList(
} else {
"execute"
};
let parity = {
let wolves = characters
.iter()
.filter(|c| c.died_to.is_none() && c.role.wolf())
.count();
let total = characters.iter().filter(|c| c.died_to.is_none()).count();
let pct_parity = (((wolves as f64) * 100.0) / (total as f64)).round();
html! {
<div class="info-tidbit">
<label>{"parity"}</label>
<span class="parity">
<span class="red">{wolves}</span>
{"/"}
<span class="total">{total}</span>
</span>
<span class="parity-pct">
{"("}
{pct_parity}
{"%)"}
</span>
</div>
}
};
let button = big_screen
.not()
.then_some(())
@ -97,12 +157,19 @@ pub fn DaytimePlayerList(
});
let day = day.as_ref().map(|day| {
html! {
<h2>{"day "}{day.get()}</h2>
<div class="info-tidbit">
<label>{"day"}</label>
<span class="current-day">{day.get()}</span>
</div>
}
});
html! {
<div class="character-picker">
{day}
<div class="top-of-day-info">
{day}
{parity}
{last_nights_kills}
</div>
<div class="player-list">
{chars}
</div>

View File

@ -131,20 +131,21 @@ pub fn SetupCategory(
.map(|(r, count)| {
let as_role = r.into_role();
let wakes = as_role.wakes_night_zero().then_some("wakes");
let count = matches!(mode, CategoryMode::ShowExactRoleCount).then(|| {
html! {
<span class="count">{count}</span>
}
});
let count =
(matches!(mode, CategoryMode::ShowExactRoleCount) && count > 0).then(|| {
html! {
<span class="count">{count}</span>
}
});
let killer_inactive = as_role.killer().killer().not().then_some("inactive");
let powerful_inactive = as_role.powerful().powerful().not().then_some("inactive");
let alignment = as_role.alignment().icon();
html! {
<div class={classes!("slot")}>
{count}
<div class={classes!("role", wakes, r.category().class())}>
<span class={classes!("role", wakes, r.category().class())}>
{r.to_string().to_case(Case::Title)}
</div>
</span>
<div class="attributes">
<div class="alignment">
<Icon source={alignment} icon_type={IconType::Small}/>

View File

@ -45,6 +45,10 @@ 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!();
const TITLE: &str = match option_env!("LOCAL") {
Some(_) => "LOCAL werewolves",
None => "werewolves",
};
use crate::clients::{
client::{Client2, ClientContext},
@ -55,6 +59,7 @@ fn main() {
wasm_logger::init(wasm_logger::Config::new(log::Level::Trace));
log::debug!("starting werewolves build {BUILD_ID}");
let document = gloo::utils::document();
document.set_title(crate::TITLE);
let url = document.document_uri().expect("get uri");
let url_obj = Url::new(&url).unwrap();
let path = url_obj.pathname();