target merge into identity, self updates, etc

This commit is contained in:
emilis 2025-10-02 17:52:12 +01:00
parent 862c5004fd
commit 01c1a4554a
No known key found for this signature in database
45 changed files with 1480 additions and 1053 deletions

766
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,6 @@ uuid = { version = "1.17", features = ["v4", "serde"] }
rand = { version = "0.9" }
werewolves-macros = { path = "../werewolves-macros" }
[dev-dependencies]
pretty_assertions = { version = "1" }
pretty_env_logger = { version = "0.5" }

View File

@ -13,8 +13,6 @@ pub enum GameError {
InvalidMessageForGameState,
#[error("no executions during night time")]
NoExecutionsAtNight,
#[error("no-trial not allowed")]
NoTrialNotAllowed,
#[error("chracter is already dead")]
CharacterAlreadyDead,
#[error("no matching character found")]
@ -47,8 +45,6 @@ pub enum GameError {
CantAddVillagerToSettings,
#[error("no mentor for an apprentice to be an apprentice to :(")]
NoApprenticeMentor,
#[error("BUG: cannot find character in village, but they should be there")]
CannotFindTargetButShouldBeThere,
#[error("inactive game object")]
InactiveGameObject,
#[error("socket error: {0}")]
@ -73,4 +69,6 @@ pub enum GameError {
NoPreviousState,
#[error("invalid original kill for guardian guard")]
GuardianInvalidOriginalKill,
#[error("player not assigned number: {0}")]
PlayerNotAssignedNumber(String),
}

View File

@ -94,8 +94,7 @@ impl Game {
.into_iter()
.map(|c| CharacterState {
player_id: c.player_id().clone(),
character_id: c.character_id().clone(),
public_identity: c.public_identity().clone(),
identity: c.identity(),
role: c.role().title(),
died_to: c.died_to().cloned(),
})
@ -109,9 +108,7 @@ impl Game {
(GameState::Night { night }, HostGameMessage::GetState) => {
if let Some(res) = night.current_result() {
return Ok(ServerToHostMessage::ActionResult(
night
.current_character()
.map(|c| c.public_identity().clone()),
night.current_character().map(|c| c.identity()),
res.clone(),
));
}
@ -138,9 +135,7 @@ impl Game {
HostGameMessage::Night(HostNightMessage::ActionResponse(resp)),
) => match night.received_response(resp.clone()) {
Ok(res) => Ok(ServerToHostMessage::ActionResult(
night
.current_character()
.map(|c| c.public_identity().clone()),
night.current_character().map(|c| c.identity()),
res,
)),
Err(GameError::NightNeedsNext) => match night.next() {

View File

@ -111,7 +111,7 @@ impl Night {
wolves: village
.living_wolf_pack_players()
.into_iter()
.map(|w| (w.target(), w.role().title()))
.map(|w| (w.identity(), w.role().title()))
.collect(),
});
}
@ -324,7 +324,7 @@ impl Night {
.village
.character_by_id(&kill_target)
.ok_or(GameError::NoMatchingCharacterFound)?
.character_identity(),
.identity(),
});
}
// Remove any further shapeshift prompts from the queue

View File

@ -7,14 +7,14 @@ use super::Result;
use crate::{
error::GameError,
game::{DateTime, GameOver, GameSettings},
message::{Identification, Target},
message::{CharacterIdentity, Identification},
player::{Character, CharacterId, PlayerId},
role::{Role, RoleTitle},
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Village {
characters: Vec<Character>,
characters: Box<[Character]>,
date_time: DateTime,
}
@ -58,8 +58,12 @@ impl Village {
.iter()
.cloned()
.zip(roles)
.map(|(player, role)| Character::new(player, role))
.collect(),
.map(|(player, role)| {
let player_str = player.public.to_string();
Character::new(player, role)
.ok_or_else(|| GameError::PlayerNotAssignedNumber(player_str))
})
.collect::<Result<Box<[_]>>>()?,
date_time: DateTime::Night { number: 0 },
})
}
@ -118,17 +122,11 @@ impl Village {
DateTime::Night { number: _ } => return Err(GameError::NoExecutionsAtNight),
};
if characters.is_empty() {
return Err(GameError::NoTrialNotAllowed);
}
let targets = self
.characters
.iter_mut()
.filter(|c| characters.contains(c.character_id()))
.collect::<Box<[_]>>();
if targets.len() != characters.len() {
return Err(GameError::CannotFindTargetButShouldBeThere);
}
for t in targets {
t.execute(day)?;
}
@ -166,39 +164,39 @@ impl Village {
}
}
pub fn living_players(&self) -> Box<[Target]> {
pub fn living_players(&self) -> Box<[CharacterIdentity]> {
self.characters
.iter()
.filter(|c| c.alive())
.map(Character::target)
.map(Character::identity)
.collect()
}
pub fn target_by_id(&self, character_id: &CharacterId) -> Option<Target> {
self.character_by_id(character_id).map(Character::target)
pub fn target_by_id(&self, character_id: &CharacterId) -> Option<CharacterIdentity> {
self.character_by_id(character_id).map(Character::identity)
}
pub fn living_villagers(&self) -> Box<[Target]> {
pub fn living_villagers(&self) -> Box<[CharacterIdentity]> {
self.characters
.iter()
.filter(|c| c.alive() && c.is_village())
.map(Character::target)
.map(Character::identity)
.collect()
}
pub fn living_players_excluding(&self, exclude: &CharacterId) -> Box<[Target]> {
pub fn living_players_excluding(&self, exclude: &CharacterId) -> Box<[CharacterIdentity]> {
self.characters
.iter()
.filter(|c| c.alive() && c.character_id() != exclude)
.map(Character::target)
.map(Character::identity)
.collect()
}
pub fn dead_targets(&self) -> Box<[Target]> {
pub fn dead_targets(&self) -> Box<[CharacterIdentity]> {
self.characters
.iter()
.filter(|c| !c.alive())
.map(Character::target)
.map(Character::identity)
.collect()
}

View File

@ -156,7 +156,7 @@ fn gen_players(range: Range<u8>) -> Box<[Identification]> {
public: PublicIdentity {
name: format!("player {num}"),
pronouns: None,
number: NonZeroU8::new(num).unwrap(),
number: NonZeroU8::new(num),
},
})
.collect()

View File

@ -4,7 +4,7 @@ use pretty_assertions::{assert_eq, assert_ne, assert_str_eq};
use crate::{
message::{
CharacterIdentity, PublicIdentity,
CharacterIdentity,
night::{ActionPrompt, ActionPromptTitle},
},
player::CharacterId,
@ -13,11 +13,9 @@ use crate::{
fn character_identity() -> CharacterIdentity {
CharacterIdentity {
character_id: CharacterId::new(),
public: PublicIdentity {
name: String::new(),
pronouns: None,
number: NonZeroU8::new(1).unwrap(),
},
name: Default::default(),
pronouns: Default::default(),
number: NonZeroU8::new(1).unwrap(),
}
}

View File

@ -14,19 +14,3 @@ pub mod modifier;
pub mod nonzero;
pub mod player;
pub mod role;
#[derive(Debug, Error, Clone, Serialize, Deserialize)]
pub enum MessageError {
#[error("{0}")]
GameError(#[from] GameError),
}
pub(crate) trait MustBeInVillage<T> {
fn must_be_in_village(self) -> Result<T, GameError>;
}
impl<T> MustBeInVillage<T> for Option<T> {
fn must_be_in_village(self) -> Result<T, GameError> {
self.ok_or(GameError::CannotFindTargetButShouldBeThere)
}
}

View File

@ -38,22 +38,6 @@ pub struct DayCharacter {
pub alive: bool,
}
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
pub struct Target {
pub character_id: CharacterId,
pub public: PublicIdentity,
}
impl Display for Target {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Target {
character_id,
public,
} = self;
write!(f, "{public} [(c){character_id}]")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ServerMessage {
Disconnect,

View File

@ -1,13 +1,12 @@
use core::num::NonZeroU8;
use serde::{Deserialize, Serialize};
use werewolves_macros::Extract;
use crate::{
error::GameError,
game::{GameOver, GameSettings},
message::{
PublicIdentity, Target,
CharacterIdentity, PublicIdentity,
night::{ActionPrompt, ActionResponse, ActionResult},
},
player::{CharacterId, PlayerId},
@ -55,6 +54,7 @@ impl From<HostDayMessage> for HostGameMessage {
pub enum HostLobbyMessage {
GetState,
Kick(PlayerId),
SetPlayerNumber(PlayerId, NonZeroU8),
GetGameSettings,
SetGameSettings(GameSettings),
Start,
@ -69,13 +69,13 @@ pub enum ServerToHostMessage {
day: NonZeroU8,
},
ActionPrompt(ActionPrompt),
ActionResult(Option<PublicIdentity>, ActionResult),
ActionResult(Option<CharacterIdentity>, ActionResult),
Lobby(Box<[PlayerState]>),
GameSettings(GameSettings),
Error(GameError),
GameOver(GameOver),
WaitingForRoleRevealAcks {
ackd: Box<[Target]>,
waiting: Box<[Target]>,
ackd: Box<[CharacterIdentity]>,
waiting: Box<[CharacterIdentity]>,
},
}

View File

@ -14,34 +14,70 @@ pub struct Identification {
pub public: PublicIdentity,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct PublicIdentity {
pub name: String,
pub pronouns: Option<String>,
pub number: NonZeroU8,
pub number: Option<NonZeroU8>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CharacterIdentity {
pub character_id: CharacterId,
pub public: PublicIdentity,
pub name: String,
pub pronouns: Option<String>,
pub number: NonZeroU8,
}
impl CharacterIdentity {
pub const fn new(character_id: CharacterId, public: PublicIdentity) -> Self {
impl From<CharacterIdentity> for PublicIdentity {
fn from(c: CharacterIdentity) -> Self {
Self {
character_id,
public,
name: c.name,
pronouns: c.pronouns,
number: Some(c.number),
}
}
}
impl Default for PublicIdentity {
fn default() -> Self {
impl From<&CharacterIdentity> for PublicIdentity {
fn from(c: &CharacterIdentity) -> Self {
Self {
name: Default::default(),
pronouns: Default::default(),
number: NonZeroU8::new(1).unwrap(),
name: c.name.clone(),
pronouns: c.pronouns.clone(),
number: Some(c.number),
}
}
}
impl Display for CharacterIdentity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let CharacterIdentity {
character_id,
name,
pronouns,
number,
} = self;
let pronouns = pronouns
.as_ref()
.map(|p| format!(" ({p})"))
.unwrap_or_default();
write!(f, "[{number}] {name}{pronouns} <<{character_id}>>")
}
}
impl CharacterIdentity {
pub const fn new(
character_id: CharacterId,
name: String,
pronouns: Option<String>,
number: NonZeroU8,
) -> Self {
Self {
name,
number,
pronouns,
character_id,
}
}
}
@ -57,7 +93,11 @@ impl Display for PublicIdentity {
.as_ref()
.map(|p| format!(" ({p})"))
.unwrap_or_default();
write!(f, "[{number}] {name}{pronouns}")
let number = number
.as_ref()
.map(|n| format!("[{n}] "))
.unwrap_or_default();
write!(f, "{number}{name}{pronouns}")
}
}
@ -83,8 +123,7 @@ pub struct PlayerState {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CharacterState {
pub player_id: PlayerId,
pub character_id: CharacterId,
pub public_identity: PublicIdentity,
pub identity: CharacterIdentity,
pub role: RoleTitle,
pub died_to: Option<DiedTo>,
}

View File

@ -7,8 +7,6 @@ use crate::{
role::{Alignment, PreviousGuardianAction, RoleTitle},
};
use super::Target;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, PartialOrd)]
pub enum ActionType {
Cover,
@ -40,7 +38,9 @@ pub enum ActionPrompt {
CoverOfDarkness,
#[checks(ActionType::WolfPackKill)]
#[checks]
WolvesIntro { wolves: Box<[(Target, RoleTitle)]> },
WolvesIntro {
wolves: Box<[(CharacterIdentity, RoleTitle)]>,
},
#[checks(ActionType::RoleChange)]
RoleChange {
character_id: CharacterIdentity,
@ -49,59 +49,61 @@ pub enum ActionPrompt {
#[checks(ActionType::Other)]
Seer {
character_id: CharacterIdentity,
living_players: Box<[Target]>,
living_players: Box<[CharacterIdentity]>,
},
#[checks(ActionType::Protect)]
Protector {
character_id: CharacterIdentity,
targets: Box<[Target]>,
targets: Box<[CharacterIdentity]>,
},
#[checks(ActionType::Other)]
Arcanist {
character_id: CharacterIdentity,
living_players: Box<[Target]>,
living_players: Box<[CharacterIdentity]>,
},
#[checks(ActionType::Other)]
Gravedigger {
character_id: CharacterIdentity,
dead_players: Box<[Target]>,
dead_players: Box<[CharacterIdentity]>,
},
#[checks(ActionType::Other)]
Hunter {
character_id: CharacterIdentity,
current_target: Option<Target>,
living_players: Box<[Target]>,
current_target: Option<CharacterIdentity>,
living_players: Box<[CharacterIdentity]>,
},
#[checks(ActionType::Other)]
Militia {
character_id: CharacterIdentity,
living_players: Box<[Target]>,
living_players: Box<[CharacterIdentity]>,
},
#[checks(ActionType::Other)]
MapleWolf {
character_id: CharacterIdentity,
kill_or_die: bool,
living_players: Box<[Target]>,
living_players: Box<[CharacterIdentity]>,
},
#[checks(ActionType::Protect)]
Guardian {
character_id: CharacterIdentity,
previous: Option<PreviousGuardianAction>,
living_players: Box<[Target]>,
living_players: Box<[CharacterIdentity]>,
},
#[checks(ActionType::WolfPackKill)]
WolfPackKill { living_villagers: Box<[Target]> },
WolfPackKill {
living_villagers: Box<[CharacterIdentity]>,
},
#[checks(ActionType::OtherWolf)]
Shapeshifter { character_id: CharacterIdentity },
#[checks(ActionType::OtherWolf)]
AlphaWolf {
character_id: CharacterIdentity,
living_villagers: Box<[Target]>,
living_villagers: Box<[CharacterIdentity]>,
},
#[checks(ActionType::Direwolf)]
DireWolf {
character_id: CharacterIdentity,
living_players: Box<[Target]>,
living_players: Box<[CharacterIdentity]>,
},
}

View File

@ -6,7 +6,7 @@ use crate::{
diedto::DiedTo,
error::GameError,
game::{DateTime, Village},
message::{CharacterIdentity, Identification, PublicIdentity, Target, night::ActionPrompt},
message::{CharacterIdentity, Identification, PublicIdentity, night::ActionPrompt},
modifier::Modifier,
role::{MAPLE_WOLF_ABSTAIN_LIMIT, PreviousGuardianAction, Role, RoleTitle},
};
@ -77,8 +77,7 @@ pub enum KillOutcome {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Character {
player_id: PlayerId,
character_id: CharacterId,
public: PublicIdentity,
identity: CharacterIdentity,
role: Role,
modifier: Option<Modifier>,
died_to: Option<DiedTo>,
@ -93,46 +92,47 @@ pub struct RoleChange {
}
impl Character {
pub fn new(Identification { player_id, public }: Identification, role: Role) -> Self {
Self {
role,
public,
pub fn new(
Identification {
player_id,
public:
PublicIdentity {
name,
pronouns,
number,
},
}: Identification,
role: Role,
) -> Option<Self> {
Some(Self {
role,
identity: CharacterIdentity {
character_id: CharacterId::new(),
name,
pronouns,
number: number?,
},
player_id,
character_id: CharacterId::new(),
modifier: None,
died_to: None,
role_changes: Vec::new(),
}
})
}
pub fn target(&self) -> Target {
Target {
character_id: self.character_id.clone(),
public: self.public.clone(),
}
}
pub const fn public_identity(&self) -> &PublicIdentity {
&self.public
}
pub fn character_identity(&self) -> CharacterIdentity {
CharacterIdentity {
character_id: self.character_id.clone(),
public: self.public.clone(),
}
pub fn identity(&self) -> CharacterIdentity {
self.identity.clone()
}
pub fn name(&self) -> &str {
&self.public.name
self.identity.name.as_str()
}
pub const fn number(&self) -> NonZeroU8 {
self.public.number
self.identity.number
}
pub const fn pronouns(&self) -> Option<&str> {
match self.public.pronouns.as_ref() {
match self.identity.pronouns.as_ref() {
Some(p) => Some(p.as_str()),
None => None,
}
@ -162,7 +162,7 @@ impl Character {
}
pub const fn character_id(&self) -> &CharacterId {
&self.character_id
&self.identity.character_id
}
pub const fn player_id(&self) -> &PlayerId {
@ -220,24 +220,24 @@ impl Character {
| Role::Scapegoat
| Role::Villager => return Ok(None),
Role::Seer => ActionPrompt::Seer {
character_id: self.character_identity(),
living_players: village.living_players_excluding(&self.character_id),
character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()),
},
Role::Arcanist => ActionPrompt::Arcanist {
character_id: self.character_identity(),
living_players: village.living_players_excluding(&self.character_id),
character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()),
},
Role::Protector {
last_protected: Some(last_protected),
} => ActionPrompt::Protector {
character_id: self.character_identity(),
character_id: self.identity(),
targets: village.living_players_excluding(last_protected),
},
Role::Protector {
last_protected: None,
} => ActionPrompt::Protector {
character_id: self.character_identity(),
targets: village.living_players_excluding(&self.character_id),
character_id: self.identity(),
targets: village.living_players_excluding(self.character_id()),
},
Role::Apprentice(role) => {
let current_night = match village.date_time() {
@ -254,7 +254,7 @@ impl Character {
DateTime::Night { number } => number + 1 >= current_night,
})
.then(|| ActionPrompt::RoleChange {
character_id: self.character_identity(),
character_id: self.identity(),
new_role: role.title(),
}));
}
@ -265,61 +265,61 @@ impl Character {
};
return Ok((current_night == knows_on_night.get()).then_some({
ActionPrompt::RoleChange {
character_id: self.character_identity(),
character_id: self.identity(),
new_role: RoleTitle::Elder,
}
}));
}
Role::Militia { targeted: None } => ActionPrompt::Militia {
character_id: self.character_identity(),
living_players: village.living_players_excluding(&self.character_id),
character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()),
},
Role::Werewolf => ActionPrompt::WolfPackKill {
living_villagers: village.living_players(),
},
Role::AlphaWolf { killed: None } => ActionPrompt::AlphaWolf {
character_id: self.character_identity(),
living_villagers: village.living_players_excluding(&self.character_id),
character_id: self.identity(),
living_villagers: village.living_players_excluding(self.character_id()),
},
Role::DireWolf => ActionPrompt::DireWolf {
character_id: self.character_identity(),
character_id: self.identity(),
living_players: village.living_players(),
},
Role::Shapeshifter { shifted_into: None } => ActionPrompt::Shapeshifter {
character_id: self.character_identity(),
character_id: self.identity(),
},
Role::Gravedigger => ActionPrompt::Gravedigger {
character_id: self.character_identity(),
character_id: self.identity(),
dead_players: village.dead_targets(),
},
Role::Hunter { target } => ActionPrompt::Hunter {
character_id: self.character_identity(),
character_id: self.identity(),
current_target: target.as_ref().and_then(|t| village.target_by_id(t)),
living_players: village.living_players_excluding(&self.character_id),
living_players: village.living_players_excluding(self.character_id()),
},
Role::MapleWolf { last_kill_on_night } => ActionPrompt::MapleWolf {
character_id: self.character_identity(),
character_id: self.identity(),
kill_or_die: last_kill_on_night + MAPLE_WOLF_ABSTAIN_LIMIT.get() == night,
living_players: village.living_players_excluding(&self.character_id),
living_players: village.living_players_excluding(self.character_id()),
},
Role::Guardian {
last_protected: Some(PreviousGuardianAction::Guard(prev_target)),
} => ActionPrompt::Guardian {
character_id: self.character_identity(),
character_id: self.identity(),
previous: Some(PreviousGuardianAction::Guard(prev_target.clone())),
living_players: village.living_players_excluding(&prev_target.character_id),
},
Role::Guardian {
last_protected: Some(PreviousGuardianAction::Protect(prev_target)),
} => ActionPrompt::Guardian {
character_id: self.character_identity(),
character_id: self.identity(),
previous: Some(PreviousGuardianAction::Protect(prev_target.clone())),
living_players: village.living_players(),
},
Role::Guardian {
last_protected: None,
} => ActionPrompt::Guardian {
character_id: self.character_identity(),
character_id: self.identity(),
previous: None,
living_players: village.living_players(),
},

View File

@ -5,7 +5,7 @@ use werewolves_macros::{ChecksAs, Titles};
use crate::{
game::{DateTime, Village},
message::Target,
message::CharacterIdentity,
player::CharacterId,
};
@ -178,6 +178,6 @@ pub enum RoleBlock {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum PreviousGuardianAction {
Protect(Target),
Guard(Target),
Protect(CharacterIdentity),
Guard(CharacterIdentity),
}

View File

@ -5,7 +5,7 @@ edition = "2024"
[dependencies]
axum = { version = "0.8", features = ["ws"] }
tokio = { version = "1.44", features = ["full"] }
tokio = { version = "1.47", features = ["full"] }
log = { version = "0.4" }
pretty_env_logger = { version = "0.5" }
# env_logger = { version = "0.11" }

View File

@ -121,7 +121,7 @@ struct Client {
who: String,
sender: Sender<IdentifiedClientMessage>,
receiver: Receiver<ServerMessage>,
message_history: Vec<ServerMessage>,
// message_history: Vec<ServerMessage>,
}
impl Client {
@ -140,7 +140,7 @@ impl Client {
who,
sender,
receiver,
message_history: Vec::new(),
// message_history: Vec::new(),
}
}
#[cfg(feature = "cbor")]
@ -193,7 +193,7 @@ impl Client {
if let ClientMessage::UpdateSelf(update) = &message {
match update {
UpdateSelf::Name(name) => self.ident.public.name = name.clone(),
UpdateSelf::Number(num) => self.ident.public.number = *num,
UpdateSelf::Number(num) => self.ident.public.number = Some(*num),
UpdateSelf::Pronouns(pronouns) => self.ident.public.pronouns = pronouns.clone(),
}
}
@ -221,7 +221,7 @@ impl Client {
})
})
.await?;
self.message_history.push(message);
// self.message_history.push(message);
Ok(())
}

View File

@ -1,35 +1,25 @@
use core::time::Duration;
use std::collections::HashMap;
use colored::Colorize;
use tokio::{
sync::broadcast::{Receiver, Sender},
time::Instant,
};
use werewolves_proto::{
error::GameError,
message::{ClientMessage, ServerMessage, Target, night::ActionResponse},
player::{Character, CharacterId, PlayerId},
};
use tokio::sync::broadcast::Receiver;
use werewolves_proto::{error::GameError, player::PlayerId};
use crate::{connection::JoinedPlayers, runner::IdentifiedClientMessage};
pub struct PlayerIdComms {
joined_players: JoinedPlayers,
// joined_players: JoinedPlayers,
message_recv: Receiver<IdentifiedClientMessage>,
connect_recv: tokio::sync::broadcast::Receiver<(PlayerId, bool)>,
// connect_recv: tokio::sync::broadcast::Receiver<(PlayerId, bool)>,
}
impl PlayerIdComms {
pub fn new(
joined_players: JoinedPlayers,
// joined_players: JoinedPlayers,
message_recv: Receiver<IdentifiedClientMessage>,
connect_recv: tokio::sync::broadcast::Receiver<(PlayerId, bool)>,
// connect_recv: tokio::sync::broadcast::Receiver<(PlayerId, bool)>,
) -> Self {
Self {
joined_players,
// joined_players,
message_recv,
connect_recv,
// connect_recv,
}
}

View File

@ -36,7 +36,7 @@ pub struct JoinedPlayer {
active_connection: ConnectionId,
in_game: bool,
pub name: String,
pub number: NonZeroU8,
pub number: Option<NonZeroU8>,
pub pronouns: Option<String>,
}
@ -46,7 +46,7 @@ impl JoinedPlayer {
receiver: Receiver<ServerMessage>,
active_connection: ConnectionId,
name: String,
number: NonZeroU8,
number: Option<NonZeroU8>,
pronouns: Option<String>,
) -> Self {
Self {
@ -111,6 +111,16 @@ impl JoinedPlayers {
}
}
pub async fn get_player_identity(&self, player_id: &PlayerId) -> Option<PublicIdentity> {
self.players.lock().await.iter().find_map(|(id, p)| {
(id == player_id).then(|| PublicIdentity {
name: p.name.clone(),
pronouns: p.pronouns.clone(),
number: p.number,
})
})
}
/// Disconnect the player
///
/// Will not disconnect if the player is currently in a game, allowing them to reconnect

View File

@ -3,7 +3,7 @@ use core::ops::Not;
use crate::{
LogError,
communication::{Comms, lobby::LobbyComms},
connection::{InGameToken, JoinedPlayers},
connection::JoinedPlayers,
lobby::{Lobby, LobbyPlayers},
runner::{IdentifiedClientMessage, Message},
};
@ -13,7 +13,7 @@ use werewolves_proto::{
game::{Game, GameOver, Village},
message::{
ClientMessage, Identification, ServerMessage,
host::{HostGameMessage, HostMessage, HostNightMessage, ServerToHostMessage},
host::{HostGameMessage, HostMessage, ServerToHostMessage},
},
player::{Character, PlayerId},
};
@ -94,11 +94,11 @@ impl GameRunner {
.send(ServerToHostMessage::WaitingForRoleRevealAcks {
ackd: acks
.iter()
.filter_map(|(a, ackd)| ackd.then_some(a.target()))
.filter_map(|(a, ackd)| ackd.then_some(a.identity()))
.collect(),
waiting: acks
.iter()
.filter_map(|(a, ackd)| ackd.not().then_some(a.target()))
.filter_map(|(a, ackd)| ackd.not().then_some(a.identity()))
.collect(),
})
.log_err();

View File

@ -3,7 +3,7 @@ use core::{
ops::{Deref, DerefMut},
time::Duration,
};
use std::collections::HashMap;
use std::{collections::HashMap, os::unix::raw::pid_t};
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast::Sender;
@ -178,6 +178,21 @@ impl Lobby {
settings.check()?;
self.settings = settings;
}
Message::Host(HostMessage::Lobby(HostLobbyMessage::SetPlayerNumber(pid, num))) => {
self.joined_players
.update(&pid, |p| p.number = Some(num))
.await;
if let Some(p) = self
.players_in_lobby
.iter_mut()
.find_map(|(c, _)| (c.player_id == pid).then_some(c))
&& let Some(joined_id) = self.joined_players.get_player_identity(&pid).await
{
p.public = joined_id;
}
self.send_lobby_info_to_clients().await;
self.send_lobby_info_to_host().await.log_debug();
}
Message::Host(HostMessage::Lobby(HostLobbyMessage::Start)) => {
if self.players_in_lobby.len() < self.settings.min_players_needed() {
return Err(GameError::TooFewPlayers {
@ -275,12 +290,19 @@ impl Lobby {
message: ClientMessage::UpdateSelf(_),
}) => {
self.joined_players
.update(&player_id, move |p| {
p.name = public.name;
.update(&player_id, |p| {
p.name = public.name.clone();
p.number = public.number;
p.pronouns = public.pronouns;
p.pronouns = public.pronouns.clone();
})
.await;
if let Some(p) = self
.players_in_lobby
.iter_mut()
.find_map(|(c, _)| (c.player_id == player_id).then_some(c))
{
p.public = public;
}
self.send_lobby_info_to_clients().await;
self.send_lobby_info_to_host().await.log_debug();
}

View File

@ -101,7 +101,11 @@ async fn main() {
let lobby_comms = LobbyComms::new(
Comms::new(
HostComms::new(server_send, server_recv),
PlayerIdComms::new(joined_players.clone(), recv, connect_recv.resubscribe()),
PlayerIdComms::new(
//joined_players.clone(),
recv,
// connect_recv.resubscribe()
),
),
connect_recv,
);

View File

@ -14,12 +14,13 @@ web-sys = { version = "0.3", features = [
"HtmlDivElement",
"HtmlSelectElement",
] }
wasm-bindgen = { version = "=0.2.100" }
log = "0.4"
rand = { version = "0.9", features = ["small_rng"] }
getrandom = { version = "0.3", features = ["wasm_js"] }
uuid = { version = "*", features = ["js"] }
yew = { version = "0.21", features = ["csr"] }
yew-router = "0.18.0"
yew-router = "0.18"
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", optional = true }
gloo = "0.11"
@ -29,8 +30,8 @@ once_cell = "1"
chrono = { version = "0.4" }
werewolves-macros = { path = "../werewolves-macros" }
werewolves-proto = { path = "../werewolves-proto" }
futures = "0.3.31"
wasm-bindgen-futures = "0.4.50"
futures = "0.3"
wasm-bindgen-futures = "0.4"
thiserror = { version = "2" }
convert_case = { version = "0.8" }
ciborium = { version = "0.2", optional = true }

View File

@ -14,12 +14,12 @@
<body>
<app></app>
<clients>
<dupe1></dupe1>
<!-- <dupe1></dupe1>
<dupe2></dupe2>
<dupe3></dupe3>
<dupe4></dupe4>
<dupe5></dupe5>
<dupe6></dupe6>
<dupe6></dupe6> -->
</clients>
<error></error>
</body>

View File

@ -54,43 +54,86 @@ nav.debug-nav {
}
.button-container {
button {
font-size: 1.3rem;
border: 1px solid rgba(255, 255, 255, 1);
padding: 5px;
background-color: rgba(0, 0, 0, 0.3);
color: #cccccc;
cursor: pointer;
.default-button {
font-size: 1.3rem;
border: 1px solid rgba(255, 255, 255, 1);
padding: 5px;
background-color: black;
color: #cccccc;
cursor: pointer;
&:hover {
background-color: white;
color: invert(#cccccc);
}
button:hover {
filter: $link_select_filter;
}
// button {
// color: #fff;
// background: transparent;
// background-repeat: no-repeat;
// cursor: pointer;
// overflow: hidden;
// outline: none;
// padding: 0px;
// &:hover {
// background-color: rgba(0, 0, 0, 0.5);
// }
// }
}
.player .number {
padding-top: 3px;
.player {
margin: 0px;
// padding-left: 5px;
// padding-right: 5px;
// padding-bottom: 5px;
min-width: 10rem;
max-width: 10vw;
max-height: 4rem;
text-align: center;
justify-content: center;
font-family: 'Cute Font';
&.marked {
// background-color: brighten($village_color, 100%);
filter: hue-rotate(90deg);
}
&.connected {}
&.disconnected {
// background-color: $disconnected_color;
// border: 3px solid darken($disconnected_color, 20%);
}
&.dead {
filter: grayscale(100%);
}
.number {
padding-top: 3px;
margin: 0px;
&.not-set {
border: 2px solid rgba(255, 0, 0, 0.3);
background-color: rgba(255, 0, 0, 0.7);
}
}
}
.player:hover {
filter: brightness(120%);
.submenu {
background-color: black;
border: 1px solid rgba(255, 255, 255, 0.7);
padding: 10px;
// position: absolute;
position: relative;
// top: 1px;
align-self: stretch;
z-index: 5;
& button {
width: 100%;
}
}
.click-backdrop {
z-index: 4;
background-color: rgba(0, 0, 0, 0.7);
position: fixed;
top: 0;
left: 0;
height: 200vh;
width: 100vw;
background-size: cover;
}
.player-container {
@ -137,41 +180,37 @@ button {
border: none;
width: fit-content;
height: fit-content;
outline: inherit;
padding-left: 5px;
padding-right: 5px;
background-color: #000;
&:disabled {
filter: grayscale(80%);
}
&:disabled:hover {
filter: sepia(100%);
}
&:disabled:hover::after {
content: attr(reason);
position: absolute;
margin-top: 10px;
top: 90%;
font: 'Cute Font';
// color: #000;
// background-color: #fff;
color: rgba(255, 0, 0, 1);
background-color: rgba(255, 0, 0, 0.3);
border: 1px solid rgba(255, 0, 0, 0.3);
min-width: 50vw;
width: fit-content;
padding: 3px;
z-index: 4;
}
}
button:disabled {
filter: grayscale(80%);
}
button:hover {
filter: brightness(80%);
}
button:disabled:hover {
filter: sepia(100%);
}
button:disabled:hover::after {
content: attr(reason);
position: absolute;
margin-top: 10px;
top: 90%;
font: 'Cute Font';
// color: #000;
// background-color: #fff;
color: rgba(255, 0, 0, 1);
background-color: rgba(255, 0, 0, 0.3);
border: 1px solid rgba(255, 0, 0, 0.3);
min-width: 50vw;
width: fit-content;
padding: 3px;
z-index: 3;
}
.settings {
list-style: none;
@ -396,7 +435,6 @@ client {
font-family: 'Cute Font';
// font-size: 0.7rem;
background-color: hsl(280, 55%, 61%);
display: flex;
// flex-wrap: wrap;
flex-direction: column;
@ -405,7 +443,6 @@ client {
padding: 30px;
gap: 30px;
filter: $client_filter;
border: 2px solid black;
}
@ -414,17 +451,10 @@ clients {
font-family: 'Cute Font';
// font-size: 0.7rem;
background-color: white;
display: flex;
flex-wrap: wrap;
flex-direction: row;
font-size: 2rem;
margin-left: 20px;
margin-right: 20px;
gap: 10px;
border: solid 3px;
border-color: #432054;
}
.role-reveal-cards {
@ -550,6 +580,19 @@ clients {
font-size: 1.2rem;
}
.client-nav {
// position: absolute;
// left: 0;
// top: 0;
width: 100%;
padding: 10px;
// background-color: rgba(255, 107, 255, 0.2);
display: flex;
flex-direction: row;
justify-content: baseline;
gap: 10px;
}
.ident {
gap: 0px;
margin: 0px;
@ -558,10 +601,11 @@ clients {
.submenu {
margin-top: 10px;
margin-bottom: 10px;
display: flex;
// display: flex;
justify-content: center;
// visibility: collapse;
display: none;
z-index: 5;
.button-container {
display: flex;
@ -571,6 +615,9 @@ clients {
&.shown {
// visibility: visible;
display: flex;
flex-direction: row;
align-items: baseline;
// position: absolute;
}
button {
@ -617,40 +664,14 @@ error {
}
}
.player {
margin: 0px;
padding-left: 5px;
padding-right: 5px;
text-align: center;
justify-content: center;
font-family: 'Cute Font';
background-color: $village_color;
border: 3px solid darken($village_color, 20%);
&.marked {
// background-color: brighten($village_color, 100%);
filter: hue-rotate(90deg);
}
&.connected {
background-color: $connected_color;
border: 3px solid darken($connected_color, 20%);
}
&.disconnected {
background-color: $disconnected_color;
border: 3px solid darken($disconnected_color, 20%);
}
&.dead {
filter: grayscale(100%);
}
.player-list {
padding-bottom: 80px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 10px;
// align-items: center;
justify-content: space-evenly;
}
.binary {
@ -674,3 +695,45 @@ error {
}
}
}
input {
background-color: rgba(255, 255, 255, 0.1);
color: white;
border: 2px solid rgba(255, 255, 255, 0.2);
margin: 10px;
}
.signin {
@extend .row-list;
justify-content: center;
text-align: center;
& label {
font-size: 1.5rem;
}
& input {
height: 2rem;
text-align: center;
&#number {
font-size: 2rem;
}
}
}
.info-update {
font-size: 2rem;
align-content: stretch;
margin: 0;
& * {
margin: 0;
width: 100%;
text-align: center;
}
}
.zoom {
zoom: 200%;
}

View File

@ -1,4 +1,4 @@
use core::{sync::atomic::AtomicBool, time::Duration};
use core::{num::NonZeroU8, sync::atomic::AtomicBool, time::Duration};
use std::collections::VecDeque;
use futures::{
@ -16,7 +16,6 @@ use werewolves_proto::{
game::GameOver,
message::{
ClientMessage, DayCharacter, Identification, PlayerUpdate, PublicIdentity, ServerMessage,
Target,
night::{ActionPrompt, ActionResponse, ActionResult},
},
player::PlayerId,
@ -25,7 +24,10 @@ use werewolves_proto::{
use yew::{html::Scope, prelude::*};
use crate::{
components::{InputName, Notification},
components::{
Button, Identity, Notification,
client::{ClientNav, InputName},
},
storage::StorageKey,
};
@ -50,8 +52,8 @@ fn url() -> String {
format!(
"{}client",
option_env!("LOCAL")
.map(|_| super::DEBUG_URL)
.unwrap_or(super::LIVE_URL)
.map(|_| crate::clients::DEBUG_URL)
.unwrap_or(crate::clients::LIVE_URL)
)
}
@ -185,7 +187,6 @@ impl Connection {
#[derive(PartialEq)]
pub enum ClientEvent {
Disconnected,
Notification(String),
Waiting,
ShowRole(RoleTitle),
NotInLobby(Box<[PublicIdentity]>),
@ -201,13 +202,17 @@ impl TryFrom<ServerMessage> for ClientEvent {
Ok(match msg {
ServerMessage::Disconnect => Self::Disconnected,
ServerMessage::LobbyInfo {
joined: false,
players,
} => Self::NotInLobby(players),
ServerMessage::LobbyInfo {
joined: true,
players,
} => Self::InLobby(players),
joined,
mut players,
} => {
const LAST: NonZeroU8 = NonZeroU8::new(0xFF).unwrap();
players.sort_by(|l, r| l.number.unwrap_or(LAST).cmp(&r.number.unwrap_or(LAST)));
let players = players.into_iter().collect();
match joined {
true => Self::InLobby(players),
false => Self::NotInLobby(players),
}
}
ServerMessage::GameInProgress => Self::GameInProgress,
_ => return Err(msg),
})
@ -251,10 +256,9 @@ impl Component for Client {
fn create(ctx: &Context<Self>) -> Self {
gloo::utils::document().set_title("Werewolves Player");
let player = StorageKey::PlayerId
.get()
let player = PlayerId::load_from_storage()
.ok()
.and_then(|p| StorageKey::PublicIdentity.get().ok().map(|n| (p, n)))
.and_then(|p| PublicIdentity::load_from_storage().ok().map(|n| (p, n)))
.map(|(player_id, public)| Identification { player_id, public });
let (send, recv) = futures::channel::mpsc::channel::<ClientMessage>(100);
@ -337,11 +341,11 @@ impl Component for Client {
);
html! {
<div class="lobby">
<Button on_click={on_click}>{"Join"}</Button>
<p>{format!("Players in lobby: {}", players.len())}</p>
<ul class="players">
{players.iter().map(|p| html!{<p>{p.to_string()}</p>}).collect::<Html>()}
</ul>
<button onclick={on_click}>{"Join"}</button>
</div>
}
}
@ -355,11 +359,11 @@ impl Component for Client {
);
html! {
<div class="lobby">
<Button on_click={on_click}>{"Leave"}</Button>
<p>{format!("Players in lobby: {}", players.len())}</p>
<ul class="players">
{players.iter().map(|p| html!{<p>{p.to_string()}</p>}).collect::<Html>()}
</ul>
<button onclick={on_click}>{"Leave"}</button>
</div>
}
}
@ -378,43 +382,42 @@ impl Component for Client {
}}</p>
</div>
},
ClientEvent::Notification(notification) => {
let scope = ctx.link().clone();
let next_event =
// Callback::from(move |_| scope.clone().send_message(Message::));
Callback::from(move |_| log::info!("nothing"));
html! {
<Notification text={notification.clone()} callback={next_event}/>
}
}
};
let player = self
.player
.as_ref()
.map(|player| {
let pronouns = if let Some(pronouns) = player.public.pronouns.as_ref() {
html! {
<p class={"pronouns"}>{"("}{pronouns.as_str()}{")"}</p>
}
} else {
html!()
};
html! {
<player>
<p>{player.public.number.get()}</p>
<name>{player.public.name.clone()}</name>
{pronouns}
</player>
<Identity ident={player.public.clone()} class="zoom">
</Identity>
}
})
.unwrap_or(html!());
let send = self.send.clone();
let client_nav_msg_cb = move |msg| {
let mut send = send.clone();
yew::platform::spawn_local(async move {
if let Err(err) = send.send(msg).await {
log::error!("sending nav message: {err}");
}
});
};
let nav = self.player.as_ref().map(|_| {
html! {
<ClientNav message_callback={client_nav_msg_cb} />
}
});
html! {
<client>
{player}
{content}
</client>
<>
{nav}
<client>
{player}
{content}
</client>
</>
}
}
@ -431,34 +434,45 @@ impl Component for Client {
}
Message::SetPublicIdentity(public) => {
match self.player.as_mut() {
Some(p) => p.public = public,
Some(p) => {
if let Err(err) = public.save_to_storage() {
self.error(err.into());
return false;
}
p.public = public;
}
None => {
let res =
StorageKey::PlayerId
.get_or_set(PlayerId::new)
.and_then(|player_id| {
StorageKey::PublicIdentity
.set(public.clone())
.map(|_| Identification { player_id, public })
});
match res {
Ok(ident) => {
self.player = Some(ident.clone());
if let Some(recv) = self.recv.take() {
yew::platform::spawn_local(
Connection {
scope: ctx.link().clone(),
ident,
recv,
}
.run(),
);
let player_id = match PlayerId::load_from_storage() {
Ok(pid) => pid,
Err(StorageError::KeyNotFound(_)) => {
let pid = PlayerId::new();
if let Err(err) = pid.save_to_storage() {
self.error(err.into());
return false;
}
pid
}
Err(err) => {
self.error(err.into());
return false;
}
};
if let Err(err) = public.save_to_storage() {
self.error(err.into());
return false;
}
let ident = Identification { player_id, public };
self.player = Some(ident.clone());
if let Some(recv) = self.recv.take() {
yew::platform::spawn_local(
Connection {
recv,
ident,
scope: ctx.link().clone(),
}
.run(),
);
}
}
}
@ -469,17 +483,16 @@ impl Component for Client {
joined: false,
players: _,
} = &msg
&& self.auto_join
{
if self.auto_join {
let mut send = self.send.clone();
yew::platform::spawn_local(async move {
if let Err(err) = send.send(ClientMessage::Hello).await {
log::error!("send: {err}");
}
});
self.auto_join = false;
return false;
}
let mut send = self.send.clone();
yew::platform::spawn_local(async move {
if let Err(err) = send.send(ClientMessage::Hello).await {
log::error!("send: {err}");
}
});
self.auto_join = false;
return false;
}
let msg = match msg.try_into() {
Ok(event) => {
@ -519,7 +532,7 @@ impl Component for Client {
ServerMessage::Sleep => self.current_event = Some(ClientEvent::Waiting),
ServerMessage::Update(update) => match (update, self.player.as_mut()) {
(PlayerUpdate::Number(num), Some(player)) => {
player.public.number = num;
player.public.number = Some(num);
return true;
}
(_, None) => return false,

View File

@ -1,4 +1,5 @@
use core::{num::NonZeroU8, ops::Not, time::Duration};
use std::{rc::Rc, sync::Arc};
use futures::{
SinkExt, StreamExt,
@ -11,7 +12,7 @@ use werewolves_proto::{
error::GameError,
game::{GameOver, GameSettings},
message::{
CharacterState, PlayerState, PublicIdentity, Target,
CharacterIdentity, CharacterState, PlayerState, PublicIdentity,
host::{
HostDayMessage, HostGameMessage, HostLobbyMessage, HostMessage, HostNightMessage,
ServerToHostMessage,
@ -37,8 +38,8 @@ fn url() -> String {
format!(
"{}host",
option_env!("LOCAL")
.map(|_| super::DEBUG_URL)
.unwrap_or(super::LIVE_URL)
.map(|_| crate::clients::DEBUG_URL)
.unwrap_or(crate::clients::LIVE_URL)
)
}
@ -184,7 +185,7 @@ pub enum HostEvent {
pub enum HostState {
Disconnected,
Lobby {
players: Box<[PlayerState]>,
players: Rc<[PlayerState]>,
settings: GameSettings,
},
Day {
@ -196,11 +197,11 @@ pub enum HostState {
result: GameOver,
},
RoleReveal {
ackd: Box<[Target]>,
waiting: Box<[Target]>,
ackd: Box<[CharacterIdentity]>,
waiting: Box<[CharacterIdentity]>,
},
Prompt(ActionPrompt),
Result(Option<PublicIdentity>, ActionResult),
Result(Option<CharacterIdentity>, ActionResult),
}
impl From<ServerToHostMessage> for HostEvent {
@ -340,7 +341,7 @@ impl Component for Host {
settings={settings}
on_start={on_start}
on_update={on_changed}
players_in_lobby={players.len()}
players_in_lobby={players.clone()}
/>
}
});
@ -352,6 +353,9 @@ impl Component for Host {
LobbyPlayerAction::Kick => {
HostMessage::Lobby(HostLobbyMessage::Kick(player_id))
}
LobbyPlayerAction::SetNumber(num) => HostMessage::Lobby(
HostLobbyMessage::SetPlayerNumber(player_id, num),
),
};
let mut send = send.clone();
let on_error = on_error.clone();
@ -404,7 +408,7 @@ impl Component for Host {
HostState::RoleReveal { ackd, waiting } => {
let send = self.send.clone();
let on_force_ready = self.big_screen.not().then(|| {
Callback::from(move |target: Target| {
Callback::from(move |target: CharacterIdentity| {
let send = send.clone();
yew::platform::spawn_local(async move {
if let Err(err) = send
@ -456,7 +460,7 @@ impl Component for Host {
result={result}
big_screen={self.big_screen}
on_complete={on_complete}
ident={ident}
ident={ident.map(|i| i.into())}
/>
}
}
@ -471,7 +475,7 @@ impl Component for Host {
self.send.clone(),
);
html! {
<nav class="debug-nav" style="z-index: 10;">
<nav class="debug-nav" style="z-index: 3;">
<Button on_click={on_error_click}>{"error"}</Button>
<Button on_click={on_prev_click}>{"previous"}</Button>
</nav>
@ -489,17 +493,26 @@ impl Component for Host {
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
HostEvent::PlayerList(players) => {
HostEvent::PlayerList(mut players) => {
const LAST: NonZeroU8 = NonZeroU8::new(0xFF).unwrap();
players.sort_by(|l, r| {
l.identification
.public
.number
.unwrap_or(LAST)
.cmp(&r.identification.public.number.unwrap_or(LAST))
});
match &mut self.state {
HostState::Lobby {
players: p,
settings: _,
} => *p = players,
} => *p = players.into_iter().collect(),
HostState::Disconnected | HostState::GameOver { result: _ } => {
let mut send = self.send.clone();
let on_err = self.error_callback.clone();
self.state = HostState::Lobby {
players,
players: players.into_iter().collect(),
settings: Default::default(),
};
yew::platform::spawn_local(async move {

View File

@ -1,8 +1,10 @@
pub mod client {
include!("client/client.rs");
mod client;
pub use client::*;
}
pub mod host {
include!("host/host.rs");
mod host;
pub use host::*;
}
// mod socket;

View File

@ -2,7 +2,7 @@ use core::ops::Not;
use werewolves_proto::{
message::{
PublicIdentity,
CharacterIdentity, PublicIdentity,
host::{HostGameMessage, HostMessage, HostNightMessage},
night::{ActionPrompt, ActionResponse},
},
@ -24,14 +24,15 @@ pub struct ActionPromptProps {
pub on_complete: Callback<HostMessage>,
}
fn identity_html(props: &ActionPromptProps, ident: Option<&PublicIdentity>) -> Option<Html> {
fn identity_html(props: &ActionPromptProps, ident: Option<&CharacterIdentity>) -> Option<Html> {
props
.big_screen
.not()
.then(|| {
ident.map(|ident| {
let ident: PublicIdentity = ident.into();
html! {
<Identity ident={ident.clone()}/>
<Identity ident={ident}/>
}
})
})
@ -85,7 +86,7 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
});
html! {
<div>
{identity_html(props, Some(&character_id.public))}
{identity_html(props, Some(&character_id))}
<SingleTarget
targets={living_players.clone()}
target_selection={on_select}
@ -111,7 +112,7 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
});
html! {
<div>
{identity_html(props, Some(&character_id.public))}
{identity_html(props, Some(&character_id))}
<h2>{"your role has changed"}</h2>
<p>{new_role.to_string()}</p>
{cont}
@ -132,7 +133,7 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
});
html! {
<div>
{identity_html(props, Some(&character_id.public))}
{identity_html(props, Some(&character_id))}
<SingleTarget
targets={targets.clone()}
target_selection={on_select}
@ -155,7 +156,7 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
});
html! {
<div>
{identity_html(props, Some(&character_id.public))}
{identity_html(props, Some(&character_id))}
<TwoTarget
targets={living_players.clone()}
target_selection={on_select}
@ -178,7 +179,7 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
});
html! {
<div>
{identity_html(props, Some(&character_id.public))}
{identity_html(props, Some(&character_id))}
<SingleTarget
targets={dead_players.clone()}
target_selection={on_select}
@ -202,7 +203,7 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
});
html! {
<div>
{identity_html(props, Some(&character_id.public))}
{identity_html(props, Some(&character_id))}
<SingleTarget
targets={living_players.clone()}
target_selection={on_select}
@ -210,7 +211,7 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
>
<h3>
<b>{"current target: "}</b>{current_target.clone().map(|t| html!{
<Identity ident={t.public} />
<Identity ident={Into::<PublicIdentity>::into(t)} />
}).unwrap_or_else(|| html!{<i>{"none"}</i>})}
</h3>
</SingleTarget>
@ -231,7 +232,7 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
});
html! {
<div>
{identity_html(props, Some(&character_id.public))}
{identity_html(props, Some(&character_id))}
<OptionalSingleTarget
targets={living_players.clone()}
target_selection={on_select}
@ -260,7 +261,7 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
});
html! {
<div>
{identity_html(props, Some(&character_id.public))}
{identity_html(props, Some(&character_id))}
<OptionalSingleTarget
targets={living_players.clone()}
target_selection={on_select}
@ -281,14 +282,14 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
html! {
<>
<b>{"last night you protected: "}</b>
<Identity ident={target.public.clone()}/>
<Identity ident={Into::<PublicIdentity>::into(target)}/>
</>
}
}
PreviousGuardianAction::Guard(target) => html! {
<>
<b>{"last night you guarded: "}</b>
<Identity ident={target.public.clone()}/>
<Identity ident={Into::<PublicIdentity>::into(target)}/>
</>
},
});
@ -303,7 +304,7 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
html! {
<div>
{identity_html(props, Some(&character_id.public))}
{identity_html(props, Some(&character_id))}
<SingleTarget
targets={living_players.clone()}
target_selection={on_select}
@ -342,7 +343,7 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
});
html! {
<div>
{identity_html(props, Some(&character_id.public))}
{identity_html(props, Some(&character_id))}
<BinaryChoice on_chosen={on_select}>
<h2>{"shapeshift?"}</h2>
</BinaryChoice>
@ -363,7 +364,7 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
});
html! {
<div>
{identity_html(props, Some(&character_id.public))}
{identity_html(props, Some(&character_id))}
<OptionalSingleTarget
targets={living_villagers.clone()}
target_selection={on_select}
@ -386,7 +387,7 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
});
html! {
<div>
{identity_html(props, Some(&character_id.public))}
{identity_html(props, Some(&character_id))}
<SingleTarget
targets={living_players.clone()}
target_selection={on_select}

View File

@ -2,14 +2,17 @@ use core::{fmt::Debug, ops::Not};
use std::sync::Arc;
use werewolves_macros::ChecksAs;
use werewolves_proto::{message::Target, player::CharacterId};
use werewolves_proto::{
message::{CharacterIdentity, PublicIdentity},
player::CharacterId,
};
use yew::prelude::*;
use crate::components::{Button, Identity};
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct TwoTargetProps {
pub targets: Box<[Target]>,
pub targets: Box<[CharacterIdentity]>,
#[prop_or_default]
pub headline: &'static str,
#[prop_or_default]
@ -52,7 +55,7 @@ impl Component for TwoTarget {
target_selection,
} = ctx.props();
let mut targets = targets.clone();
targets.sort_by(|l, r| l.public.number.cmp(&r.public.number));
targets.sort_by(|l, r| l.number.cmp(&r.number));
let target_selection = target_selection.clone();
let scope = ctx.link().clone();
@ -136,7 +139,7 @@ impl Component for TwoTarget {
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct OptionalSingleTargetProps {
pub targets: Box<[Target]>,
pub targets: Box<[CharacterIdentity]>,
#[prop_or_default]
pub headline: &'static str,
#[prop_or_default]
@ -164,7 +167,7 @@ impl Component for OptionalSingleTarget {
children,
} = ctx.props();
let mut targets = targets.clone();
targets.sort_by(|l, r| l.public.number.cmp(&r.public.number));
targets.sort_by(|l, r| l.number.cmp(&r.number));
let target_selection = target_selection.clone();
let scope = ctx.link().clone();
@ -233,7 +236,7 @@ impl Component for OptionalSingleTarget {
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct SingleTargetProps {
pub targets: Box<[Target]>,
pub targets: Box<[CharacterIdentity]>,
#[prop_or_default]
pub headline: &'static str,
#[prop_or_default]
@ -263,7 +266,7 @@ impl Component for SingleTarget {
children,
} = ctx.props();
let mut targets = targets.clone();
targets.sort_by(|l, r| l.public.number.cmp(&r.public.number));
targets.sort_by(|l, r| l.number.cmp(&r.number));
let target_selection = target_selection.clone();
let scope = ctx.link().clone();
let card_select = Callback::from(move |target| {
@ -335,7 +338,7 @@ impl Component for SingleTarget {
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct TargetCardProps {
pub target: Target,
pub target: CharacterIdentity,
pub selected: bool,
pub on_select: Callback<CharacterId>,
}
@ -357,7 +360,7 @@ fn TargetCard(props: &TargetCardProps) -> Html {
html! {
<div class={"row-list baseline margin-5"}>
<div class={classes!("player", "ident", "column-list", marked)}>
<Identity ident={props.target.public.clone()} />
<Identity ident={Into::<PublicIdentity>::into(&props.target)} />
{submenu}
</div>
</div>
@ -366,7 +369,7 @@ fn TargetCard(props: &TargetCardProps) -> Html {
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct CustomTargetCardProps {
pub target: Target,
pub target: CharacterIdentity,
pub options: Arc<[String]>,
pub on_select: Option<Callback<(CharacterId, String)>>,
#[prop_or_default]
@ -414,7 +417,7 @@ pub fn CustomTargetCard(
html! {
<div class={"row-list baseline"}>
<div class={classes!("ident", "column-list", class)}>
<Identity ident={target.public.clone()} />
<Identity ident={Into::<PublicIdentity>::into(target)} />
{submenu}
</div>
</div>

View File

@ -1,13 +1,16 @@
use core::ops::Not;
use werewolves_proto::{message::Target, role::RoleTitle};
use werewolves_proto::{
message::{CharacterIdentity, PublicIdentity},
role::RoleTitle,
};
use yew::prelude::*;
use crate::components::{Button, Identity};
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct WolvesIntroProps {
pub wolves: Box<[(Target, RoleTitle)]>,
pub wolves: Box<[(CharacterIdentity, RoleTitle)]>,
pub big_screen: bool,
pub on_complete: Callback<()>,
}
@ -24,7 +27,7 @@ pub fn WolvesIntro(props: &WolvesIntroProps) -> Html {
props.wolves.iter().map(|w| html!{
<div class="character wolves">
<p class="role">{w.1.to_string()}</p>
<Identity ident={w.0.public.clone()} />
<Identity ident={Into::<PublicIdentity>::into(&w.0)} />
</div>
}).collect::<Html>()
}

View File

@ -14,7 +14,6 @@ pub fn Button(props: &ButtonProperties) -> Html {
let on_click = props.on_click.clone();
let on_click = Callback::from(move |_| on_click.emit(()));
html! {
<div class="button-container">
<button
class="default-button"
disabled={props.disabled_reason.is_some()}
@ -23,6 +22,5 @@ pub fn Button(props: &ButtonProperties) -> Html {
>
{props.children.clone()}
</button>
</div>
}
}

View File

@ -0,0 +1,207 @@
use core::{fmt::Debug, num::NonZeroU8, ops::Not};
use werewolves_proto::{
message::{ClientMessage, PublicIdentity, UpdateSelf},
player::PlayerId,
};
use yew::prelude::*;
use crate::{
components::{Button, ClickableField, ClickableNumberEdit},
storage::StorageKey,
};
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct ClientNavProps {
pub message_callback: Callback<ClientMessage>,
}
#[function_component]
pub fn ClientNav(ClientNavProps { message_callback }: &ClientNavProps) -> Html {
let ident = use_state(|| {
PublicIdentity::load_from_storage().expect("don't call client nav without an identity")
});
let pronouns = ident
.pronouns
.as_ref()
.map(|pronouns| {
html! {
<div>{"("}{pronouns.as_str()}{")"}</div>
}
})
.unwrap_or_else(|| {
html! {
<div>{"(None)"}</div>
}
});
let number = {
let current_value = use_state(String::new);
let message_callback = message_callback.clone();
let submit_ident = ident.clone();
let current_num = ident
.number
.map(|v| v.to_string())
.unwrap_or_else(|| String::from("???"));
let open = use_state(|| false);
let open_set = open.setter();
let on_submit = {
let val = current_value.clone();
Callback::from(move |_| {
let num = match val.trim().parse::<NonZeroU8>().ok() {
Some(num) => num,
None => return,
};
message_callback.emit(ClientMessage::UpdateSelf(UpdateSelf::Number(num)));
let new_ident = PublicIdentity {
name: submit_ident.name.clone(),
pronouns: submit_ident.pronouns.clone(),
number: Some(num),
};
submit_ident.set(new_ident.clone());
if let Err(err) = new_ident.save_to_storage() {
log::error!("saving public identity after change: {err}");
}
open_set.set(false);
})
};
html! {
<ClickableNumberEdit
value={current_value.clone()}
field_name="number"
on_submit={on_submit}
state={open}
>
<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 = ident.clone();
let message_callback = message_callback.clone();
Callback::from(move |value: String| -> Option<PublicIdentity> {
value.trim().is_empty().not().then(|| {
let name = value.trim().to_string();
message_callback
.emit(ClientMessage::UpdateSelf(UpdateSelf::Name(name.clone())));
PublicIdentity {
name,
number: ident.number,
pronouns: ident.pronouns.clone(),
}
})
})
};
html! {
<ClickableTextEdit
value={name.clone()}
submit_ident={ident.clone()}
field_name="pronouns"
on_submit={on_submit}
state={open}
>
<div class="name">{ident.name.as_str()}</div>
</ClickableTextEdit>
}
};
let pronouns = {
let pronouns_state = use_state(String::new);
let on_submit = {
let ident = ident.clone();
let message_callback = message_callback.clone();
Callback::from(move |value: String| -> Option<PublicIdentity> {
let pronouns = value.trim().is_empty().not().then_some(value);
message_callback.emit(ClientMessage::UpdateSelf(UpdateSelf::Pronouns(
pronouns.clone(),
)));
Some(PublicIdentity {
pronouns,
name: ident.name.clone(),
number: ident.number,
})
})
};
let open = use_state(|| false);
html! {
<ClickableTextEdit
value={pronouns_state}
submit_ident={ident.clone()}
field_name="pronouns"
on_submit={on_submit}
state={open}
>
{pronouns}
</ClickableTextEdit>
}
};
let cb = message_callback.clone();
let forgor = move |_| {
PlayerId::delete();
PublicIdentity::delete();
cb.emit(ClientMessage::Goodbye);
let _ = gloo::utils::window().location().reload();
};
html! {
<nav class="client-nav">
{number}
{name}
{pronouns}
<Button on_click={forgor}>{"forgor 💀"}</Button>
</nav>
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
struct ClickableTextEditProps {
#[prop_or_default]
pub children: Html,
pub value: UseStateHandle<String>,
pub submit_ident: UseStateHandle<PublicIdentity>,
pub on_submit: Callback<String, Option<PublicIdentity>>,
pub field_name: &'static str,
pub state: UseStateHandle<bool>,
}
#[function_component]
fn ClickableTextEdit(
ClickableTextEditProps {
children,
value,
submit_ident,
field_name,
on_submit,
state,
}: &ClickableTextEditProps,
) -> Html {
let on_input = crate::components::input_element_string_oninput(value.setter());
let submit_ident = submit_ident.clone();
let message_callback = on_submit.clone();
let value = value.clone();
let open_set = state.setter();
let submit = move |_| {
if let Some(new_ident) = message_callback.emit(value.trim().to_string()) {
submit_ident.set(new_ident.clone());
if let Err(err) = new_ident.save_to_storage() {
log::error!("saving public identity after change: {err}");
}
open_set.set(false);
}
};
let options = html! {
<div class="row-list info-update">
<input type="text" oninput={on_input} name={*field_name}/>
<Button on_click={submit}>{"ok"}</Button>
</div>
};
html! {
<ClickableField options={options} state={state.clone()}>
{children.clone()}
</ClickableField>
}
}

View File

@ -0,0 +1,82 @@
use core::num::NonZeroU8;
use web_sys::HtmlInputElement;
use werewolves_proto::message::PublicIdentity;
use yew::prelude::*;
use crate::components::Button;
#[derive(Debug, PartialEq, Properties)]
pub struct InputProps {
pub callback: Callback<PublicIdentity, ()>,
}
#[function_component]
pub fn InputName(props: &InputProps) -> Html {
let callback = props.callback.clone();
let num_value = use_state(String::new);
let name_value = use_state(String::new);
let pronouns_value = use_state(String::new);
let on_input_update = |state: UseStateSetter<String>| {
move |ev: InputEvent| {
if let Some(target) = ev.target_dyn_into::<HtmlInputElement>() {
state.set(target.value());
}
}
};
let name_on_input = on_input_update(name_value.setter());
let pronouns_on_input = on_input_update(pronouns_value.setter());
let on_click_num_value = num_value.clone();
let on_click = Callback::from(move |_| {
let name = name_value.trim().to_string();
let pronouns = match pronouns_value.trim() {
"" => None,
p => Some(p.to_string()),
};
let number = on_click_num_value.parse::<NonZeroU8>().ok();
if name.is_empty() {
return;
}
callback.emit(PublicIdentity {
name,
pronouns,
number,
});
});
// let num_value = num_value.clone();
// let on_change = move |ev: InputEvent| {
// let data = ev.data();
// if let Some(z) = data.as_ref().and_then(|d| d.trim().parse::<u8>().ok())
// && !(z == 0 && num_value.is_empty())
// {
// let new_value = format!("{}{z}", num_value.as_str());
// num_value.set(new_value);
// return;
// } else if data.is_none()
// && let Some(target) = ev.target_dyn_into::<HtmlInputElement>()
// {
// num_value.set(target.value());
// return;
// }
// if let Some(target) = ev.target_dyn_into::<HtmlInputElement>() {
// target.set_value(num_value.as_str());
// }
// };
let on_change = crate::components::input_element_number_oninput(num_value);
html! {
<div class="signin">
<div class="column-list">
<label for="name">{"Name"}</label>
<input oninput={name_on_input} name="name" id="name" type="text"/>
<label for="pronouns">{"Pronouns"}</label>
<input oninput={pronouns_on_input} name="pronouns" id="pronouns" type="text"/>
<label for="number">{"Number"}</label>
<input oninput={on_change} type="text" name="number" id="number"/>
<Button on_click={on_click}>{"Submit"}</Button>
</div>
</div>
}
}

View File

@ -0,0 +1,89 @@
use yew::prelude::*;
use crate::components::Button;
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct ClickableFieldProps {
#[prop_or_default]
pub children: Html,
pub options: Html,
#[prop_or_default]
pub class: yew::Classes,
#[prop_or_default]
pub with_backdrop_exit: bool,
pub state: UseStateHandle<bool>,
}
#[function_component]
pub fn ClickableField(
ClickableFieldProps {
children,
options,
class,
with_backdrop_exit,
state,
}: &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 submenu_open_close = open_close.clone();
let submenu = open.clone().then(|| {
let backdrop = with_backdrop_exit.then(|| {
html! {
<div onclick={move |_| submenu_open_close.emit(())} class="click-backdrop"/>
}
});
html! {
<>
<nav class="submenu shown">
{options.clone()}
</nav>
{backdrop}
</>
}
});
html! {
<div class={class.clone()}>
<Button on_click={open_close}>{children.clone()}</Button>
{submenu}
</div>
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct ClickableNumberEditProps {
#[prop_or_default]
pub children: Html,
pub value: UseStateHandle<String>,
pub on_submit: Callback<()>,
pub field_name: &'static str,
pub state: UseStateHandle<bool>,
}
#[function_component]
pub fn ClickableNumberEdit(
ClickableNumberEditProps {
children,
value,
field_name,
on_submit,
state,
}: &ClickableNumberEditProps,
) -> Html {
let on_input = crate::components::input_element_string_oninput(value.setter());
let on_submit = on_submit.clone();
let options = html! {
<div class="row-list info-update">
<input type="text" oninput={on_input} name={*field_name}/>
<Button on_click={on_submit.clone()}>{"ok"}</Button>
</div>
};
html! {
<ClickableField options={options} state={state.clone()}>
{children.clone()}
</ClickableField>
}
}

View File

@ -1,6 +1,9 @@
use core::ops::Not;
use werewolves_proto::{message::CharacterState, player::CharacterId};
use werewolves_proto::{
message::{CharacterState, PublicIdentity},
player::CharacterId,
};
use yew::prelude::*;
use crate::components::{Button, Identity};
@ -26,14 +29,14 @@ pub fn DaytimePlayerList(
) -> Html {
let on_select = big_screen.not().then(|| on_mark.clone());
let mut characters = characters.clone();
characters.sort_by(|l, r| l.public_identity.number.cmp(&r.public_identity.number));
characters.sort_by(|l, r| l.identity.number.cmp(&r.identity.number));
let chars = characters
.iter()
.map(|c| {
html! {
<DaytimePlayer
character={c.clone()}
on_the_block={marked.contains(&c.character_id)}
on_the_block={marked.contains(&c.identity.character_id)}
on_select={on_select.clone()}
/>
}
@ -77,10 +80,9 @@ pub fn DaytimePlayer(
character:
CharacterState {
player_id: _,
character_id,
public_identity,
role: _,
died_to,
identity,
},
}: &DaytimePlayerProps,
) -> Html {
@ -89,7 +91,7 @@ pub fn DaytimePlayer(
let on_the_block = on_the_block.then_some("marked");
let submenu = died_to.is_none().then_some(()).and_then(|_| {
on_select.as_ref().map(|on_select| {
let character_id = character_id.clone();
let character_id = identity.character_id.clone();
let on_select = on_select.clone();
let on_click = Callback::from(move |_| on_select.emit(character_id.clone()));
html! {
@ -100,9 +102,10 @@ pub fn DaytimePlayer(
})
});
let identity: PublicIdentity = identity.into();
html! {
<div class={classes!("player", dead, on_the_block, "column-list", "ident")}>
<Identity ident={public_identity.clone()}/>
<Identity ident={identity}/>
{submenu}
</div>
}

View File

@ -1,3 +1,4 @@
use core::ops::Deref;
use werewolves_proto::message::PublicIdentity;
use yew::prelude::*;
@ -24,11 +25,29 @@ pub fn Identity(props: &IdentityProps) -> Html {
<p class="pronouns">{"("}{p}{")"}</p>
}
});
let not_set = number.is_none().then_some("not-set");
let number = number
.map(|n| n.to_string())
.unwrap_or_else(|| String::from("???"));
html! {
<div class={classes!("identity", class)}>
<p class="number"><b>{number.get()}</b></p>
<p class={classes!("number", not_set)}><b>{number}</b></p>
<p>{name}</p>
{pronouns}
</div>
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct StatefulIdentityProps {
pub ident: UseStateHandle<PublicIdentity>,
#[prop_or_default]
pub class: Option<String>,
}
#[function_component]
pub fn StatefulIdentity(props: &StatefulIdentityProps) -> Html {
html! {
<Identity ident={props.ident.deref().clone()} class={props.class.clone()}/>
}
}

View File

@ -0,0 +1,29 @@
use core::num::NonZeroU8;
use web_sys::HtmlInputElement;
use yew::prelude::*;
pub fn input_element_number_oninput(num_value: UseStateHandle<String>) -> Callback<InputEvent> {
Callback::from(move |ev: InputEvent| {
let target = if let Some(target) = ev.target_dyn_into::<HtmlInputElement>() {
target
} else {
log::error!("cannot find target for input event");
return;
};
let value = target.value();
if let Ok(z) = value.trim().parse::<NonZeroU8>() {
num_value.set(z.to_string());
} else {
target.set_value(num_value.as_str());
}
})
}
pub fn input_element_string_oninput(value: UseStateSetter<String>) -> Callback<InputEvent> {
Callback::from(move |ev: InputEvent| {
if let Some(target) = ev.target_dyn_into::<HtmlInputElement>() {
value.set(target.value());
}
})
}

View File

@ -1,73 +0,0 @@
use core::num::NonZeroU8;
use web_sys::{HtmlInputElement, HtmlSelectElement, wasm_bindgen::JsCast};
use werewolves_proto::message::PublicIdentity;
use yew::prelude::*;
#[derive(Debug, PartialEq, Properties)]
pub struct InputProps {
#[prop_or_default]
pub initial_value: PublicIdentity,
pub callback: Callback<PublicIdentity, ()>,
}
#[function_component]
pub fn InputName(props: &InputProps) -> Html {
let callback = props.callback.clone();
let on_click = Callback::from(move |_| {
let name = gloo::utils::document()
.query_selector(".identity-input #name")
.expect("cannot find name input")
.and_then(|e| e.dyn_into::<HtmlInputElement>().ok())
.expect("name input element not HtmlInputElement")
.value()
.trim()
.to_string();
let pronouns = match gloo::utils::document()
.query_selector(".identity-input #pronouns")
.expect("cannot find pronouns input")
.and_then(|e| e.dyn_into::<HtmlInputElement>().ok())
.expect("pronouns input element not HtmlInputElement")
.value()
.trim()
{
"" => None,
p => Some(p.to_string()),
};
let number = gloo::utils::document()
.query_selector(".identity-input #number")
.expect("cannot find number input")
.and_then(|e| e.dyn_into::<HtmlSelectElement>().ok())
.expect("number input element not HtmlSelectElement")
.value()
.trim()
.parse::<NonZeroU8>()
.expect("parse number");
if name.is_empty() {
return;
}
callback.emit(PublicIdentity {
name,
pronouns,
number,
});
});
html! {
<div class="identity-input">
<label for="name">{"Name"}</label>
<input name="name" id="name" type="text" value={props.initial_value.name.clone()}/>
<label for="pronouns">{"Pronouns"}</label>
<input name="pronouns" id="pronouns" type="text"
value={props.initial_value.pronouns.clone().unwrap_or_default()}/>
<label for="number">{"Number"}</label>
<select name="number" id="number">
{
(1..=0xFFu8).into_iter().map(|i| html!{
<option value={i.to_string()}>{i}</option>
}).collect::<Html>()
}
</select>
<button onclick={on_click}>{"Submit"}</button>
</div>
}
}

View File

@ -1,3 +1,6 @@
use core::num::NonZeroU8;
use std::rc::Rc;
use werewolves_proto::{message::PlayerState, player::PlayerId};
use yew::prelude::*;
@ -5,23 +8,21 @@ use crate::components::{LobbyPlayer, LobbyPlayerAction};
#[derive(Debug, PartialEq, Properties)]
pub struct LobbyProps {
pub players: Box<[PlayerState]>,
pub players: Rc<[PlayerState]>,
#[prop_or_default]
pub on_action: Option<Callback<(PlayerId, LobbyPlayerAction)>>,
}
#[function_component]
pub fn Lobby(LobbyProps { players, on_action }: &LobbyProps) -> Html {
let mut players = players.clone();
players.sort_by_key(|f| f.identification.public.number.get());
html! {
<div class="column-list">
<p style="text-align: center;">{format!("Players in lobby: {}", players.len())}</p>
<div class="row-list small baseline player-list gap">
<div class="player-list">
{
players
.into_iter()
.map(|p| html! {<LobbyPlayer on_action={on_action} player={p} />})
.map(|p| html! {<LobbyPlayer on_action={on_action} player={p.clone()} />})
.collect::<Html>()
}
</div>

View File

@ -1,8 +1,10 @@
use core::num::NonZeroU8;
use web_sys::{HtmlDivElement, HtmlElement};
use werewolves_proto::{message::PlayerState, player::PlayerId};
use yew::prelude::*;
use crate::components::{Button, Identity};
use crate::components::{Button, ClickableField, ClickableNumberEdit, Identity};
#[derive(Debug, PartialEq, Properties)]
pub struct LobbyPlayerProps {
@ -14,6 +16,7 @@ pub struct LobbyPlayerProps {
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LobbyPlayerAction {
Kick,
SetNumber(NonZeroU8),
}
#[function_component]
@ -26,25 +29,54 @@ pub fn LobbyPlayer(LobbyPlayerProps { player, on_action }: &LobbyPlayerProps) ->
let pid = player.identification.player_id.clone();
let action = |action: LobbyPlayerAction| {
let pid = pid.clone();
if let Some(on_action) = on_action.as_ref() {
let on_action = on_action.clone();
Callback::from(move |_| on_action.emit((pid.clone(), action)))
} else {
Callback::noop()
}
on_action
.as_ref()
.cloned()
.map(|on_action| Callback::from(move |_| on_action.emit((pid.clone(), action))))
.unwrap_or_default()
};
let submenu = on_action.is_some().then(|| {
let number = use_state(String::new);
let open = use_state(|| false);
let number_open = use_state(|| false);
let submenu_open = open.clone();
let submenu = on_action.clone().map(|on_action| {
let pid = player.identification.player_id.clone();
let number_submit = number.clone();
let open = submenu_open.clone();
let on_number_submit = Callback::from(move |_| {
let number = match number_submit.trim().parse::<NonZeroU8>() {
Ok(num) => num,
Err(_) => return,
};
on_action.emit((pid.clone(), LobbyPlayerAction::SetNumber(number)));
open.set(false);
});
html! {
<nav class="submenu">
<Button on_click={(action)(LobbyPlayerAction::Kick)}>{"Kick"}</Button>
</nav>
<>
<Button on_click={(action)(LobbyPlayerAction::Kick)}>{"kick"}</Button>
<ClickableNumberEdit
state={number_open}
value={number}
field_name="number"
on_submit={on_number_submit}
>
<div class="number">{"set number"}</div>
</ClickableNumberEdit>
</>
}
});
html! {
<div class={classes!("player", class, "column-list", "ident")}>
<Identity ident={player.identification.public.clone()}/>
{submenu}
</div>
// <div class={classes!("player", class, "column-list")}>
<ClickableField
state={open}
options={submenu}
class={classes!("player", class, "column-list")}
with_backdrop_exit=true
>
<Identity ident={player.identification.public.clone()}/>
</ClickableField>
// {submenu}
// </div>
}
}

View File

@ -1,21 +1,21 @@
use std::sync::Arc;
use werewolves_proto::message::Target;
use werewolves_proto::message::CharacterIdentity;
use yew::prelude::*;
use crate::components::{Button, action::CustomTargetCard};
#[derive(Debug, PartialEq, Properties)]
pub struct RoleRevealProps {
pub ackd: Box<[Target]>,
pub waiting: Box<[Target]>,
pub on_force_ready: Option<Callback<Target>>,
pub ackd: Box<[CharacterIdentity]>,
pub waiting: Box<[CharacterIdentity]>,
pub on_force_ready: Option<Callback<CharacterIdentity>>,
}
pub struct RoleReveal {}
impl Component for RoleReveal {
type Message = Target;
type Message = CharacterIdentity;
type Properties = RoleRevealProps;
@ -34,7 +34,7 @@ impl Component for RoleReveal {
.map(|t| (t, true))
.chain(waiting.iter().map(|t| (t, false)))
.collect::<Box<[_]>>();
chars.sort_by(|(l, _), (r, _)| l.public.number.cmp(&r.public.number));
chars.sort_by(|(l, _), (r, _)| l.number.cmp(&r.number));
let on_force_ready = on_force_ready.clone();
let cards = chars
@ -77,9 +77,9 @@ impl Component for RoleReveal {
#[derive(Debug, PartialEq, Properties)]
pub struct RoleRevealCardProps {
pub target: Target,
pub target: CharacterIdentity,
pub is_ready: bool,
pub on_force_ready: Option<Callback<Target>>,
pub on_force_ready: Option<Callback<CharacterIdentity>>,
}
#[function_component]
@ -106,14 +106,6 @@ pub fn RoleRevealCard(props: &RoleRevealCardProps) -> Html {
on_select={on_click}
hide_submenu=false
/>
// <p>{props.target.public.name.as_str()}</p>
// {
// if !props.is_ready {
// html! {<Button on_click={on_click}>{"force ready"}</Button>}
// } else {
// html!{}
// }
// }
</div>
}
}

View File

@ -1,6 +1,11 @@
use core::{num::NonZeroU8, ops::Not};
use std::{collections::HashMap, rc::Rc};
use convert_case::{Case, Casing};
use web_sys::HtmlInputElement;
use werewolves_proto::{error::GameError, game::GameSettings, role::RoleTitle};
use werewolves_proto::{
error::GameError, game::GameSettings, message::PlayerState, role::RoleTitle,
};
use yew::prelude::*;
const ALIGN_VILLAGE: &str = "village";
@ -9,7 +14,7 @@ const ALIGN_WOLVES: &str = "wolves";
#[derive(Debug, PartialEq, Properties)]
pub struct SettingsProps {
pub settings: GameSettings,
pub players_in_lobby: usize,
pub players_in_lobby: Rc<[PlayerState]>,
pub on_update: Callback<GameSettings>,
pub on_start: Callback<()>,
#[prop_or_default]
@ -37,15 +42,37 @@ enum AmountChange {
#[function_component]
pub fn Settings(props: &SettingsProps) -> Html {
let on_update = props.on_update.clone();
let (start_game_disabled, reason) = match props.settings.check() {
Ok(_) => {
if props.players_in_lobby < props.settings.min_players_needed() {
(true, String::from("too few players for role setup"))
} else {
(false, String::new())
}
}
Err(err) => (true, err.to_string()),
let disabled_reason = match props.settings.check() {
Ok(_) => (props.players_in_lobby.len() < props.settings.min_players_needed())
.then(|| String::from("too few players for role setup"))
.or_else(|| {
props
.players_in_lobby
.iter()
.any(|p| p.identification.public.number.is_none())
.then(|| String::from("not all players are assigned numbers"))
})
.or_else(|| {
let mut unique: HashMap<NonZeroU8, NonZeroU8> = HashMap::new();
for p in props.players_in_lobby.iter() {
let num = p.identification.public.number.unwrap();
if let Some(cnt) = unique.get_mut(&num) {
*cnt = NonZeroU8::new(cnt.get() + 1).unwrap();
} else {
unique.insert(num, NonZeroU8::new(1).unwrap());
}
}
let dupes = unique
.iter()
.filter_map(|(num, cnt)| (cnt.get() > 1).then_some(*num))
.map(|dupe| dupe.to_string())
.collect::<Box<[_]>>();
dupes
.is_empty()
.not()
.then(|| format!("duplicate numbers: {}", dupes.join(", ")))
}),
Err(err) => Some(err.to_string()),
};
let settings = props.settings.clone();
let on_error = props.on_error.clone();
@ -74,6 +101,7 @@ pub fn Settings(props: &SettingsProps) -> Html {
.filter(|r| !matches!(r, RoleTitle::Scapegoat | RoleTitle::Villager))
.map(|r| html! {<RoleCard role={r} amount={get_role_count(&props.settings, r)} on_changed={on_changed.clone()}/>})
.collect::<Html>();
let disabled = disabled_reason.is_some();
html! {
<div class="settings">
<h2>{format!("Min players for settings: {}", props.settings.min_players_needed())}</h2>
@ -81,7 +109,7 @@ pub fn Settings(props: &SettingsProps) -> Html {
// <BoolRoleCard role={RoleTitle::Scapegoat} enabled={props.settings.scapegoat} on_changed={on_bool_changed}/>
{roles}
</div>
<button reason={reason} disabled={start_game_disabled} class="start-game" onclick={on_start_game}>{"Start Game"}</button>
<button reason={disabled_reason} disabled={disabled} class="start-game" onclick={on_start_game}>{"Start Game"}</button>
</div>
}
}

View File

@ -3,7 +3,12 @@ mod clients;
mod storage;
mod components {
werewolves_macros::include_path!("werewolves/src/components");
pub mod host;
pub mod client {
werewolves_macros::include_path!("werewolves/src/components/client");
}
pub mod host {
werewolves_macros::include_path!("werewolves/src/components/host");
}
pub mod action {
werewolves_macros::include_path!("werewolves/src/components/action");
}
@ -12,7 +17,6 @@ mod pages {
werewolves_macros::include_path!("werewolves/src/pages");
}
mod callback;
use core::num::NonZeroU8;
use pages::{ErrorComponent, WerewolfError};
use web_sys::Url;
@ -48,44 +52,24 @@ fn main() {
host.send_message(HostEvent::SetBigScreenState(true));
}
} else if path.starts_with("/many-client") {
let mut number = 1..=0xFFu8;
for (player_id, name, dupe) in [
let clients = document.query_selector("clients").unwrap().unwrap();
for (player_id, name, dupe) in [(
PlayerId::from_u128(1),
"player 1".to_string(),
document.query_selector("app").unwrap().unwrap(),
)]
.into_iter()
.chain((2..17).map(|num| {
(
PlayerId::from_u128(1),
"player 1",
document.query_selector("app").unwrap().unwrap(),
),
(
PlayerId::from_u128(2),
"player 2",
document.query_selector("dupe1").unwrap().unwrap(),
),
(
PlayerId::from_u128(3),
"player 3",
document.query_selector("dupe2").unwrap().unwrap(),
),
(
PlayerId::from_u128(4),
"player 4",
document.query_selector("dupe3").unwrap().unwrap(),
),
(
PlayerId::from_u128(5),
"player 5",
document.query_selector("dupe4").unwrap().unwrap(),
),
(
PlayerId::from_u128(6),
"player 6",
document.query_selector("dupe5").unwrap().unwrap(),
),
(
PlayerId::from_u128(7),
"player 7",
document.query_selector("dupe6").unwrap().unwrap(),
),
] {
PlayerId::from_u128(num as u128),
format!("player {num}"),
document.create_element("autoclient").unwrap(),
)
})) {
if dupe.tag_name() == "AUTOCLIENT" {
clients.append_child(&dupe).unwrap();
}
let client =
yew::Renderer::<Client>::with_root_and_props(dupe, ClientProps { auto_join: true })
.render();
@ -94,7 +78,7 @@ fn main() {
public: PublicIdentity {
name: name.to_string(),
pronouns: Some(String::from("he/him")),
number: NonZeroU8::new(number.next().unwrap()).unwrap(),
number: None,
},
}));
client.send_message(Message::SetErrorCallback(error_callback.clone()));

View File

@ -1,43 +1,29 @@
use gloo::storage::{LocalStorage, Storage, errors::StorageError};
use serde::{Deserialize, Serialize};
use werewolves_proto::{message::PublicIdentity, player::PlayerId};
pub enum StorageKey {
PlayerId,
PublicIdentity,
}
type Result<T> = core::result::Result<T, StorageError>;
impl StorageKey {
const fn key(&self) -> &'static str {
match self {
StorageKey::PlayerId => "player_id",
StorageKey::PublicIdentity => "player_public",
}
}
pub fn get<T>(&self) -> Result<T, StorageError>
where
T: for<'de> Deserialize<'de>,
{
LocalStorage::get(self.key())
pub trait StorageKey: for<'a> Deserialize<'a> + Serialize {
const KEY: &str;
fn load_from_storage() -> Result<Self> {
LocalStorage::get(Self::KEY)
}
pub fn set<T>(&self, value: T) -> Result<(), StorageError>
where
T: Serialize,
{
LocalStorage::set(self.key(), value)
fn save_to_storage(&self) -> Result<()> {
LocalStorage::set(Self::KEY, self)
}
pub fn get_or_set<T>(&self, value_fn: impl FnOnce() -> T) -> Result<T, StorageError>
where
T: Serialize + for<'de> Deserialize<'de>,
{
match LocalStorage::get(self.key()) {
Ok(v) => Ok(v),
Err(StorageError::KeyNotFound(_)) => {
LocalStorage::set(self.key(), (value_fn)())?;
self.get()
}
Err(err) => Err(err),
}
fn delete() {
LocalStorage::delete(Self::KEY);
}
}
impl StorageKey for PlayerId {
const KEY: &str = "ww_player_id";
}
impl StorageKey for PublicIdentity {
const KEY: &str = "ww_public_identity";
}