target split, more prompts, improved kills

This commit is contained in:
emilis 2025-09-28 02:13:34 +01:00
parent e704fdca8b
commit d352cfb1ee
No known key found for this signature in database
17 changed files with 1000 additions and 350 deletions

View File

@ -1,14 +1,16 @@
use core::{fmt::Debug, num::NonZeroU8}; use core::{fmt::Debug, num::NonZeroU8};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use werewolves_macros::Extract;
use crate::{game::DateTime, player::CharacterId}; use crate::{game::DateTime, player::CharacterId};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Extract)]
pub enum DiedTo { pub enum DiedTo {
Execution { Execution {
day: NonZeroU8, day: NonZeroU8,
}, },
#[extract(source as killer)]
MapleWolf { MapleWolf {
source: CharacterId, source: CharacterId,
night: NonZeroU8, night: NonZeroU8,
@ -17,13 +19,17 @@ pub enum DiedTo {
MapleWolfStarved { MapleWolfStarved {
night: NonZeroU8, night: NonZeroU8,
}, },
#[extract(killer as killer)]
Militia { Militia {
killer: CharacterId, killer: CharacterId,
night: NonZeroU8, night: NonZeroU8,
}, },
#[extract(killing_wolf as killer)]
Wolfpack { Wolfpack {
killing_wolf: CharacterId,
night: NonZeroU8, night: NonZeroU8,
}, },
#[extract(killer as killer)]
AlphaWolf { AlphaWolf {
killer: CharacterId, killer: CharacterId,
night: NonZeroU8, night: NonZeroU8,
@ -32,12 +38,17 @@ pub enum DiedTo {
into: CharacterId, into: CharacterId,
night: NonZeroU8, night: NonZeroU8,
}, },
#[extract(killer as killer)]
Hunter { Hunter {
killer: CharacterId, killer: CharacterId,
night: NonZeroU8, night: NonZeroU8,
}, },
Guardian { #[extract(source as killer)]
killer: CharacterId, GuardianProtecting {
source: CharacterId,
protecting: CharacterId,
protecting_from: CharacterId,
protecting_from_cause: Box<DiedTo>,
night: NonZeroU8, night: NonZeroU8,
}, },
} }
@ -47,7 +58,13 @@ impl DiedTo {
match self { match self {
DiedTo::Execution { day } => DateTime::Day { number: *day }, DiedTo::Execution { day } => DateTime::Day { number: *day },
DiedTo::Guardian { killer: _, night } DiedTo::GuardianProtecting {
source: _,
protecting: _,
protecting_from: _,
protecting_from_cause: _,
night,
}
| DiedTo::MapleWolf { | DiedTo::MapleWolf {
source: _, source: _,
night, night,
@ -55,7 +72,10 @@ impl DiedTo {
} }
| DiedTo::MapleWolfStarved { night } | DiedTo::MapleWolfStarved { night }
| DiedTo::Militia { killer: _, night } | DiedTo::Militia { killer: _, night }
| DiedTo::Wolfpack { night } | DiedTo::Wolfpack {
night,
killing_wolf: _,
}
| DiedTo::AlphaWolf { killer: _, night } | DiedTo::AlphaWolf { killer: _, night }
| DiedTo::Shapeshift { into: _, night } | DiedTo::Shapeshift { into: _, night }
| DiedTo::Hunter { killer: _, night } => DateTime::Night { | DiedTo::Hunter { killer: _, night } => DateTime::Night {

View File

@ -69,4 +69,8 @@ pub enum GameError {
GameOngoing, GameOngoing,
#[error("needs a role reveal")] #[error("needs a role reveal")]
NeedRoleReveal, NeedRoleReveal,
#[error("no previous state")]
NoPreviousState,
#[error("invalid original kill for guardian guard")]
GuardianInvalidOriginalKill,
} }

View File

@ -0,0 +1,292 @@
use core::{
num::NonZeroU8,
ops::{Deref, Not},
};
use super::Result;
use crate::{
diedto::DiedTo,
error::GameError,
game::{Village, kill::taken::Taken, night::NightChange},
player::{CharacterId, Protection},
};
#[derive(Debug, PartialEq)]
pub enum KillOutcome {
Single(CharacterId, DiedTo),
Guarding {
original_killer: CharacterId,
original_target: CharacterId,
original_kill: DiedTo,
guardian: CharacterId,
night: NonZeroU8,
},
}
impl KillOutcome {
pub fn apply_to_village(self, village: &mut Village) -> Result<()> {
match self {
KillOutcome::Single(character_id, died_to) => Ok(village
.character_by_id_mut(&character_id)
.ok_or(GameError::InvalidTarget)?
.kill(died_to)),
KillOutcome::Guarding {
original_killer,
original_target,
original_kill,
guardian,
night,
} => {
// check if guardian exists before we mutably borrow killer, which would
// prevent us from borrowing village to check after.
village
.character_by_id(&guardian)
.ok_or(GameError::InvalidTarget)?;
village
.character_by_id_mut(&original_killer)
.ok_or(GameError::InvalidTarget)?
.kill(DiedTo::GuardianProtecting {
night,
source: guardian.clone(),
protecting: original_target,
protecting_from: original_killer,
protecting_from_cause: Box::new(original_kill.clone()),
});
village
.character_by_id_mut(&guardian)
.ok_or(GameError::InvalidTarget)?
.kill(original_kill);
Ok(())
}
}
}
}
fn resolve_protection(
killer: CharacterId,
killed_with: &DiedTo,
target: &CharacterId,
protection: &Protection,
night: NonZeroU8,
) -> Option<KillOutcome> {
match protection {
Protection::Guardian {
source,
guarding: true,
} => Some(KillOutcome::Guarding {
original_killer: killer,
guardian: source.clone(),
original_target: target.clone(),
original_kill: killed_with.clone(),
night,
}),
Protection::Guardian {
source: _,
guarding: false,
}
| Protection::Protector { source: _ } => None,
}
}
pub fn resolve_kill(
changes: &mut ChangesLookup<'_>,
target: &CharacterId,
died_to: &DiedTo,
night: u8,
village: &Village,
) -> Result<Option<KillOutcome>> {
if let DiedTo::MapleWolf {
source,
night,
starves_if_fails: true,
} = died_to
&& let Some(protection) = changes.protected_take(target)
{
return Ok(Some(
resolve_protection(source.clone(), died_to, target, &protection, *night).unwrap_or(
KillOutcome::Single(source.clone(), DiedTo::MapleWolfStarved { night: *night }),
),
));
}
if let DiedTo::Wolfpack {
night,
killing_wolf,
} = died_to
&& let Some(ss_source) = changes.shapeshifter()
{
let killing_wolf = village
.character_by_id(killing_wolf)
.ok_or(GameError::InvalidTarget)?;
match changes.protected_take(target) {
Some(protection) => {
return Ok(resolve_protection(
killing_wolf.character_id().clone(),
died_to,
target,
&protection,
*night,
));
}
None => {
// Wolf kill went through -- can kill shifter
return Ok(Some(KillOutcome::Single(
ss_source.clone(),
DiedTo::Shapeshift {
into: target.clone(),
night: *night,
},
)));
}
};
}
let protection = match changes.protected_take(target) {
Some(prot) => prot,
None => return Ok(Some(KillOutcome::Single(target.clone(), died_to.clone()))),
};
match protection.deref() {
Protection::Guardian {
source,
guarding: true,
} => Ok(Some(KillOutcome::Guarding {
original_killer: died_to
.killer()
.ok_or(GameError::GuardianInvalidOriginalKill)?
.clone(),
original_target: target.clone(),
original_kill: died_to.clone(),
guardian: source.clone(),
night: NonZeroU8::new(night).unwrap(),
})),
Protection::Guardian {
source: _,
guarding: false,
}
| Protection::Protector { source: _ } => Ok(None),
}
}
pub struct ChangesLookup<'a>(&'a [NightChange], Vec<usize>);
impl<'a> ChangesLookup<'a> {
pub fn new(changes: &'a [NightChange]) -> Self {
Self(changes, Vec::new())
}
pub fn killed(&self, target: &CharacterId) -> Option<&'a DiedTo> {
self.0.iter().enumerate().find_map(|(idx, c)| {
self.1
.contains(&idx)
.not()
.then(|| match c {
NightChange::Kill { target: t, died_to } => (t == target).then_some(died_to),
_ => None,
})
.flatten()
})
}
pub fn release<T>(&mut self, taken: Taken<'a, T>) {
self.1.swap_remove(
self.1
.iter()
.enumerate()
.find_map(|(idx, c)| (*c == taken.idx()).then_some(idx))
.unwrap(),
);
}
pub fn protected_take(&mut self, target: &CharacterId) -> Option<Taken<'a, Protection>> {
if let Some((idx, c)) = self.0.iter().enumerate().find_map(|(idx, c)| {
self.1
.contains(&idx)
.not()
.then(|| match c {
NightChange::Protection {
target: t,
protection,
} => (t == target).then_some((idx, protection)),
_ => None,
})
.flatten()
}) {
self.1.push(idx);
Some(Taken::new(idx, c))
} else {
None
}
}
pub fn protected(&self, target: &CharacterId) -> Option<&'a Protection> {
self.0.iter().enumerate().find_map(|(idx, c)| {
self.1
.contains(&idx)
.not()
.then(|| match c {
NightChange::Protection {
target: t,
protection,
} => (t == target).then_some(protection),
_ => None,
})
.flatten()
})
}
pub fn shapeshifter(&self) -> Option<&'a CharacterId> {
self.0.iter().enumerate().find_map(|(idx, c)| {
self.1
.contains(&idx)
.not()
.then_some(match c {
NightChange::Shapeshift { source } => Some(source),
_ => None,
})
.flatten()
})
}
pub fn wolf_pack_kill_target(&self) -> Option<&'a CharacterId> {
self.0.iter().enumerate().find_map(|(idx, c)| {
self.1
.contains(&idx)
.not()
.then_some(match c {
NightChange::Kill {
target,
died_to:
DiedTo::Wolfpack {
night: _,
killing_wolf: _,
},
} => Some(target),
_ => None,
})
.flatten()
})
}
}
mod taken {
use core::ops::Deref;
pub struct Taken<'a, T>(usize, &'a T);
impl<'a, T> Taken<'a, T> {
pub const fn new(idx: usize, item: &'a T) -> Self {
Self(idx, item)
}
pub const fn idx(&self) -> usize {
self.0
}
}
impl<'a, T> Deref for Taken<'a, T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.1
}
}
}

View File

@ -1,3 +1,4 @@
mod kill;
mod night; mod night;
mod settings; mod settings;
mod village; mod village;
@ -27,12 +28,14 @@ type Result<T> = core::result::Result<T, GameError>;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Game { pub struct Game {
previous: Vec<GameState>, previous: Vec<GameState>,
next: Vec<GameState>,
state: GameState, state: GameState,
} }
impl Game { impl Game {
pub fn new(players: &[Identification], settings: GameSettings) -> Result<Self> { pub fn new(players: &[Identification], settings: GameSettings) -> Result<Self> {
Ok(Self { Ok(Self {
next: Vec::new(),
previous: Vec::new(), previous: Vec::new(),
state: GameState::Night { state: GameState::Night {
night: Night::new(Village::new(players, settings)?)?, night: Night::new(Village::new(players, settings)?)?,
@ -161,6 +164,24 @@ impl Game {
}, },
HostGameMessage::Night(_), HostGameMessage::Night(_),
) => Err(GameError::InvalidMessageForGameState), ) => Err(GameError::InvalidMessageForGameState),
(
GameState::Day {
village: _,
marked: _,
},
HostGameMessage::PreviousState,
) => {
let mut prev = self.previous.pop().ok_or(GameError::NoPreviousState)?;
log::info!("previous state loaded: {prev:?}");
core::mem::swap(&mut prev, &mut self.state);
self.next.push(prev);
self.process(HostGameMessage::GetState)
}
(GameState::Night { night }, HostGameMessage::PreviousState) => {
night.previous_state()?;
self.process(HostGameMessage::GetState)
}
} }
} }

View File

@ -8,7 +8,10 @@ use super::Result;
use crate::{ use crate::{
diedto::DiedTo, diedto::DiedTo,
error::GameError, error::GameError,
game::{DateTime, Village}, game::{
DateTime, Village,
kill::{self, ChangesLookup},
},
message::night::{ActionPrompt, ActionResponse, ActionResult}, message::night::{ActionPrompt, ActionResponse, ActionResult},
player::{Character, CharacterId, Protection}, player::{Character, CharacterId, Protection},
role::{PreviousGuardianAction, Role, RoleBlock, RoleTitle}, role::{PreviousGuardianAction, Role, RoleBlock, RoleTitle},
@ -19,6 +22,7 @@ pub struct Night {
village: Village, village: Village,
night: u8, night: u8,
action_queue: VecDeque<(ActionPrompt, Character)>, action_queue: VecDeque<(ActionPrompt, Character)>,
used_actions: Vec<(ActionPrompt, Character)>,
changes: Vec<NightChange>, changes: Vec<NightChange>,
night_state: NightState, night_state: NightState,
} }
@ -166,15 +170,62 @@ impl Night {
village, village,
night_state, night_state,
action_queue, action_queue,
used_actions: Vec::new(),
}) })
} }
pub fn previous_state(&mut self) -> Result<()> {
let (prev_act, prev_char) = self.used_actions.pop().ok_or(GameError::NoPreviousState)?;
log::info!("loading previous prompt: {prev_act:?}");
match &self.night_state {
NightState::Active {
current_prompt,
current_char,
current_result: Some(current_result),
} => {
// if let Some(last_change) = self.changes.pop() {
// }
log::info!("removing current result: {current_result:?}");
self.night_state = NightState::Active {
current_prompt: current_prompt.clone(),
current_char: current_char.clone(),
current_result: None,
};
}
NightState::Active {
current_prompt,
current_char,
current_result: None,
} => {
log::info!("pushing current prompt to front of action queue: {current_prompt:?}");
self.action_queue.push_front((
current_prompt.clone(),
self.village.character_by_id(current_char).unwrap().clone(),
));
self.night_state = NightState::Active {
current_prompt: prev_act,
current_char: prev_char.character_id().clone(),
current_result: None,
}
}
NightState::Complete => {
self.night_state = NightState::Active {
current_prompt: prev_act,
current_char: prev_char.character_id().clone(),
current_result: None,
};
}
}
Ok(())
}
pub fn collect_completed(&self) -> Result<Village> { pub fn collect_completed(&self) -> Result<Village> {
if !matches!(self.night_state, NightState::Complete) { if !matches!(self.night_state, NightState::Complete) {
return Err(GameError::NotEndOfNight); return Err(GameError::NotEndOfNight);
} }
let mut new_village = self.village.clone(); let mut new_village = self.village.clone();
let changes = ChangesLookup(&self.changes); let mut changes = ChangesLookup::new(&self.changes);
for change in self.changes.iter() { for change in self.changes.iter() {
match change { match change {
NightChange::RoleChange(character_id, role_title) => new_village NightChange::RoleChange(character_id, role_title) => new_village
@ -201,108 +252,17 @@ impl Night {
} }
} }
NightChange::Kill { target, died_to } => { NightChange::Kill { target, died_to } => {
if let DiedTo::MapleWolf { if let Some(kill) = kill::resolve_kill(
source, &mut changes,
night, target,
starves_if_fails: true, died_to,
} = died_to self.night,
&& changes.protected(target).is_some() &self.village,
{ )? {
// kill maple first, then act as if they get their kill attempt kill.apply_to_village(&mut new_village)?;
new_village
.character_by_id_mut(source)
.unwrap()
.kill(DiedTo::MapleWolfStarved { night: *night });
} }
if let Some(prot) = changes.protected(target) {
match prot {
Protection::Guardian {
source,
guarding: true,
} => {
let kill_source = match died_to {
DiedTo::MapleWolfStarved { night } => {
new_village
.character_by_id_mut(target)
.unwrap()
.kill(DiedTo::MapleWolfStarved { night: *night });
continue;
}
DiedTo::Execution { day: _ } => unreachable!(),
DiedTo::MapleWolf {
source,
night: _,
starves_if_fails: _,
}
| DiedTo::Militia {
killer: source,
night: _,
}
| DiedTo::AlphaWolf {
killer: source,
night: _,
}
| DiedTo::Hunter {
killer: source,
night: _,
} => source.clone(),
DiedTo::Wolfpack { night: _ } => {
if let Some(wolf_to_kill) = new_village
.living_wolf_pack_players()
.into_iter()
.find(|w| matches!(w.role(), Role::Werewolf))
.map(|w| w.character_id().clone())
.or_else(|| {
new_village
.living_wolf_pack_players()
.into_iter()
.next()
.map(|w| w.character_id().clone())
})
{
wolf_to_kill
} else {
// No wolves? Game over?
continue;
}
}
DiedTo::Shapeshift { into: _, night: _ } => target.clone(),
DiedTo::Guardian {
killer: _,
night: _,
} => continue,
};
new_village.character_by_id_mut(&kill_source).unwrap().kill(
DiedTo::Guardian {
killer: source.clone(),
night: NonZeroU8::new(self.night).unwrap(),
},
);
new_village.character_by_id_mut(source).unwrap().kill(
DiedTo::Wolfpack {
night: NonZeroU8::new(self.night).unwrap(),
},
);
continue;
}
Protection::Guardian {
source: _,
guarding: false,
} => continue,
Protection::Protector { source: _ } => continue,
}
}
new_village
.character_by_id_mut(target)
.unwrap()
.kill(DiedTo::Wolfpack {
night: NonZeroU8::new(self.night).unwrap(),
});
} }
NightChange::Shapeshift { source } => { NightChange::Shapeshift { source } => {
// TODO: shapeshift should probably notify immediately after it happens
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()
{ {
@ -317,13 +277,15 @@ impl Night {
into: target.clone(), into: target.clone(),
night: NonZeroU8::new(self.night).unwrap(), night: NonZeroU8::new(self.night).unwrap(),
}); });
let target = new_village.find_by_character_id_mut(target).unwrap(); // role change pushed in [apply_shapeshift]
target
.role_change( // let target = new_village.find_by_character_id_mut(target).unwrap();
RoleTitle::Werewolf, // target
DateTime::Night { number: self.night }, // .role_change(
) // RoleTitle::Werewolf,
.unwrap(); // DateTime::Night { number: self.night },
// )
// .unwrap();
} }
} }
NightChange::RoleBlock { NightChange::RoleBlock {
@ -347,7 +309,11 @@ impl Night {
if let Some(kill_target) = self.changes.iter().find_map(|c| match c { if let Some(kill_target) = self.changes.iter().find_map(|c| match c {
NightChange::Kill { NightChange::Kill {
target, target,
died_to: DiedTo::Wolfpack { night: _ }, died_to:
DiedTo::Wolfpack {
night: _,
killing_wolf: _,
},
} => Some(target.clone()), } => Some(target.clone()),
_ => None, _ => None,
}) { }) {
@ -361,22 +327,42 @@ impl Night {
// there is protection, so the kill doesn't happen -> no shapeshift // there is protection, so the kill doesn't happen -> no shapeshift
return Ok(ActionResult::GoBackToSleep); return Ok(ActionResult::GoBackToSleep);
} }
if let Some(kill) = self.changes.iter_mut().find(|c| { // if let Some(kill) = self.changes.iter_mut().find(|c| {
// matches!(
// c,
// NightChange::Kill {
// target: _,
// died_to: DiedTo::Wolfpack { night: _ }
// }
// )
// }) {
// *kill = NightChange::Kill {
// target: source.clone(),
// died_to: DiedTo::Shapeshift {
// into: kill_target.clone(),
// night: NonZeroU8::new(self.night).unwrap(),
// },
// }
// }
if self.changes.iter_mut().any(|c| {
matches!( matches!(
c, c,
NightChange::Kill { NightChange::Kill {
target: _, target: _,
died_to: DiedTo::Wolfpack { night: _ } died_to: DiedTo::Wolfpack {
night: _,
killing_wolf: _
}
} }
) )
}) { }) {
*kill = NightChange::Kill { self.changes.push(NightChange::Kill {
target: source.clone(), target: source.clone(),
died_to: DiedTo::Shapeshift { died_to: DiedTo::Shapeshift {
into: kill_target.clone(), into: kill_target.clone(),
night: NonZeroU8::new(self.night).unwrap(), night: NonZeroU8::new(self.night).unwrap(),
}, },
} });
} }
self.changes.push(NightChange::Shapeshift { self.changes.push(NightChange::Shapeshift {
source: source.clone(), source: source.clone(),
@ -780,7 +766,10 @@ impl Night {
result: ActionResult::GoBackToSleep, result: ActionResult::GoBackToSleep,
change: Some(NightChange::Kill { change: Some(NightChange::Kill {
target: target.clone(), target: target.clone(),
died_to: DiedTo::Wolfpack { night }, died_to: DiedTo::Wolfpack {
night,
killing_wolf: self.village.killing_wolf_id(),
},
}), }),
unless: Some(Unless::TargetBlocked(target)), unless: Some(Unless::TargetBlocked(target)),
}) })
@ -980,6 +969,7 @@ impl Night {
NightState::Complete => return Err(GameError::NightOver), NightState::Complete => return Err(GameError::NightOver),
} }
if let Some((prompt, character)) = self.action_queue.pop_front() { if let Some((prompt, character)) = self.action_queue.pop_front() {
self.used_actions.push((prompt.clone(), character.clone()));
self.night_state = NightState::Active { self.night_state = NightState::Active {
current_prompt: prompt, current_prompt: prompt,
current_char: character.character_id().clone(), current_char: character.character_id().clone(),
@ -996,34 +986,3 @@ impl Night {
self.changes.as_slice() self.changes.as_slice()
} }
} }
struct ChangesLookup<'a>(&'a [NightChange]);
impl<'a> ChangesLookup<'a> {
pub fn killed(&self, target: &CharacterId) -> Option<&'a DiedTo> {
self.0.iter().find_map(|c| match c {
NightChange::Kill { target: t, died_to } => (t == target).then_some(died_to),
_ => None,
})
}
pub fn protected(&self, target: &CharacterId) -> Option<&'a Protection> {
self.0.iter().find_map(|c| match c {
NightChange::Protection {
target: t,
protection,
} => (t == target).then_some(protection),
_ => None,
})
}
pub fn wolf_pack_kill_target(&self) -> Option<&'a CharacterId> {
self.0.iter().find_map(|c| match c {
NightChange::Kill {
target,
died_to: DiedTo::Wolfpack { night: _ },
} => Some(target),
_ => None,
})
}
}

View File

@ -153,6 +153,19 @@ impl Village {
.collect() .collect()
} }
pub fn killing_wolf_id(&self) -> CharacterId {
let wolves = self.living_wolf_pack_players();
if let Some(ww) = wolves.iter().find(|w| matches!(w.role(), Role::Werewolf)) {
ww.character_id().clone()
} else if let Some(non_ss_wolf) = wolves.iter().find(|w| {
w.role().wolf() && !matches!(w.role(), Role::Shapeshifter { shifted_into: _ })
}) {
non_ss_wolf.character_id().clone()
} else {
wolves.into_iter().next().unwrap().character_id().clone()
}
}
pub fn living_players(&self) -> Box<[Target]> { pub fn living_players(&self) -> Box<[Target]> {
self.characters self.characters
.iter() .iter()

View File

@ -5,7 +5,7 @@ pub mod night;
use core::{fmt::Display, num::NonZeroU8}; use core::{fmt::Display, num::NonZeroU8};
pub use ident::*; pub use ident::*;
use night::{ActionPrompt, ActionResponse, ActionResult, RoleChange}; use night::{ActionPrompt, ActionResponse, ActionResult};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{

View File

@ -28,6 +28,7 @@ pub enum HostMessage {
pub enum HostGameMessage { pub enum HostGameMessage {
Day(HostDayMessage), Day(HostDayMessage),
Night(HostNightMessage), Night(HostNightMessage),
PreviousState,
GetState, GetState,
} }

View File

@ -2,93 +2,107 @@ use serde::{Deserialize, Serialize};
use werewolves_macros::ChecksAs; use werewolves_macros::ChecksAs;
use crate::{ use crate::{
diedto::DiedTo,
message::PublicIdentity,
player::CharacterId, player::CharacterId,
role::{Alignment, PreviousGuardianAction, Role, RoleTitle}, role::{Alignment, PreviousGuardianAction, Role, RoleTitle},
}; };
use super::Target; use super::Target;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub enum ActionType {
Protect = 0,
WolfPackKill = 1,
Direwolf = 2,
Wolf = 3,
Block = 4,
Other = 5,
RoleChange = 6,
}
impl PartialOrd for ActionType {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
(*self as u8).partial_cmp(&(*other as u8))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ChecksAs)]
pub enum ActionPrompt { pub enum ActionPrompt {
WolvesIntro { #[checks(ActionType::WolfPackKill)]
wolves: Box<[(Target, RoleTitle)]>, WolvesIntro { wolves: Box<[(Target, RoleTitle)]> },
}, #[checks(ActionType::RoleChange)]
RoleChange { RoleChange { new_role: RoleTitle },
new_role: RoleTitle, #[checks(ActionType::Other)]
}, Seer { living_players: Box<[Target]> },
Seer { #[checks(ActionType::Protect)]
living_players: Box<[Target]>, Protector { targets: Box<[Target]> },
}, #[checks(ActionType::Other)]
Protector { Arcanist { living_players: Box<[Target]> },
targets: Box<[Target]>, #[checks(ActionType::Other)]
}, Gravedigger { dead_players: Box<[Target]> },
Arcanist { #[checks(ActionType::Other)]
living_players: Box<[Target]>,
},
Gravedigger {
dead_players: Box<[Target]>,
},
Hunter { Hunter {
current_target: Option<Target>, current_target: Option<Target>,
living_players: Box<[Target]>, living_players: Box<[Target]>,
}, },
Militia { #[checks(ActionType::Other)]
living_players: Box<[Target]>, Militia { living_players: Box<[Target]> },
}, #[checks(ActionType::Other)]
MapleWolf { MapleWolf {
kill_or_die: bool, kill_or_die: bool,
living_players: Box<[Target]>, living_players: Box<[Target]>,
}, },
#[checks(ActionType::Protect)]
Guardian { Guardian {
previous: Option<PreviousGuardianAction>, previous: Option<PreviousGuardianAction>,
living_players: Box<[Target]>, living_players: Box<[Target]>,
}, },
WolfPackKill { #[checks(ActionType::Wolf)]
living_villagers: Box<[Target]>, WolfPackKill { living_villagers: Box<[Target]> },
}, #[checks(ActionType::Wolf)]
Shapeshifter, Shapeshifter,
AlphaWolf { #[checks(ActionType::Wolf)]
living_villagers: Box<[Target]>, AlphaWolf { living_villagers: Box<[Target]> },
}, #[checks(ActionType::Direwolf)]
DireWolf { DireWolf { living_players: Box<[Target]> },
living_players: Box<[Target]>,
},
} }
impl PartialOrd for ActionPrompt { impl PartialOrd for ActionPrompt {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
fn ordering_num(prompt: &ActionPrompt) -> u8 { // fn ordering_num(prompt: &ActionPrompt) -> u8 {
match prompt { // match prompt {
ActionPrompt::WolvesIntro { wolves: _ } => 0, // ActionPrompt::WolvesIntro { wolves: _ } => 0,
ActionPrompt::Guardian { // ActionPrompt::Guardian {
living_players: _, // living_players: _,
previous: _, // previous: _,
} // }
| ActionPrompt::Protector { targets: _ } => 1, // | ActionPrompt::Protector { targets: _ } => 1,
ActionPrompt::WolfPackKill { // ActionPrompt::WolfPackKill {
living_villagers: _, // living_villagers: _,
} => 2, // } => 2,
ActionPrompt::Shapeshifter => 3, // ActionPrompt::Shapeshifter => 3,
ActionPrompt::AlphaWolf { // ActionPrompt::AlphaWolf {
living_villagers: _, // living_villagers: _,
} => 4, // } => 4,
ActionPrompt::DireWolf { living_players: _ } => 5, // ActionPrompt::DireWolf { living_players: _ } => 5,
ActionPrompt::Seer { living_players: _ } // ActionPrompt::Seer { living_players: _ }
| ActionPrompt::Arcanist { living_players: _ } // | ActionPrompt::Arcanist { living_players: _ }
| ActionPrompt::Gravedigger { dead_players: _ } // | ActionPrompt::Gravedigger { dead_players: _ }
| ActionPrompt::Hunter { // | ActionPrompt::Hunter {
current_target: _, // current_target: _,
living_players: _, // living_players: _,
} // }
| ActionPrompt::Militia { living_players: _ } // | ActionPrompt::Militia { living_players: _ }
| ActionPrompt::MapleWolf { // | ActionPrompt::MapleWolf {
kill_or_die: _, // kill_or_die: _,
living_players: _, // living_players: _,
} // }
| ActionPrompt::RoleChange { new_role: _ } => 0xFF, // | ActionPrompt::RoleChange { new_role: _ } => 0xFF,
} // }
} // }
ordering_num(self).partial_cmp(&ordering_num(other)) // ordering_num(self).partial_cmp(&ordering_num(other))
self.action_type().partial_cmp(&other.action_type())
} }
} }
@ -121,10 +135,3 @@ pub enum ActionResult {
GoBackToSleep, GoBackToSleep,
WolvesIntroDone, WolvesIntroDone,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RoleChange {
Elder(Role),
Apprentice(Role),
Shapeshift(Role),
}

View File

@ -25,5 +25,5 @@ ciborium = { version = "0.2", optional = true }
colored = { version = "3.0" } colored = { version = "3.0" }
[features] [features]
# default = ["cbor"] default = ["cbor"]
cbor = ["dep:ciborium"] cbor = ["dep:ciborium"]

View File

@ -36,7 +36,7 @@ convert_case = { version = "0.8" }
ciborium = { version = "0.2", optional = true } ciborium = { version = "0.2", optional = true }
[features] [features]
# default = ["cbor"] default = ["cbor"]
default = ["json"] # default = ["json"]
cbor = ["dep:ciborium"] cbor = ["dep:ciborium"]
json = ["dep:serde_json"] json = ["dep:serde_json"]

View File

@ -836,9 +836,18 @@ clients {
flex-wrap: wrap; flex-wrap: wrap;
flex-direction: row; flex-direction: row;
font-size: 2rem; font-size: 2rem;
margin-left: 20px;
margin-right: 20px;
justify-content: center; justify-content: center;
&.margin-20 {
margin-left: 20px;
margin-right: 20px;
}
&.margin-5 {
margin-left: 5px;
margin-right: 5px;
}
} }
.gap { .gap {
@ -1002,7 +1011,7 @@ error {
background-color: $village_color; background-color: $village_color;
border: 3px solid darken($village_color, 20%); border: 3px solid darken($village_color, 20%);
&.on-the-block { &.marked {
// background-color: brighten($village_color, 100%); // background-color: brighten($village_color, 100%);
filter: hue-rotate(90deg); filter: hue-rotate(90deg);
} }
@ -1021,3 +1030,25 @@ error {
filter: grayscale(100%); filter: grayscale(100%);
} }
} }
.binary {
.button-container {
background-color: $village_color;
border: 3px solid darken($village_color, 20%);
text-align: center;
padding: 0;
margin: 0;
display: flex;
flex: 1 1 0;
button {
font-size: 3rem;
font-weight: bold;
align-self: center;
padding: 20px;
width: 100%;
height: 100%;
margin: 0;
}
}
}

View File

@ -4,7 +4,7 @@ use crate::components::Button;
#[derive(Debug, Clone, PartialEq, Properties)] #[derive(Debug, Clone, PartialEq, Properties)]
pub struct BinaryChoiceProps { pub struct BinaryChoiceProps {
pub on_chosen: Callback<bool>, pub on_chosen: Option<Callback<bool>>,
#[prop_or_default] #[prop_or_default]
pub children: Html, pub children: Html,
} }
@ -17,15 +17,19 @@ pub fn BinaryChoice(
}: &BinaryChoiceProps, }: &BinaryChoiceProps,
) -> Html { ) -> Html {
let on_chosen_yes = on_chosen.clone(); let on_chosen_yes = on_chosen.clone();
let yes = move |_| on_chosen_yes.emit(true); let yes = on_chosen_yes
.map(|on_chosen| Callback::from(move |_| on_chosen.emit(true)))
.unwrap_or_default();
let on_chosen = on_chosen.clone(); let on_chosen = on_chosen.clone();
let no = move |_| on_chosen.emit(false); let no = on_chosen
.map(|on_chosen| Callback::from(move |_| on_chosen.emit(false)))
.unwrap_or_default();
html! { html! {
<div class="column-list"> <div class="column-list binary">
{children.clone()} {children.clone()}
<div class="row-list"> <div class="row-list gap">
<Button on_click={yes}>{"yes"}</Button> <Button on_click={yes}>{"Yes"}</Button>
<Button on_click={no}>{"no"}</Button> <Button on_click={no}>{"No"}</Button>
</div> </div>
</div> </div>
} }

View File

@ -7,12 +7,13 @@ use werewolves_proto::{
night::{ActionPrompt, ActionResponse}, night::{ActionPrompt, ActionResponse},
}, },
player::CharacterId, player::CharacterId,
role::PreviousGuardianAction,
}; };
use yew::prelude::*; use yew::prelude::*;
use crate::components::{ use crate::components::{
Identity, Identity,
action::{BinaryChoice, SingleTarget, TargetSelection, WolvesIntro}, action::{BinaryChoice, OptionalSingleTarget, SingleTarget, TwoTarget, WolvesIntro},
}; };
#[derive(Debug, Clone, PartialEq, Properties)] #[derive(Debug, Clone, PartialEq, Properties)]
@ -54,7 +55,6 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
HostNightMessage::ActionResponse(ActionResponse::Seer(target)), HostNightMessage::ActionResponse(ActionResponse::Seer(target)),
))); )));
}) })
.into()
}); });
html! { html! {
<div> <div>
@ -96,7 +96,6 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
HostNightMessage::ActionResponse(ActionResponse::Protector(target)), HostNightMessage::ActionResponse(ActionResponse::Protector(target)),
))); )));
}) })
.into()
}); });
html! { html! {
<div> <div>
@ -108,7 +107,25 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
</div> </div>
} }
} }
ActionPrompt::Arcanist { living_players } => todo!(), ActionPrompt::Arcanist { living_players } => {
let on_complete = props.on_complete.clone();
let on_select = props.big_screen.not().then(|| {
Callback::from(move |(t1, t2): (CharacterId, CharacterId)| {
on_complete.emit(HostMessage::InGame(HostGameMessage::Night(
HostNightMessage::ActionResponse(ActionResponse::Arcanist(t1, t2)),
)));
})
});
html! {
<div>
<TwoTarget
targets={living_players.clone()}
target_selection={on_select}
headline={"arcanist"}
/>
</div>
}
}
ActionPrompt::Gravedigger { dead_players } => { ActionPrompt::Gravedigger { dead_players } => {
let on_complete = props.on_complete.clone(); let on_complete = props.on_complete.clone();
let on_select = props.big_screen.not().then(|| { let on_select = props.big_screen.not().then(|| {
@ -117,7 +134,6 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
HostNightMessage::ActionResponse(ActionResponse::Gravedigger(target)), HostNightMessage::ActionResponse(ActionResponse::Gravedigger(target)),
))); )));
}) })
.into()
}); });
html! { html! {
<div> <div>
@ -132,16 +148,112 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
ActionPrompt::Hunter { ActionPrompt::Hunter {
current_target, current_target,
living_players, living_players,
} => todo!(), } => {
ActionPrompt::Militia { living_players } => todo!(), let on_complete = props.on_complete.clone();
let on_select = props.big_screen.not().then(|| {
Callback::from(move |target: CharacterId| {
on_complete.emit(HostMessage::InGame(HostGameMessage::Night(
HostNightMessage::ActionResponse(ActionResponse::Hunter(target)),
)));
})
});
html! {
<SingleTarget
targets={living_players.clone()}
target_selection={on_select}
headline={"hunter"}
>
<h3>
<b>{"current target: "}</b>{current_target.clone().map(|t| html!{
<Identity ident={t.public} />
}).unwrap_or_else(|| html!{<i>{"none"}</i>})}
</h3>
</SingleTarget>
}
}
ActionPrompt::Militia { living_players } => {
let on_complete = props.on_complete.clone();
let on_select = props.big_screen.not().then(|| {
Callback::from(move |target: Option<CharacterId>| {
on_complete.emit(HostMessage::InGame(HostGameMessage::Night(
HostNightMessage::ActionResponse(ActionResponse::Militia(target)),
)));
})
});
html! {
<OptionalSingleTarget
targets={living_players.clone()}
target_selection={on_select}
headline={"pew pew?"}
/>
}
}
ActionPrompt::MapleWolf { ActionPrompt::MapleWolf {
kill_or_die, kill_or_die,
living_players, living_players,
} => todo!(), } => {
let on_complete = props.on_complete.clone();
let on_select = props.big_screen.not().then(|| {
Callback::from(move |target: Option<CharacterId>| {
on_complete.emit(HostMessage::InGame(HostGameMessage::Night(
HostNightMessage::ActionResponse(ActionResponse::MapleWolf(target)),
)));
})
});
let kill_or_die = kill_or_die.then(|| {
html! {
<em>{"if you fail to eat tonight, you will starve"}</em>
}
});
html! {
<OptionalSingleTarget
targets={living_players.clone()}
target_selection={on_select}
headline={"nom nom?"}
>
{kill_or_die}
</OptionalSingleTarget>
}
}
ActionPrompt::Guardian { ActionPrompt::Guardian {
previous, previous,
living_players, living_players,
} => todo!(), } => {
let last_protect = previous.as_ref().map(|prev| match prev {
PreviousGuardianAction::Protect(target) => {
html! {
<>
<b>{"last night you protected: "}</b>
<Identity ident={target.public.clone()}/>
</>
}
}
PreviousGuardianAction::Guard(target) => html! {
<>
<b>{"last night you guarded: "}</b>
<Identity ident={target.public.clone()}/>
</>
},
});
let on_complete = props.on_complete.clone();
let on_select = props.big_screen.not().then_some({
move |prot| {
on_complete.emit(HostMessage::InGame(HostGameMessage::Night(
HostNightMessage::ActionResponse(ActionResponse::Guardian(prot)),
)));
}
});
html! {
<SingleTarget
targets={living_players.clone()}
target_selection={on_select}
headline={"pick someone to protect"}
>
{last_protect}
</SingleTarget>
}
}
ActionPrompt::WolfPackKill { living_villagers } => { ActionPrompt::WolfPackKill { living_villagers } => {
let on_complete = props.on_complete.clone(); let on_complete = props.on_complete.clone();
let on_select = props.big_screen.not().then(|| { let on_select = props.big_screen.not().then(|| {
@ -150,25 +262,24 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
HostNightMessage::ActionResponse(ActionResponse::WolfPackKillVote(target)), HostNightMessage::ActionResponse(ActionResponse::WolfPackKillVote(target)),
))); )));
}) })
.into()
}); });
html! { html! {
<div> <SingleTarget
<SingleTarget targets={living_villagers.clone()}
targets={living_villagers.clone()} target_selection={on_select}
target_selection={on_select} headline={"wolf pack kill"}
headline={"wolf pack kill"} />
/>
</div>
} }
} }
ActionPrompt::Shapeshifter => { ActionPrompt::Shapeshifter => {
let on_complete = props.on_complete.clone(); let on_complete = props.on_complete.clone();
let on_select = move |shift| { let on_select = props.big_screen.not().then_some({
on_complete.emit(HostMessage::InGame(HostGameMessage::Night( move |shift| {
HostNightMessage::ActionResponse(ActionResponse::Shapeshifter(shift)), on_complete.emit(HostMessage::InGame(HostGameMessage::Night(
))); HostNightMessage::ActionResponse(ActionResponse::Shapeshifter(shift)),
}; )));
}
});
html! { html! {
<BinaryChoice on_chosen={on_select}> <BinaryChoice on_chosen={on_select}>
<h2>{"shapeshift?"}</h2> <h2>{"shapeshift?"}</h2>
@ -183,10 +294,9 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
HostNightMessage::ActionResponse(ActionResponse::AlphaWolf(target)), HostNightMessage::ActionResponse(ActionResponse::AlphaWolf(target)),
))); )));
}) })
.into()
}); });
html! { html! {
<SingleTarget <OptionalSingleTarget
targets={living_villagers.clone()} targets={living_villagers.clone()}
target_selection={on_select} target_selection={on_select}
headline={"alpha wolf target"} headline={"alpha wolf target"}
@ -201,7 +311,6 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
HostNightMessage::ActionResponse(ActionResponse::Direwolf(target)), HostNightMessage::ActionResponse(ActionResponse::Direwolf(target)),
))); )));
}) })
.into()
}); });
html! { html! {
<SingleTarget <SingleTarget

View File

@ -1,67 +1,56 @@
use core::fmt::Debug; use core::{fmt::Debug, ops::Not};
use werewolves_macros::ChecksAs;
use werewolves_proto::{message::Target, player::CharacterId}; use werewolves_proto::{message::Target, player::CharacterId};
use yew::prelude::*; use yew::prelude::*;
use crate::components::Identity; use crate::components::{Button, Identity};
#[derive(Debug, Clone, PartialEq)]
pub enum TargetSelection {
SingleOptional(Callback<Option<CharacterId>>),
Single(Callback<CharacterId>),
}
impl From<Callback<CharacterId>> for TargetSelection {
fn from(value: Callback<CharacterId>) -> Self {
Self::Single(value)
}
}
impl From<Callback<Option<CharacterId>>> for TargetSelection {
fn from(value: Callback<Option<CharacterId>>) -> Self {
Self::SingleOptional(value)
}
}
impl TargetSelection {
pub const fn button_disabled(&self, selected: &[CharacterId]) -> bool {
match self {
TargetSelection::SingleOptional(_) => selected.len() > 1,
TargetSelection::Single(_) => selected.len() != 1,
}
}
}
#[derive(Debug, Clone, PartialEq, Properties)] #[derive(Debug, Clone, PartialEq, Properties)]
pub struct SingleTargetProps { pub struct TwoTargetProps {
pub targets: Box<[Target]>, pub targets: Box<[Target]>,
#[prop_or_default] #[prop_or_default]
pub headline: &'static str, pub headline: &'static str,
#[prop_or_default] #[prop_or_default]
pub target_selection: Option<TargetSelection>, pub target_selection: Option<Callback<(CharacterId, CharacterId)>>,
} }
pub struct SingleTarget { #[derive(ChecksAs, Clone)]
selected: Vec<CharacterId>, enum TwoTargetSelection {
None,
One(CharacterId),
#[checks]
Two(CharacterId, CharacterId),
} }
impl Component for SingleTarget { impl TwoTargetSelection {
fn is_selected(&self, id: &CharacterId) -> bool {
match self {
TwoTargetSelection::None => false,
TwoTargetSelection::One(character_id) => id == character_id,
TwoTargetSelection::Two(cid1, cid2) => id == cid1 || id == cid2,
}
}
}
pub struct TwoTarget(TwoTargetSelection);
impl Component for TwoTarget {
type Message = CharacterId; type Message = CharacterId;
type Properties = SingleTargetProps; type Properties = TwoTargetProps;
fn create(_: &Context<Self>) -> Self { fn create(_: &Context<Self>) -> Self {
Self { Self(TwoTargetSelection::None)
selected: Vec::new(),
}
} }
fn view(&self, ctx: &Context<Self>) -> Html { fn view(&self, ctx: &Context<Self>) -> Html {
let SingleTargetProps { let TwoTargetProps {
headline,
targets, targets,
headline,
target_selection, target_selection,
} = ctx.props(); } = ctx.props();
let target_selection = target_selection.clone(); let target_selection = target_selection.clone();
let scope = ctx.link().clone(); let scope = ctx.link().clone();
let card_select = Callback::from(move |target| { let card_select = Callback::from(move |target| {
@ -73,44 +62,27 @@ impl Component for SingleTarget {
html! { html! {
<TargetCard <TargetCard
target={t.clone()} target={t.clone()}
selected={self.selected.iter().any(|sel| sel == &t.character_id)} selected={self.0.is_selected(&t.character_id)}
on_select={card_select.clone()} on_select={card_select.clone()}
/> />
} }
}) })
.collect::<Html>(); .collect::<Html>();
let headline = if headline.trim().is_empty() { let headline = headline
html!() .trim()
} else { .is_empty()
html!(<h2>{headline}</h2>) .not()
}; .then(|| html!(<h2>{headline}</h2>));
let on_click =
target_selection
.as_ref()
.and_then(|target_selection| match target_selection {
TargetSelection::SingleOptional(on_click) => {
if self.selected.len() > 1 {
None
} else {
let selected = self.selected.first().cloned();
let on_click = on_click.clone();
Some(Callback::from(move |_| on_click.emit(selected.clone())))
}
}
TargetSelection::Single(on_click) => {
if self.selected.len() != 1 {
None
} else {
let selected = self.selected[0].clone();
let on_click = on_click.clone();
Some(Callback::from(move |_| on_click.emit(selected.clone())))
}
}
});
let submit = target_selection.as_ref().map(|target_selection| { let submit = target_selection.as_ref().map(|target_selection| {
let disabled = target_selection.button_disabled(&self.selected); let selected = match &self.0 {
TwoTargetSelection::None | TwoTargetSelection::One(_) => None,
TwoTargetSelection::Two(t1, t2) => Some((t1.clone(), t2.clone())),
};
let target_selection = target_selection.clone();
let disabled = selected.is_none();
let on_click =
selected.map(|(t1, t2)| move |_| target_selection.emit((t1.clone(), t2.clone())));
html! { html! {
<div class="button-container sp-ace"> <div class="button-container sp-ace">
<button <button
@ -135,15 +107,220 @@ impl Component for SingleTarget {
} }
fn update(&mut self, _: &Context<Self>, msg: Self::Message) -> bool { fn update(&mut self, _: &Context<Self>, msg: Self::Message) -> bool {
if let Some(idx) = self match &self.0 {
.selected TwoTargetSelection::None => self.0 = TwoTargetSelection::One(msg),
TwoTargetSelection::One(character_id) => {
if character_id == &msg {
self.0 = TwoTargetSelection::None
} else {
self.0 = TwoTargetSelection::Two(character_id.clone(), msg)
}
}
TwoTargetSelection::Two(t1, t2) => {
if &msg == t1 {
self.0 = TwoTargetSelection::One(t2.clone());
} else if &msg == t2 {
self.0 = TwoTargetSelection::One(t1.clone());
} else {
self.0 = TwoTargetSelection::Two(t1.clone(), msg);
}
}
}
true
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct OptionalSingleTargetProps {
pub targets: Box<[Target]>,
#[prop_or_default]
pub headline: &'static str,
#[prop_or_default]
pub target_selection: Option<Callback<Option<CharacterId>>>,
#[prop_or_default]
pub children: Html,
}
pub struct OptionalSingleTarget(Option<CharacterId>);
impl Component for OptionalSingleTarget {
type Message = CharacterId;
type Properties = OptionalSingleTargetProps;
fn create(_: &Context<Self>) -> Self {
Self(None)
}
fn view(&self, ctx: &Context<Self>) -> Html {
let OptionalSingleTargetProps {
targets,
headline,
target_selection,
children,
} = ctx.props();
let target_selection = target_selection.clone();
let scope = ctx.link().clone();
let card_select = Callback::from(move |target| {
scope.send_message(target);
});
let targets = targets
.iter() .iter()
.enumerate() .map(|t| {
.find_map(|(idx, c)| (c == &msg).then_some(idx)) html! {
{ <TargetCard
self.selected.swap_remove(idx); target={t.clone()}
} else { selected={self.0.as_ref().map(|c| c == &t.character_id).unwrap_or_default()}
self.selected.push(msg); on_select={card_select.clone()}
/>
}
})
.collect::<Html>();
let headline = headline
.trim()
.is_empty()
.not()
.then(|| html!(<h2>{headline}</h2>));
let submit = target_selection.as_ref().map(|target_selection| {
let target_selection = target_selection.clone();
let sel = self.0.clone();
let on_click = move |_| target_selection.emit(sel.clone());
html! {
<div class="button-container sp-ace">
<button
onclick={on_click}
>
{"submit"}
</button>
</div>
}
});
html! {
<div class="column-list">
{headline}
{children.clone()}
<div class="row-list">
{targets}
</div>
{submit}
</div>
}
}
fn update(&mut self, _: &Context<Self>, msg: Self::Message) -> bool {
match &self.0 {
Some(t) => {
if t == &msg {
self.0 = None
} else {
self.0 = Some(msg);
}
}
None => self.0 = Some(msg),
}
true
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct SingleTargetProps {
pub targets: Box<[Target]>,
#[prop_or_default]
pub headline: &'static str,
#[prop_or_default]
pub target_selection: Option<Callback<CharacterId>>,
#[prop_or_default]
pub children: Html,
}
pub struct SingleTarget {
selected: Option<CharacterId>,
}
impl Component for SingleTarget {
type Message = CharacterId;
type Properties = SingleTargetProps;
fn create(_: &Context<Self>) -> Self {
Self { selected: None }
}
fn view(&self, ctx: &Context<Self>) -> Html {
let SingleTargetProps {
headline,
targets,
target_selection,
children,
} = ctx.props();
let target_selection = target_selection.clone();
let scope = ctx.link().clone();
let card_select = Callback::from(move |target| {
scope.send_message(target);
});
let targets = targets
.iter()
.map(|t| {
html! {
<TargetCard
target={t.clone()}
selected={self.selected.as_ref().map(|sel| sel == &t.character_id).unwrap_or_default()}
on_select={card_select.clone()}
/>
}
})
.collect::<Html>();
let headline = headline
.trim()
.is_empty()
.not()
.then(|| html!(<h2>{headline}</h2>));
let submit = target_selection.as_ref().map(|target_selection| {
let disabled = self.selected.is_none();
let target_selection = target_selection.clone();
let on_click = self
.selected
.clone()
.map(|t| move |_| target_selection.emit(t.clone()));
html! {
<div class="button-container sp-ace">
<button
disabled={disabled}
onclick={on_click}
>
{"submit"}
</button>
</div>
}
});
html! {
<div class="column-list">
{headline}
{children.clone()}
<div class="row-list">
{targets}
</div>
{submit}
</div>
}
}
fn update(&mut self, _: &Context<Self>, msg: Self::Message) -> bool {
match &self.selected {
Some(current) => {
if current == &msg {
self.selected = None;
} else {
self.selected = Some(msg);
}
}
None => self.selected = Some(msg),
} }
true true
} }
@ -158,17 +335,24 @@ pub struct TargetCardProps {
#[function_component] #[function_component]
fn TargetCard(props: &TargetCardProps) -> Html { fn TargetCard(props: &TargetCardProps) -> Html {
let on_select = props.on_select.clone(); let submenu = {
let target = props.target.character_id.clone(); let button_text = if props.selected { "unpick" } else { "pick" };
let on_click = Callback::from(move |_| { let character_id = props.target.character_id.clone();
on_select.emit(target.clone()); let on_select = props.on_select.clone();
}); let on_click = Callback::from(move |_| on_select.emit(character_id.clone()));
html! {
<nav class="submenu">
<Button on_click={on_click}>{button_text}</Button>
</nav>
}
};
let marked = props.selected.then_some("marked");
html! { html! {
<div <div class={"row-list baseline margin-5"}>
class={classes!("target-card", "character", props.selected.then_some("selected"))} <div class={classes!("player", "ident", "column-list", marked)}>
onclick={on_click} <Identity ident={props.target.public.clone()} />
> {submenu}
<Identity ident={props.target.public.clone()} /> </div>
</div> </div>
} }
} }

View File

@ -84,7 +84,7 @@ pub fn DaytimePlayer(
) -> Html { ) -> Html {
let dead = died_to.is_some().then_some("dead"); let dead = died_to.is_some().then_some("dead");
let button_text = if *on_the_block { "unmark" } else { "mark" }; let button_text = if *on_the_block { "unmark" } else { "mark" };
let on_the_block = on_the_block.then_some("on-the-block"); let on_the_block = on_the_block.then_some("marked");
let submenu = died_to.is_none().then_some(()).and_then(|_| { let submenu = died_to.is_none().then_some(()).and_then(|_| {
on_select.as_ref().map(|on_select| { on_select.as_ref().map(|on_select| {
let character_id = character_id.clone(); let character_id = character_id.clone();

View File

@ -469,10 +469,15 @@ impl Component for Host {
HostMessage::Echo(ServerToHostMessage::Error(GameError::NoApprenticeMentor)), HostMessage::Echo(ServerToHostMessage::Error(GameError::NoApprenticeMentor)),
self.send.clone(), self.send.clone(),
); );
let on_prev_click = callback::send_message(
HostMessage::InGame(HostGameMessage::PreviousState),
self.send.clone(),
);
html! { html! {
<nav class="debug-nav"> <nav class="debug-nav" style="z-index: 10;">
<div class="row-list"> <div class="row-list">
<Button on_click={on_error_click}>{"error"}</Button> <Button on_click={on_error_click}>{"error"}</Button>
<Button on_click={on_prev_click}>{"previous"}</Button>
</div> </div>
</nav> </nav>
} }