qr mode + fix mortician

This commit is contained in:
emilis 2025-10-07 01:47:59 +01:00
parent 1c6321322f
commit 98493d34be
No known key found for this signature in database
15 changed files with 264 additions and 37 deletions

7
Cargo.lock generated
View File

@ -464,6 +464,12 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "fast_qr"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f5ea9788036fedaf55f43a2db0ba01eedf47d26fd6852f01e5cf51952571d57"
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.2" version = "0.1.2"
@ -2447,6 +2453,7 @@ dependencies = [
"chrono", "chrono",
"ciborium", "ciborium",
"colored", "colored",
"fast_qr",
"futures", "futures",
"log", "log",
"mime-sniffer", "mime-sniffer",

View File

@ -400,7 +400,13 @@ impl Character {
}, },
Role::Mortician => ActionPrompt::Mortician { Role::Mortician => ActionPrompt::Mortician {
character_id: self.identity(), character_id: self.identity(),
dead_players: village.dead_targets(), dead_players: {
let dead = village.dead_targets();
if dead.is_empty() {
return Ok(Box::new([]));
}
dead
},
marked: None, marked: None,
}, },
Role::Beholder => ActionPrompt::Beholder { Role::Beholder => ActionPrompt::Beholder {

View File

@ -3,6 +3,7 @@ use core::num::NonZeroU8;
use pretty_assertions::{assert_eq, assert_ne, assert_str_eq}; use pretty_assertions::{assert_eq, assert_ne, assert_str_eq};
use crate::{ use crate::{
diedto::DiedTo,
game::{Game, GameSettings, SetupRole}, game::{Game, GameSettings, SetupRole},
game_test::{ActionPromptTitleExt, ActionResultExt, GameExt, SettingsExt, gen_players}, game_test::{ActionPromptTitleExt, ActionResultExt, GameExt, SettingsExt, gen_players},
message::night::{ActionPrompt, ActionPromptTitle}, message::night::{ActionPrompt, ActionPromptTitle},
@ -67,3 +68,46 @@ fn recruits_decrement() {
game.next_expect_day(); game.next_expect_day();
} }
#[test]
fn dies_recruiting_wolf() {
let players = gen_players(1..10);
let mason_leader_player_id = players[0].player_id;
let wolf_player_id = players[1].player_id;
let mut settings = GameSettings::empty();
settings.add_and_assign(
SetupRole::MasonLeader {
recruits_available: NonZeroU8::new(1).unwrap(),
},
mason_leader_player_id,
);
settings.add_and_assign(SetupRole::Werewolf, wolf_player_id);
settings.fill_remaining_slots_with_villagers(9);
let mut game = Game::new(&players, settings).unwrap();
game.r#continue().r#continue();
assert_eq!(game.next().title(), ActionPromptTitle::WolvesIntro);
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(game.execute().title(), ActionPromptTitle::WolfPackKill);
game.mark(game.living_villager().character_id());
game.r#continue().sleep();
assert_eq!(game.next().title(), ActionPromptTitle::MasonLeaderRecruit);
game.mark(game.character_by_player_id(wolf_player_id).character_id());
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(
game.character_by_player_id(mason_leader_player_id)
.died_to()
.cloned(),
Some(DiedTo::MasonLeaderRecruitFail {
tried_recruiting: game.character_by_player_id(wolf_player_id).character_id(),
night: 1,
})
);
}
// todo: masons wake even if leader dead

View File

@ -4,6 +4,7 @@ mod diseased;
mod elder; mod elder;
mod empath; mod empath;
mod mason; mod mason;
mod mortician;
mod pyremaster; mod pyremaster;
mod scapegoat; mod scapegoat;
mod shapeshifter; mod shapeshifter;

View File

@ -0,0 +1,32 @@
use core::num::NonZeroU8;
#[allow(unused)]
use pretty_assertions::{assert_eq, assert_ne, assert_str_eq};
use crate::{
diedto::DiedTo,
game::{Game, GameSettings, SetupRole},
game_test::{ActionPromptTitleExt, ActionResultExt, GameExt, SettingsExt, gen_players},
message::night::{ActionPrompt, ActionPromptTitle},
};
#[test]
fn no_dead_doesnt_prompt() {
let players = gen_players(1..10);
let mortician_player_id = players[0].player_id;
let wolf_player_id = players[1].player_id;
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::Mortician, mortician_player_id);
settings.add_and_assign(SetupRole::Werewolf, wolf_player_id);
settings.fill_remaining_slots_with_villagers(9);
let mut game = Game::new(&players, settings).unwrap();
game.r#continue().r#continue();
assert_eq!(game.next().title(), ActionPromptTitle::WolvesIntro);
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(game.execute().title(), ActionPromptTitle::WolfPackKill);
game.mark(game.living_villager().character_id());
game.r#continue().sleep();
game.next_expect_day();
}

View File

@ -58,6 +58,7 @@ pub enum HostLobbyMessage {
SetPlayerNumber(PlayerId, NonZeroU8), SetPlayerNumber(PlayerId, NonZeroU8),
GetGameSettings, GetGameSettings,
SetGameSettings(GameSettings), SetGameSettings(GameSettings),
SetQrMode(bool),
Start, Start,
} }
@ -72,6 +73,7 @@ pub enum ServerToHostMessage {
ActionPrompt(ActionPrompt), ActionPrompt(ActionPrompt),
ActionResult(Option<CharacterIdentity>, ActionResult), ActionResult(Option<CharacterIdentity>, ActionResult),
Lobby(Box<[PlayerState]>), Lobby(Box<[PlayerState]>),
QrMode(bool),
GameSettings(GameSettings), GameSettings(GameSettings),
Error(GameError), Error(GameError),
GameOver(GameOver), GameOver(GameOver),

View File

@ -23,6 +23,7 @@ serde = { version = "1.0", features = ["derive"] }
thiserror = { version = "2" } thiserror = { version = "2" }
ciborium = { version = "0.2", optional = true } ciborium = { version = "0.2", optional = true }
colored = { version = "3.0" } colored = { version = "3.0" }
fast_qr = { version = "0.13", features = ["svg"] }
[features] [features]
default = ["cbor"] default = ["cbor"]

View File

@ -24,6 +24,7 @@ pub struct Lobby {
settings: GameSettings, settings: GameSettings,
joined_players: JoinedPlayers, joined_players: JoinedPlayers,
comms: Option<LobbyComms>, comms: Option<LobbyComms>,
qr_mode: bool,
} }
impl Lobby { impl Lobby {
@ -33,6 +34,7 @@ impl Lobby {
comms: Some(comms), comms: Some(comms),
settings: GameSettings::default(), settings: GameSettings::default(),
players_in_lobby: LobbyPlayers(Vec::new()), players_in_lobby: LobbyPlayers(Vec::new()),
qr_mode: false,
} }
} }
@ -79,10 +81,10 @@ impl Lobby {
pub async fn send_lobby_info_to_host(&mut self) -> Result<(), GameError> { pub async fn send_lobby_info_to_host(&mut self) -> Result<(), GameError> {
let players = self.get_lobby_player_list().await; let players = self.get_lobby_player_list().await;
self.comms()? let qr_mode = self.qr_mode;
.host() let host = self.comms()?.host();
.send(ServerToHostMessage::Lobby(players)) host.send(ServerToHostMessage::Lobby(players))?;
.map_err(|err| GameError::GenericError(err.to_string())) host.send(ServerToHostMessage::QrMode(qr_mode))
} }
pub async fn next(&mut self) -> Option<GameRunner> { pub async fn next(&mut self) -> Option<GameRunner> {
@ -150,6 +152,10 @@ impl Lobby {
async fn next_inner(&mut self, msg: Message) -> Result<Option<GameRunner>, GameError> { async fn next_inner(&mut self, msg: Message) -> Result<Option<GameRunner>, GameError> {
match msg { match msg {
Message::Host(HostMessage::Lobby(HostLobbyMessage::SetQrMode(mode))) => {
self.qr_mode = mode;
self.send_lobby_info_to_host().await.log_debug();
}
Message::Host(HostMessage::InGame(_)) Message::Host(HostMessage::InGame(_))
| Message::Host(HostMessage::ForceRoleAckFor(_)) => { | Message::Host(HostMessage::ForceRoleAckFor(_)) => {
return Err(GameError::InvalidMessageForGameState); return Err(GameError::InvalidMessageForGameState);

View File

@ -9,7 +9,7 @@ mod saver;
use axum::{ use axum::{
Router, Router,
http::{Request, header}, http::{Request, StatusCode, header},
response::IntoResponse, response::IntoResponse,
routing::{any, get}, routing::{any, get},
}; };
@ -17,6 +17,7 @@ use axum_extra::headers;
use communication::lobby::LobbyComms; use communication::lobby::LobbyComms;
use connection::JoinedPlayers; use connection::JoinedPlayers;
use core::{fmt::Display, net::SocketAddr, str::FromStr}; use core::{fmt::Display, net::SocketAddr, str::FromStr};
use fast_qr::convert::{Builder, Shape, svg::SvgBuilder};
use runner::IdentifiedClientMessage; use runner::IdentifiedClientMessage;
use std::{env, io::Write, path::Path}; use std::{env, io::Write, path::Path};
use tokio::sync::{broadcast, mpsc}; use tokio::sync::{broadcast, mpsc};
@ -29,6 +30,7 @@ use crate::{
const DEFAULT_PORT: u16 = 8080; const DEFAULT_PORT: u16 = 8080;
const DEFAULT_HOST: &str = "127.0.0.1"; const DEFAULT_HOST: &str = "127.0.0.1";
const DEFAULT_SAVE_DIR: &str = "werewolves-saves/"; const DEFAULT_SAVE_DIR: &str = "werewolves-saves/";
const DEFAULT_QRCODE_URL: &str = "https://wolf.emilis.dev/";
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
@ -136,6 +138,7 @@ async fn main() {
let app = Router::new() let app = Router::new()
.route("/connect/client", any(client::handler)) .route("/connect/client", any(client::handler))
.route("/connect/host", any(host::handler)) .route("/connect/host", any(host::handler))
.route("/qrcode", get(handle_qr_code))
.with_state(state) .with_state(state)
.fallback(get(handle_http_static)); .fallback(get(handle_http_static));
let listener = tokio::net::TcpListener::bind(listen_addr).await.unwrap(); let listener = tokio::net::TcpListener::bind(listen_addr).await.unwrap();
@ -165,6 +168,33 @@ impl Clone for AppState {
} }
} }
async fn handle_qr_code() -> impl IntoResponse {
const QRCODE: &str = const {
match option_env!("QRCODE_URL") {
Some(qrcode) => qrcode,
None => DEFAULT_QRCODE_URL,
}
};
const EMPTY: &[u8] = &[];
let qr_str = match fast_qr::QRBuilder::new(QRCODE).build() {
Ok(qr) => SvgBuilder::default().shape(Shape::Square).to_str(&qr),
Err(err) => {
log::error!("generating qr code from [{QRCODE}]: {err}");
return (
StatusCode::INTERNAL_SERVER_ERROR,
[(header::CONTENT_TYPE, "application/octet-stream")],
EMPTY.to_vec(),
);
}
};
(
StatusCode::OK,
[(header::CONTENT_TYPE, "image/svg+xml")],
qr_str.as_bytes().to_vec(),
)
}
async fn handle_http_static(req: Request<axum::body::Body>) -> impl IntoResponse { async fn handle_http_static(req: Request<axum::body::Body>) -> impl IntoResponse {
use mime_sniffer::MimeTypeSniffer; use mime_sniffer::MimeTypeSniffer;
const INDEX_FILE: &[u8] = include_bytes!("../../werewolves/dist/index.html"); const INDEX_FILE: &[u8] = include_bytes!("../../werewolves/dist/index.html");

View File

@ -355,6 +355,17 @@ button.confirm {
font-size: 2rem; font-size: 2rem;
} }
.roles-in-setup {
border: 1px solid rgba(255, 255, 255, 0.6);
padding: 10px;
&>h3 {
margin: 0;
text-align: center;
color: rgba(255, 255, 255, 0.6);
}
}
.role-list { .role-list {
list-style: none; list-style: none;
@ -980,6 +991,7 @@ input {
text-align: center; text-align: center;
& button label { & button label {
color: white;
cursor: pointer; cursor: pointer;
} }
} }
@ -1058,10 +1070,11 @@ input {
gap: 10px; gap: 10px;
.assignment { .assignment {
color: white;
text-align: center; text-align: center;
padding-left: 10px; padding-left: 10px;
padding-right: 10px; padding-right: 10px;
border: 1px solid white; // border: 1px solid white;
&>* { &>* {
cursor: pointer; cursor: pointer;
@ -1169,3 +1182,28 @@ input {
} }
} }
} }
.qrcode {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
position: fixed;
top: 0;
left: 0;
margin: 5vw;
width: 90vw;
height: 90vh;
gap: 1cm;
img {
height: 100%;
width: 100%;
}
.details {
// height: 100%;
// width: 100%;
border: 1px solid $village_border;
background-color: color.change($village_color, $alpha: 0.3);
}
}

View File

@ -31,6 +31,7 @@ use crate::{
action::{ActionResultView, Prompt}, action::{ActionResultView, Prompt},
host::{DaytimePlayerList, Setup}, host::{DaytimePlayerList, Setup},
}, },
storage::StorageKey,
}; };
use crate::WerewolfError; use crate::WerewolfError;
@ -182,6 +183,7 @@ pub enum HostEvent {
PlayerList(Box<[PlayerState]>), PlayerList(Box<[PlayerState]>),
Settings(GameSettings), Settings(GameSettings),
Error(GameError), Error(GameError),
QrMode(bool),
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum HostState { pub enum HostState {
@ -209,6 +211,7 @@ pub enum HostState {
impl From<ServerToHostMessage> for HostEvent { impl From<ServerToHostMessage> for HostEvent {
fn from(msg: ServerToHostMessage) -> Self { fn from(msg: ServerToHostMessage) -> Self {
match msg { match msg {
ServerToHostMessage::QrMode(mode) => HostEvent::QrMode(mode),
ServerToHostMessage::Disconnect => HostEvent::SetState(HostState::Disconnected), ServerToHostMessage::Disconnect => HostEvent::SetState(HostState::Disconnected),
ServerToHostMessage::Daytime { ServerToHostMessage::Daytime {
characters, characters,
@ -244,6 +247,7 @@ pub struct Host {
state: HostState, state: HostState,
error_callback: Callback<Option<WerewolfError>>, error_callback: Callback<Option<WerewolfError>>,
big_screen: bool, big_screen: bool,
qr_mode: bool,
debug: bool, debug: bool,
} }
@ -269,6 +273,7 @@ impl Component for Host {
state: HostState::Disconnected, state: HostState::Disconnected,
debug: option_env!("DEBUG").is_some(), debug: option_env!("DEBUG").is_some(),
big_screen: false, big_screen: false,
qr_mode: false,
error_callback: Callback::from(|err| { error_callback: Callback::from(|err| {
if let Some(err) = err { if let Some(err) = err {
log::error!("{err}") log::error!("{err}")
@ -453,6 +458,10 @@ impl Component for Host {
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool { fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg { match msg {
HostEvent::QrMode(mode) => {
self.qr_mode = mode;
true
}
HostEvent::PlayerList(mut players) => { HostEvent::PlayerList(mut players) => {
const LAST: NonZeroU8 = NonZeroU8::new(0xFF).unwrap(); const LAST: NonZeroU8 = NonZeroU8::new(0xFF).unwrap();
players.sort_by(|l, r| { players.sort_by(|l, r| {
@ -582,18 +591,50 @@ impl Component for Host {
impl Host { impl Host {
fn lobby_big_screen_show_setup(&self, _: Rc<[PlayerState]>, settings: GameSettings) -> Html { fn lobby_big_screen_show_setup(&self, _: Rc<[PlayerState]>, settings: GameSettings) -> Html {
if !self.qr_mode {
return html! {
<Setup settings={settings}/>
};
}
let qrcode_url = if option_env!("LOCAL").is_some() {
String::from("http://192.168.1.162:8080/qrcode")
} else {
String::from("/qrcode")
};
html! { html! {
<Setup settings={settings}/> <div class="qrcode">
<img src={qrcode_url} alt="qr code to join"/>
<div class="details">
<h3>{"scan the qrcode to join"}</h3>
</div>
</div>
} }
} }
fn lobby_setup(&self, players: Rc<[PlayerState]>, settings: GameSettings) -> Html { fn lobby_setup(&self, players: Rc<[PlayerState]>, settings: GameSettings) -> Html {
let on_error = self.error_callback.clone(); let on_error = self.error_callback.clone();
let qr_mode_toggle = {
let on_click = crate::callback::send_message(
HostMessage::Lobby(HostLobbyMessage::SetQrMode(!self.qr_mode)),
self.send.clone(),
);
let text = if self.qr_mode {
"disable qr mode"
} else {
"enable qr mode"
};
html! {
<Button on_click={on_click}>{text}</Button>
}
};
let settings = self.big_screen.not().then(|| { let settings = self.big_screen.not().then(|| {
let send = self.send.clone(); let send = self.send.clone();
let on_changed = Callback::from(move |s| { let on_changed = Callback::from(move |s: GameSettings| {
let send = send.clone(); let send = send.clone();
if let Err(err) = s.save_to_storage() {
log::error!("saving settings to local storage: {err}");
}
yew::platform::spawn_local(async move { yew::platform::spawn_local(async move {
let mut send = send.clone(); let mut send = send.clone();
if let Err(err) = send if let Err(err) = send
@ -614,6 +655,7 @@ impl Host {
let on_start = Callback::from(move |_| { let on_start = Callback::from(move |_| {
let send = send.clone(); let send = send.clone();
let on_error = on_error.clone(); let on_error = on_error.clone();
yew::platform::spawn_local(async move { yew::platform::spawn_local(async move {
let mut send = send.clone(); let mut send = send.clone();
if let Err(err) = send.send(HostMessage::Lobby(HostLobbyMessage::Start)).await { if let Err(err) = send.send(HostMessage::Lobby(HostLobbyMessage::Start)).await {
@ -627,6 +669,7 @@ impl Host {
on_start={on_start} on_start={on_start}
on_update={on_changed} on_update={on_changed}
players_in_lobby={players.clone()} players_in_lobby={players.clone()}
qr_mode_button={qr_mode_toggle}
/> />
} }
}); });

View File

@ -115,7 +115,7 @@ pub fn SetupCategory(
let all_roles = roles_in_category let all_roles = roles_in_category
.into_iter() .into_iter()
.map(|r| (r, roles.iter().filter(|sr| sr.title() == r).count())) .map(|r| (r, roles.iter().filter(|sr| sr.title() == r).count()))
.filter(|(_, count)| !(matches!(mode, CategoryMode::ShowExactRoleCount) && *count == 0)) .filter(|(_, count)| !(matches!(category, Category::Wolves) && *count == 0))
.map(|(r, count)| { .map(|(r, count)| {
let as_role = r.into_role(); let as_role = r.into_role();
let wakes = as_role.wakes_night_zero().then_some("wakes"); let wakes = as_role.wakes_night_zero().then_some("wakes");

View File

@ -18,8 +18,7 @@ pub struct SettingsProps {
pub players_in_lobby: Rc<[PlayerState]>, pub players_in_lobby: Rc<[PlayerState]>,
pub on_update: Callback<GameSettings>, pub on_update: Callback<GameSettings>,
pub on_start: Callback<()>, pub on_start: Callback<()>,
#[prop_or_default] pub qr_mode_button: Html,
pub on_error: Option<Callback<GameError>>,
} }
#[function_component] #[function_component]
@ -29,7 +28,7 @@ pub fn Settings(
players_in_lobby, players_in_lobby,
on_update, on_update,
on_start, on_start,
on_error, qr_mode_button,
}: &SettingsProps, }: &SettingsProps,
) -> Html { ) -> Html {
let players = players_in_lobby let players = players_in_lobby
@ -83,18 +82,22 @@ pub fn Settings(
.collect::<Html>(); .collect::<Html>();
let add_roles_update = on_update.clone(); let add_roles_update = on_update.clone();
let add_roles_buttons = RoleTitle::ALL let sorted_role_tiles = {
.iter() let mut v = RoleTitle::ALL.to_vec();
v.sort_by_key(|v| Into::<SetupRole>::into(*v).category());
v
};
let add_roles_buttons = sorted_role_tiles
.into_iter()
.map(|r| { .map(|r| {
let update = add_roles_update.clone(); let update = add_roles_update.clone();
let settings = settings.clone(); let settings = settings.clone();
let role = *r;
let on_click = Callback::from(move |_| { let on_click = Callback::from(move |_| {
let mut settings = (*settings).clone(); let mut settings = (*settings).clone();
settings.new_slot(role); settings.new_slot(r);
update.emit(settings); update.emit(settings);
}); });
let class = Into::<SetupRole>::into(*r).category().class(); let class = Into::<SetupRole>::into(r).category().class();
let name = r.to_string().to_case(Case::Title); let name = r.to_string().to_case(Case::Title);
html! { html! {
<Button on_click={on_click} classes={classes!(class, "add-role")}> <Button on_click={on_click} classes={classes!(class, "add-role")}>
@ -159,29 +162,31 @@ pub fn Settings(
.iter() .iter()
.any(|s| s.assign_to.is_some()) .any(|s| s.assign_to.is_some())
.then(|| { .then(|| {
let assignments = settings let assignments =
.slots() settings
.iter() .slots()
.cloned() .iter()
.filter_map(|s| { .cloned()
s.assign_to .filter_map(|s| {
s.assign_to
.as_ref() .as_ref()
.map(|a| { .map(|a| {
players players
.iter() .iter()
.find(|p| p.player_id == *a) .find(|p| p.player_id == *a)
.map(|assign| { .map(|assign| {
html! { let class = s.role.category().class();
(html! {
<Identity ident={assign.public.clone()}/> <Identity ident={assign.public.clone()}/>
} }, Some(class))
}) })
.unwrap_or_else(|| { .unwrap_or_else(|| {
html! { (html! {
<span>{"[left the lobby]"}</span> <span>{"[left the lobby]"}</span>
} }, None)
}) })
}) })
.map(|who| { .map(|(who, class)| {
let assignments_update = on_update.clone(); let assignments_update = on_update.clone();
let assignments_settings = settings.clone(); let assignments_settings = settings.clone();
let click_slot = s.clone(); let click_slot = s.clone();
@ -193,14 +198,14 @@ pub fn Settings(
assignments_update.emit(settings); assignments_update.emit(settings);
}); });
html! { html! {
<Button classes={classes!("assignment")} on_click={on_click}> <Button classes={classes!("assignment", class)} on_click={on_click}>
<label>{s.role.to_string().to_case(Case::Title)}</label> <label>{s.role.to_string().to_case(Case::Title)}</label>
{who} {who}
</Button> </Button>
} }
}) })
}) })
.collect::<Html>(); .collect::<Html>();
html! { html! {
<> <>
@ -246,20 +251,24 @@ pub fn Settings(
html! { html! {
<div class="settings"> <div class="settings">
<div class="top-settings"> <div class="top-settings">
{qr_mode_button.clone()}
{fill_empty_with_villagers} {fill_empty_with_villagers}
{clear_setup} {clear_setup}
{clear_all_assignments} {clear_all_assignments}
{clear_bad_assigned} {clear_bad_assigned}
</div> </div>
{assignments}
<p>{format!("min roles for setup: {}", settings.min_players_needed())}</p> <p>{format!("min roles for setup: {}", settings.min_players_needed())}</p>
<p>{format!("current role count: {}", settings.slots().len())}</p> <p>{format!("current role count: {}", settings.slots().len())}</p>
<div class="roles-add-list"> <div class="roles-add-list">
{add_roles_buttons} {add_roles_buttons}
</div> </div>
<div class="role-list"> <div class="roles-in-setup">
{roles} <h3>{"roles in the game"}</h3>
<div class="role-list">
{roles}
</div>
</div> </div>
{assignments}
<Button <Button
disabled_reason={disabled_reason} disabled_reason={disabled_reason}
classes={classes!("start-game")} classes={classes!("start-game")}

View File

@ -55,7 +55,11 @@ fn main() {
} }
} else if path.starts_with("/many-client") { } else if path.starts_with("/many-client") {
let clients = document.query_selector("clients").unwrap().unwrap(); let clients = document.query_selector("clients").unwrap().unwrap();
for (player_id, name, num, dupe) in (1..=7).map(|num| { let client_count = option_env!("CLIENTS")
.and_then(|c| c.parse::<NonZeroU8>().ok())
.unwrap_or(NonZeroU8::new(7).unwrap())
.get();
for (player_id, name, num, dupe) in (1..=client_count).map(|num| {
( (
PlayerId::from_u128(num as u128), PlayerId::from_u128(num as u128),
format!("player {num}"), format!("player {num}"),

View File

@ -1,6 +1,6 @@
use gloo::storage::{LocalStorage, Storage, errors::StorageError}; use gloo::storage::{LocalStorage, Storage, errors::StorageError};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use werewolves_proto::{message::PublicIdentity, player::PlayerId}; use werewolves_proto::{game::GameSettings, message::PublicIdentity, player::PlayerId};
type Result<T> = core::result::Result<T, StorageError>; type Result<T> = core::result::Result<T, StorageError>;
@ -27,3 +27,7 @@ impl StorageKey for PlayerId {
impl StorageKey for PublicIdentity { impl StorageKey for PublicIdentity {
const KEY: &str = "ww_public_identity"; const KEY: &str = "ww_public_identity";
} }
impl StorageKey for GameSettings {
const KEY: &str = "game_settings";
}