host: change seat number for players

This commit is contained in:
emilis 2026-02-18 23:06:46 +00:00
parent 852973eddf
commit 14e8f369ea
No known key found for this signature in database
14 changed files with 280 additions and 122 deletions

7
Cargo.lock generated
View File

@ -3064,6 +3064,12 @@ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
[[package]]
name = "sorted-vec"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f58d7b0190c7f12df7e8be6b79767a0836059159811b869d5ab55721fe14d0"
[[package]] [[package]]
name = "spin" name = "spin"
version = "0.9.8" version = "0.9.8"
@ -4053,6 +4059,7 @@ dependencies = [
"rand 0.9.2", "rand 0.9.2",
"reactive_stores", "reactive_stores",
"serde", "serde",
"sorted-vec",
"sqlx", "sqlx",
"tokio", "tokio",
"tower-http", "tower-http",

View File

@ -124,6 +124,7 @@ ciborium = { version = "0.2" }
pretty_assertions = { version = "1.4" } pretty_assertions = { version = "1.4" }
colored = { version = "3.1" } colored = { version = "3.1" }
pretty_env_logger = { version = "0.5" } pretty_env_logger = { version = "0.5" }
sorted-vec = { version = "0.8" }
[profile.dev] [profile.dev]
opt-level = 0 opt-level = 0

View File

@ -241,6 +241,10 @@ dialog::backdrop {
&.full { &.full {
border-bottom: 1px solid white; border-bottom: 1px solid white;
} }
.headline {
user-select: none;
}
} }
} }
@ -281,26 +285,7 @@ dialog::backdrop {
max-width: 70%; max-width: 70%;
} }
form {
font-size: 1em;
display: flex;
flex-direction: column;
gap: 1ch;
align-items: center;
.form-fields {
display: flex;
flex-direction: column;
align-items: center;
// width: 100%;
gap: 1ch;
}
label {
user-select: none;
}
}
} }
.user-settings-list { .user-settings-list {
@ -659,13 +644,24 @@ dialog .tab-content {
} }
} }
.host-seat-changer {
width: min-content;
input[type=submit] {
width: 100%;
}
}
.number-update:not([hidden]) { .number-update:not([hidden]) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-wrap: nowrap; flex-wrap: nowrap;
gap: 0.25ch; gap: 0.25ch;
margin-top: 3ch; margin-top: 3ch;
.bigger {
font-size: 1.5em; font-size: 1.5em;
}
input::-webkit-outer-spin-button, input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button { input::-webkit-inner-spin-button {
@ -756,3 +752,35 @@ dialog .tab-content {
border: 1px solid red; border: 1px solid red;
background-color: black; background-color: black;
} }
.player-options {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
max-width: 80vw;
gap: 0.25ch;
min-width: 45vw;
&>* {
width: 100%;
}
}
form {
font-size: 1em;
display: flex;
flex-direction: column;
gap: 0.25ch;
align-items: center;
.form-fields {
display: flex;
flex-direction: column;
align-items: center;
gap: 1ch;
}
label {
user-select: none;
}
}

View File

@ -13,6 +13,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
use core::hash::Hash;
use std::collections::HashMap; use std::collections::HashMap;
use chrono::{DateTime, TimeDelta, Utc}; use chrono::{DateTime, TimeDelta, Utc};
@ -204,6 +205,24 @@ pub struct DeadChatMessage {
pub message: DeadChatContent, pub message: DeadChatContent,
} }
impl Hash for DeadChatMessage {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}
impl Ord for DeadChatMessage {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.id.cmp(&other.id)
}
}
impl PartialOrd for DeadChatMessage {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DeadChatContent { pub enum DeadChatContent {
PlayerMessage { PlayerMessage {

View File

@ -40,6 +40,7 @@ werewolves-macros.workspace = true
werewolves-proto.workspace = true werewolves-proto.workspace = true
codee.workspace = true codee.workspace = true
convert_case.workspace = true convert_case.workspace = true
sorted-vec.workspace = true
[features] [features]
hydrate = [ hydrate = [

View File

@ -0,0 +1,75 @@
use core::num::NonZeroU8;
use leptos::{
ev::{Event, MouseEvent, SubmitEvent, Targeted},
html::Form,
prelude::*,
tachys::html::node_ref::NodeRefContainer,
web_sys::HtmlInputElement,
};
use crate::ConsoleLogError;
#[component]
pub fn ChangePlayerNumber(
submitted_number: WriteSignal<Option<NonZeroU8>>,
error: WriteSignal<Option<String>>,
) -> impl IntoView {
let number: RwSignal<Option<NonZeroU8>> = RwSignal::new(None);
let update = move |e: Targeted<Event, HtmlInputElement>| {
e.prevent_default();
let value = e.target().value();
if value.trim().is_empty() {
number.set(None);
return;
}
let current = number.get_untracked();
let default = current.map(|c| c.get().to_string()).unwrap_or_default();
let value_u8 = match e.target().value().trim().parse::<u8>() {
Ok(v) => v,
Err(err) => {
log::error!("{err}");
e.target().set_value(default.as_str());
return;
}
};
if let Some(nz) = NonZeroU8::new(value_u8) {
number.set(Some(nz));
} else {
e.target().set_value(default.as_str());
}
};
let submit = move |ev: SubmitEvent| {
ev.prevent_default();
log::warn!("called submit with number: {:?}", number.get());
let Some(num) = number.get() else {
error.set(Some("please set a number".into()));
return;
};
submitted_number.set(Some(num));
};
let submit_click = move |ev: MouseEvent| {
ev.prevent_default();
log::warn!("called submit with number: {:?}", number.get());
let Some(num) = number.get() else {
error.set(Some("please set a number".into()));
return;
};
submitted_number.set(Some(num));
};
move || {
view! {
<form class="number-update" on:submit=submit>
<label for="player-number">"change seat number"</label>
<input
id="player-number"
type="number"
autocomplete="off"
on:input:target=update
value=move || { number.get().map(|n| n.get().to_string()).unwrap_or_default() }
/>
<input value="submit" type="submit" on:click=submit_click/>
</form>
}
}
}

View File

@ -13,7 +13,7 @@ pub enum DialogMode {
#[component] #[component]
pub fn DialogModal( pub fn DialogModal(
text: String, button_content: impl IntoView + Clone,
#[prop(optional)] open: Option<RwSignal<bool>>, #[prop(optional)] open: Option<RwSignal<bool>>,
#[prop(optional)] button_class: String, #[prop(optional)] button_class: String,
#[prop(default = Box::new(|| ().into_any()))] mut children: ChildrenFnMut, #[prop(default = Box::new(|| ().into_any()))] mut children: ChildrenFnMut,
@ -23,15 +23,13 @@ pub fn DialogModal(
// use generated id if not supplied // use generated id if not supplied
let open = open.unwrap_or_else(|| RwSignal::new(false)); let open = open.unwrap_or_else(|| RwSignal::new(false));
let close_cb = { let close_cb = move |ev: MouseEvent| {
move |ev: MouseEvent| {
ev.prevent_default(); ev.prevent_default();
open.set(false); open.set(false);
}
}; };
let close = { let close = {
view! { view! {
<button on:click=close_cb.clone() class="close"> <button on:click=close_cb class="close">
"close" "close"
</button> </button>
} }
@ -48,9 +46,7 @@ pub fn DialogModal(
.into_any(), .into_any(),
}; };
let dialog_element: NodeRef<leptos::html::Dialog> = NodeRef::new(); let dialog_element: NodeRef<leptos::html::Dialog> = NodeRef::new();
let on_backdrop_click = { let on_backdrop_click = move |ev: MouseEvent| {
let close_cb = close_cb.clone();
move |ev: MouseEvent| {
ev.prevent_default(); ev.prevent_default();
if !close_backdrop { if !close_backdrop {
return; return;
@ -77,7 +73,6 @@ pub fn DialogModal(
close_cb(ev); close_cb(ev);
} }
} }
}
}; };
let is_open = move || open.get(); let is_open = move || open.get();
let modal = view! { let modal = view! {
@ -97,11 +92,13 @@ pub fn DialogModal(
ev.prevent_default(); ev.prevent_default();
open.set(true); open.set(true);
}; };
let button = text.is_empty().not().then_some(view! { let button = {
<button class=button_class on:click=on_click> view! {
{text.clone()} <button class=button_class.clone() on:click=on_click>
{button_content.clone().into_view()}
</button> </button>
}); }
};
view! { <div class="dialog-modal">{button} {modal}</div> } view! { <div class="dialog-modal">{button} {modal}</div> }
} }

View File

@ -88,6 +88,7 @@ pub fn HostGamePage(
HostPage::None => ().into_any(), HostPage::None => ().into_any(),
HostPage::Settings => view! { HostPage::Settings => view! {
<Settings <Settings
error=error
open_categories=open_categories open_categories=open_categories
settings=settings.read_only() settings=settings.read_only()
players=players.read_only() players=players.read_only()
@ -122,7 +123,7 @@ fn CancelGame(
<div hidden=move || derive_hidden.get()> <div hidden=move || derive_hidden.get()>
<DialogModal <DialogModal
button_class="cancel-game-button".into() button_class="cancel-game-button".into()
text="cancel game".into() button_content="cancel game"
open=open open=open
> >
<h1>"this will cancel the game. are you sure?"</h1> <h1>"this will cancel the game. are you sure?"</h1>

View File

@ -1,19 +1,49 @@
use leptos::prelude::*; use core::num::NonZeroU8;
use werewolves_proto::message::{Identification, PlayerState};
use crate::app::components::{Equals, IdentificationInline, Sample, TutorialBox}; use leptos::prelude::*;
use werewolves_proto::message::{
Identification, PlayerState,
host::{HostLobbyMessage, HostMessage},
};
use crate::app::{
class::AsClasses,
components::{
DialogModal, DialogMode, Equals, IdentificationInline, Sample, TutorialBox,
input::ChangePlayerNumber,
},
};
#[component] #[component]
pub fn HostPlayerList(players: ReadSignal<Box<[PlayerState]>>) -> impl IntoView { pub fn HostPlayerList(
players: ReadSignal<Box<[PlayerState]>>,
reply: WriteSignal<Option<HostMessage>>,
error: WriteSignal<Option<String>>,
) -> impl IntoView {
let players = move || { let players = move || {
let mut players = players.get();
players.sort_by_key(|p| p.identification.public.number.unwrap_or(NonZeroU8::MAX));
players players
.read() .into_iter()
.iter()
.map(|p| { .map(|p| {
let ident = p.identification.clone();
let button_content = move || {
view! { <IdentificationInline ident=ident.clone() /> }
};
let connected = if p.connected {
"connected"
} else {
Default::default()
};
view! { view! {
<button class="player" class:connected=p.connected> <DialogModal
<IdentificationInline ident=p.identification.clone() /> button_class=["player", connected].as_classes().to_string()
</button> button_content=button_content
close_backdrop=true
mode=DialogMode::Box
>
<HostPlayerDialogBody error=error player=p.clone() reply=reply />
</DialogModal>
} }
}) })
.collect_view() .collect_view()
@ -53,3 +83,46 @@ pub fn HostPlayerList(players: ReadSignal<Box<[PlayerState]>>) -> impl IntoView
</TutorialBox> </TutorialBox>
} }
} }
#[component]
fn HostPlayerDialogBody(
player: PlayerState,
reply: WriteSignal<Option<HostMessage>>,
error: WriteSignal<Option<String>>,
) -> impl IntoView {
let pid = player.identification.player_id;
let kick = move |_| reply.set(Some(HostMessage::Lobby(HostLobbyMessage::Kick(pid))));
let set_number = RwSignal::new(None);
let num_change_open = RwSignal::new(false);
let num_change_button_text = move || {
if num_change_open.get() {
"close seat changer"
} else {
"change seat number"
}
};
Effect::new(move || {
if let Some(num) = set_number.get() {
log::info!("gunna set seat number to {num}");
reply.set(Some(HostMessage::Lobby(HostLobbyMessage::SetPlayerNumber(
pid, num,
))));
}
});
let toggle_change_seat_open = move |_| num_change_open.set(!num_change_open.get());
move || {
view! {
<div class="headline">
<IdentificationInline ident=player.identification.clone() />
</div>
<div class="player-options">
<button on:click=kick>"kick"</button>
<button on:click=toggle_change_seat_open>{num_change_button_text}</button>
<div class="host-seat-changer" hidden=move || !num_change_open.get()>
<ChangePlayerNumber submitted_number=set_number.write_only() error=error />
</div>
</div>
}
}
}

View File

@ -27,6 +27,7 @@ pub fn Settings(
dialog_open: RwSignal<HashMap<SlotId, bool>>, dialog_open: RwSignal<HashMap<SlotId, bool>>,
open_categories: RwSignal<HashMap<Category, bool>>, open_categories: RwSignal<HashMap<Category, bool>>,
reply: WriteSignal<Option<HostMessage>>, reply: WriteSignal<Option<HostMessage>>,
error: WriteSignal<Option<String>>,
) -> impl IntoView { ) -> impl IntoView {
let slots = move || { let slots = move || {
settings settings
@ -129,7 +130,7 @@ pub fn Settings(
<div class="role-add-list">{categories}</div> <div class="role-add-list">{categories}</div>
<div class="setup-slots">{slots}</div> <div class="setup-slots">{slots}</div>
</div> </div>
<HostPlayerList players=players /> <HostPlayerList error=error players=players reply=reply />
} }
} }
#[component] #[component]
@ -211,7 +212,7 @@ fn SettingsSetupSlot(
] ]
.as_classes() .as_classes()
.to_string() .to_string()
text=setup_slot.read().role.title().to_string().to_case(Case::Title) button_content=setup_slot.read().role.title().to_string().to_case(Case::Title)
close_backdrop=true close_backdrop=true
> >
<SlotSettingsDialogBody setup_slot=setup_slot players=players /> <SlotSettingsDialogBody setup_slot=setup_slot players=players />

View File

@ -5,6 +5,7 @@ use std::collections::HashSet;
use convert_case::{Case, Casing}; use convert_case::{Case, Casing};
use leptos::prelude::*; use leptos::prelude::*;
use sorted_vec::SortedSet;
use werewolves_proto::{ use werewolves_proto::{
message::{ message::{
ClientDeadChat, ClientMessage, ServerToClientMessage as Srv2Client, dead::DeadChatMessage, ClientDeadChat, ClientMessage, ServerToClientMessage as Srv2Client, dead::DeadChatMessage,
@ -35,8 +36,7 @@ pub fn PlayerGamePage(
reply: WriteSignal<Option<ClientMessage>>, reply: WriteSignal<Option<ClientMessage>>,
disconnect: RwSignal<bool>, disconnect: RwSignal<bool>,
) -> impl IntoView { ) -> impl IntoView {
let error = RwSignal::new(None); let dead_chat: RwSignal<Option<SortedSet<DeadChatMessage>>> = RwSignal::new(None);
let dead_chat: RwSignal<Option<HashSet<HashedDeadChatMessage>>> = RwSignal::new(None);
let page: RwSignal<Option<Page>> = RwSignal::new(None); let page: RwSignal<Option<Page>> = RwSignal::new(None);
Effect::new(move || { Effect::new(move || {
let Some(message) = message.get() else { let Some(message) = message.get() else {
@ -64,17 +64,12 @@ pub fn PlayerGamePage(
Srv2Client::Story(game_story) => todo!("{game_story:#?}"), Srv2Client::Story(game_story) => todo!("{game_story:#?}"),
Srv2Client::Update(_) => {} Srv2Client::Update(_) => {}
Srv2Client::DeadChat(dead_chat_messages) => { Srv2Client::DeadChat(dead_chat_messages) => {
dead_chat.set(Some( dead_chat.set(Some(dead_chat_messages.into_iter().collect()));
dead_chat_messages
.into_iter()
.map(Into::<HashedDeadChatMessage>::into)
.collect(),
));
page.set(Some(Page::DeadChat)); page.set(Some(Page::DeadChat));
} }
Srv2Client::DeadChatMessage(dead_chat_message) => { Srv2Client::DeadChatMessage(dead_chat_message) => {
if let Some(chat) = dead_chat.write().as_mut() { if let Some(chat) = dead_chat.write().as_mut() {
chat.insert(dead_chat_message.into()); chat.push(dead_chat_message);
page.set(Some(Page::DeadChat)); page.set(Some(Page::DeadChat));
} else { } else {
reply.set(Some(ClientMessage::DeadChat(ClientDeadChat::GetHistory))); reply.set(Some(ClientMessage::DeadChat(ClientDeadChat::GetHistory)));
@ -134,45 +129,5 @@ pub fn PlayerGamePage(
.into_any(), .into_any(),
} }
}; };
view! { view! { {content} }.into_any()
<ErrorBox msg=error />
{content}
}
.into_any()
}
#[repr(transparent)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HashedDeadChatMessage(DeadChatMessage);
impl Ord for HashedDeadChatMessage {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.timestamp.cmp(&other.timestamp)
}
}
impl PartialOrd for HashedDeadChatMessage {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Hash for HashedDeadChatMessage {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}
impl From<DeadChatMessage> for HashedDeadChatMessage {
fn from(value: DeadChatMessage) -> Self {
Self(value)
}
}
impl From<HashedDeadChatMessage> for DeadChatMessage {
fn from(value: HashedDeadChatMessage) -> Self {
value.0
}
}
impl Deref for HashedDeadChatMessage {
type Target = DeadChatMessage;
fn deref(&self) -> &Self::Target {
&self.0
}
} }

View File

@ -11,7 +11,7 @@ pub fn PlayerLobby(
reply: WriteSignal<Option<ClientMessage>>, reply: WriteSignal<Option<ClientMessage>>,
joined: ReadSignal<bool>, joined: ReadSignal<bool>,
current_number: ReadSignal<Option<NonZeroU8>>, current_number: ReadSignal<Option<NonZeroU8>>,
error: RwSignal<Option<String>>, error: WriteSignal<Option<String>>,
) -> impl IntoView { ) -> impl IntoView {
let click = move |ev: MouseEvent| { let click = move |ev: MouseEvent| {
ev.prevent_default(); ev.prevent_default();
@ -68,7 +68,7 @@ pub fn PlayerLobby(
form_hidden.set(true); form_hidden.set(true);
}; };
view! { view! {
<form class="number-update" on:submit=submit hidden=move || form_hidden.get()> <form class="number-update bigger" on:submit=submit hidden=move || form_hidden.get()>
<label for="player-number">"change seat number"</label> <label for="player-number">"change seat number"</label>
<input <input
id="player-number" id="player-number"

View File

@ -109,7 +109,7 @@ pub fn ChangePasswordButton() -> impl IntoView {
}; };
view! { view! {
<DialogModal text="change password".into() close_backdrop=false> <DialogModal button_content="change password" close_backdrop=false>
<ErrorBox msg=error /> <ErrorBox msg=error />
<form> <form>
<div class="form-fields"> <div class="form-fields">

View File

@ -81,7 +81,7 @@ pub fn UpdateProfileButton() -> impl IntoView {
.then_some(view! { <p>"profile updated"</p> }) .then_some(view! { <p>"profile updated"</p> })
}; };
view! { view! {
<DialogModal text="update profile".into() close_backdrop=false> <DialogModal button_content="update profile" close_backdrop=false>
<ErrorBox msg=error /> <ErrorBox msg=error />
<form> <form>
<label for="display-name">"display name"</label> <label for="display-name">"display name"</label>