removed notorious, added more info to host setup

This commit is contained in:
emilis 2025-12-11 16:04:05 +00:00
parent 3d17a2d54b
commit 6dd78aa1b5
No known key found for this signature in database
13 changed files with 828 additions and 425 deletions

View File

@ -53,8 +53,6 @@ pub enum Aura {
/// getting a first result of wolf/killer/powerful if seer/adjudicator/power seer /// getting a first result of wolf/killer/powerful if seer/adjudicator/power seer
#[checks("assignable")] #[checks("assignable")]
InevitableScapegoat, InevitableScapegoat,
#[checks("assignable")]
Notorious,
} }
impl Display for Aura { impl Display for Aura {
@ -69,7 +67,6 @@ impl Display for Aura {
Aura::VindictiveScapegoat => "Vindictive Scapegoat", Aura::VindictiveScapegoat => "Vindictive Scapegoat",
Aura::SpitefulScapegoat => "Spiteful Scapegoat", Aura::SpitefulScapegoat => "Spiteful Scapegoat",
Aura::InevitableScapegoat => "Inevitable Scapegoat", Aura::InevitableScapegoat => "Inevitable Scapegoat",
Aura::Notorious => "Notorious",
}) })
} }
} }
@ -82,7 +79,6 @@ impl Aura {
| Aura::VindictiveScapegoat | Aura::VindictiveScapegoat
| Aura::SpitefulScapegoat | Aura::SpitefulScapegoat
| Aura::InevitableScapegoat | Aura::InevitableScapegoat
| Aura::Notorious
| Aura::Traitor | Aura::Traitor
| Aura::Drunk(_) | Aura::Drunk(_)
| Aura::Insane => false, | Aura::Insane => false,
@ -171,8 +167,7 @@ impl Auras {
for aura in self.0.iter() { for aura in self.0.iter() {
match aura { match aura {
Aura::Traitor => return Some(Alignment::Traitor), Aura::Traitor => return Some(Alignment::Traitor),
Aura::Notorious Aura::RedeemableScapegoat
| Aura::RedeemableScapegoat
| Aura::VindictiveScapegoat | Aura::VindictiveScapegoat
| Aura::SpitefulScapegoat | Aura::SpitefulScapegoat
| Aura::Scapegoat | Aura::Scapegoat
@ -212,7 +207,39 @@ impl AuraTitle {
AuraTitle::VindictiveScapegoat => Aura::VindictiveScapegoat, AuraTitle::VindictiveScapegoat => Aura::VindictiveScapegoat,
AuraTitle::SpitefulScapegoat => Aura::SpitefulScapegoat, AuraTitle::SpitefulScapegoat => Aura::SpitefulScapegoat,
AuraTitle::InevitableScapegoat => Aura::InevitableScapegoat, AuraTitle::InevitableScapegoat => Aura::InevitableScapegoat,
AuraTitle::Notorious => Aura::Notorious, }
}
/// returns a list of auras with the current aura added.
///
/// **this will remove incompatible auras**
pub fn try_add_to_list(&self, list: &[Self]) -> Vec<Self> {
list.iter()
.filter(|o| *o != self && !self.incompatible_with(**o))
.copied()
.chain(Some(*self))
.collect()
}
/// determines whether the `other` [AuraTitle] is incompatible with
/// this aura
pub fn incompatible_with(&self, other: Self) -> bool {
match self {
AuraTitle::Bloodlet | AuraTitle::Insane | AuraTitle::Drunk | AuraTitle::Traitor => {
false
}
AuraTitle::VindictiveScapegoat
| AuraTitle::RedeemableScapegoat
| AuraTitle::SpitefulScapegoat
| AuraTitle::InevitableScapegoat
| AuraTitle::Scapegoat => matches!(
other,
AuraTitle::Scapegoat
| AuraTitle::RedeemableScapegoat
| AuraTitle::SpitefulScapegoat
| AuraTitle::VindictiveScapegoat
| AuraTitle::InevitableScapegoat
),
} }
} }
} }

View File

@ -643,7 +643,6 @@ impl Night {
Aura::RedeemableScapegoat Aura::RedeemableScapegoat
| Aura::VindictiveScapegoat | Aura::VindictiveScapegoat
| Aura::SpitefulScapegoat | Aura::SpitefulScapegoat
| Aura::Notorious
| Aura::Scapegoat | Aura::Scapegoat
| Aura::Traitor | Aura::Traitor
| Aura::Bloodlet { .. } => continue, | Aura::Bloodlet { .. } => continue,

View File

@ -165,7 +165,6 @@ impl SetupRoleTitle {
| AuraTitle::VindictiveScapegoat | AuraTitle::VindictiveScapegoat
| AuraTitle::SpitefulScapegoat | AuraTitle::SpitefulScapegoat
| AuraTitle::InevitableScapegoat | AuraTitle::InevitableScapegoat
| AuraTitle::Notorious
| AuraTitle::Traitor | AuraTitle::Traitor
| AuraTitle::Bloodlet | AuraTitle::Bloodlet
| AuraTitle::Insane => false, | AuraTitle::Insane => false,
@ -212,12 +211,10 @@ impl SetupRoleTitle {
| SetupRoleTitle::Insomniac | SetupRoleTitle::Insomniac
) )
} }
AuraTitle::Notorious => {
!matches!(self, SetupRoleTitle::Villager | SetupRoleTitle::Scapegoat)
}
AuraTitle::RedeemableScapegoat => matches!(self, SetupRoleTitle::Villager), AuraTitle::RedeemableScapegoat => matches!(self, SetupRoleTitle::Villager),
AuraTitle::VindictiveScapegoat | AuraTitle::SpitefulScapegoat => true, AuraTitle::VindictiveScapegoat
AuraTitle::Scapegoat => matches!(self, SetupRoleTitle::Villager), | AuraTitle::SpitefulScapegoat
| AuraTitle::Scapegoat => !matches!(self, SetupRoleTitle::Scapegoat),
} }
} }
pub fn into_role(self) -> Role { pub fn into_role(self) -> Role {

View File

@ -264,7 +264,7 @@ nav.host-nav {
width: 100%; width: 100%;
} }
&>label { &>span {
font-size: 1rem; font-size: 1rem;
margin-bottom: 0; margin-bottom: 0;
} }
@ -473,13 +473,17 @@ button.confirm {
} }
.roles-in-setup { .roles-in-setup {
border: 1px solid rgba(255, 255, 255, 0.6); padding: 1rem;
padding: 10px; border: 4px solid $village_color_faint;
background-color: color.change($village_color_faint, $alpha: 0.05);
zoom: 120%;
&>h3 { &>h3 {
margin: 0; margin: 0;
text-align: center; text-align: center;
color: rgba(255, 255, 255, 0.6); color: white;
font-weight: normal;
} }
} }
@ -862,11 +866,9 @@ clients {
} }
&.shown { &.shown {
// visibility: visible;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: baseline; align-items: baseline;
// position: absolute;
} }
button { button {
@ -914,6 +916,31 @@ error {
} }
} }
.identity-span {
list-style: none;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 2px;
font-size: 1rem;
margin: 0px;
padding: 0px;
align-items: center;
span {
margin: 0px;
padding: 0px;
text-wrap: nowrap;
overflow: hidden;
}
.number {
padding-right: 5px;
font-size: 1.2em;
}
}
.binary { .binary {
margin: 0; margin: 0;
padding: 0; padding: 0;
@ -1133,6 +1160,52 @@ input {
align-self: flex-end; align-self: flex-end;
} }
.slot-container {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 3px;
font-size: 0.7em;
.slot-options {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 3px;
align-items: center;
opacity: 30%;
&:hover {
opacity: 100%;
}
}
}
.slot-auras {
padding-top: 5px;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
align-items: center;
gap: 2px;
.slot-aura-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 3px;
span::after {
content: ', ';
}
span:last-child::after {
content: '';
}
}
}
.increment-decrement { .increment-decrement {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -1142,9 +1215,7 @@ input {
align-items: center; align-items: center;
align-content: center; align-content: center;
&>label { &>span {
// height: 100%;
// width: 100%;
flex-grow: 3; flex-grow: 3;
} }
@ -1158,8 +1229,7 @@ input {
.setup-slot { .setup-slot {
text-align: center; text-align: center;
& button label { & button span {
color: white;
cursor: pointer; cursor: pointer;
} }
@ -1167,6 +1237,7 @@ input {
flex-direction: column; flex-direction: column;
flex-wrap: nowrap; flex-wrap: nowrap;
align-items: center; align-items: center;
gap: 3px;
&>.submenu { &>.submenu {
width: 30vw; width: 30vw;
@ -1199,6 +1270,19 @@ input {
gap: 10px; gap: 10px;
font-size: 1.5rem; font-size: 1.5rem;
.role-box {
width: 1.25em;
height: 1.25em;
display: flex;
align-items: center;
justify-content: center;
&>* {
flex-shrink: 1;
width: 1em;
}
}
cursor: pointer; cursor: pointer;
} }
@ -1431,7 +1515,8 @@ input {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-around; justify-content: flex-start;
gap: 5px;
row-gap: 10px; row-gap: 10px;
font-size: 2em; font-size: 2em;
@ -1463,71 +1548,106 @@ input {
border: 1px solid color.change($defensive_border, $lightness: 40%); border: 1px solid color.change($defensive_border, $lightness: 40%);
} }
.category {
margin-bottom: 30px;
width: 30%;
text-align: center;
display: flex;
flex-direction: column;
&.final { }
margin-top: 1cm;
margin-bottom: 1cm;
.category {
text-align: center;
display: flex;
flex-direction: column;
&.add-list {
@media only screen and (max-width : 599px) {
width: 100%;
} }
& .title { @media only screen and (min-width : 600px) {
text-shadow: black 3px 2px; width: 160px;
margin-bottom: 10px;
} }
& .count { .hidden {
text-align: right; display: none;
left: -40px; }
position: relative;
width: 0; width: auto;
height: 0; flex-wrap: wrap;
gap: 1px;
.scapegoats {
color: rgba(255, 0, 255, 0.7); &>.title {
font-size: 2em; font-size: 0.5em !important;
position: absolute; margin-bottom: 0px !important;
} cursor: pointer;
} }
.category-list { .category-list {
text-align: left; gap: 1px;
flex: 1, 1, 100%; }
display: flex; }
flex-wrap: nowrap;
flex-direction: column;
gap: 5px;
.slot { &.big {
margin-bottom: 30px;
width: 30%;
}
&.final {
margin-top: 1cm;
margin-bottom: 1cm;
}
& .title {
text-shadow: black 3px 2px;
margin-bottom: 10px;
}
& .count {
text-align: right;
left: -40px;
position: relative;
width: 0;
height: 0;
.scapegoats {
color: rgba(255, 0, 255, 0.7);
font-size: 2em;
position: absolute;
}
}
.category-list {
text-align: left;
display: flex;
flex-wrap: nowrap;
flex-direction: column;
gap: 5px;
.slot {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
.attributes {
margin-left: 10px;
align-self: flex-end;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: nowrap; flex-wrap: nowrap;
gap: 10px;
}
.attributes { .role {
margin-left: 10px; text-shadow: black 3px 2px;
align-self: flex-end;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 10px;
}
.role { width: 100%;
text-shadow: black 3px 2px; filter: saturate(40%);
padding-left: 10px;
padding-right: 10px;
width: 100%; &.wakes {
filter: saturate(40%); border: 2px solid yellow;
padding-left: 10px;
padding-right: 10px;
&.wakes {
border: 2px solid yellow;
}
} }
} }
} }
@ -2521,3 +2641,10 @@ li.choice {
} }
} }
} }
.option-menu {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 5px;
}

View File

@ -42,9 +42,10 @@ use yew::{html::Scope, prelude::*};
use crate::{ use crate::{
callback, callback,
components::{ components::{
Button, Footer, Lobby, LobbyPlayerAction, RoleReveal, Settings, Story, Victory, Button, Footer, Lobby, LobbyPlayerAction, RoleReveal, Story, Victory,
action::{ActionResultView, Prompt}, action::{ActionResultView, Prompt},
host::{CharacterStatesReadOnly, DaytimePlayerList, Setup}, host::{CharacterStatesReadOnly, DaytimePlayerList, Setup},
settings::Settings,
}, },
pages::RolePage, pages::RolePage,
storage::StorageKey, storage::StorageKey,

View File

@ -30,7 +30,6 @@ impl Class for AuraTitle {
AuraTitle::RedeemableScapegoat AuraTitle::RedeemableScapegoat
| AuraTitle::SpitefulScapegoat | AuraTitle::SpitefulScapegoat
| AuraTitle::VindictiveScapegoat | AuraTitle::VindictiveScapegoat
| AuraTitle::Notorious
| AuraTitle::InevitableScapegoat | AuraTitle::InevitableScapegoat
| AuraTitle::Scapegoat => "scapegoat", | AuraTitle::Scapegoat => "scapegoat",
}) })

View File

@ -100,7 +100,7 @@ pub fn Setup(SetupProps { settings }: &SetupProps) -> Html {
<div class="setup"> <div class="setup">
{categories} {categories}
</div> </div>
<div class="category village final"> <div class="category big village final">
<span class="count">{power_roles_count}</span> <span class="count">{power_roles_count}</span>
<span class="title">{"Power roles from..."}</span> <span class="title">{"Power roles from..."}</span>
</div> </div>
@ -208,7 +208,7 @@ pub fn SetupCategory(
}) })
.collect::<Html>(); .collect::<Html>();
html! { html! {
<div class="category"> <div class="category big">
{roles_count} {roles_count}
<div class={classes!("title", category.class())}> <div class={classes!("title", category.class())}>
{category.to_string().to_case(Case::Title)} {category.to_string().to_case(Case::Title)}

View File

@ -241,8 +241,7 @@ impl PartialAssociatedIcon for AuraTitle {
| AuraTitle::RedeemableScapegoat | AuraTitle::RedeemableScapegoat
| AuraTitle::VindictiveScapegoat | AuraTitle::VindictiveScapegoat
| AuraTitle::SpitefulScapegoat | AuraTitle::SpitefulScapegoat
| AuraTitle::InevitableScapegoat | AuraTitle::InevitableScapegoat => Some(IconSource::Scapegoat),
| AuraTitle::Notorious => Some(IconSource::Scapegoat),
} }
} }
} }

View File

@ -50,3 +50,33 @@ pub fn Identity(props: &IdentityProps) -> Html {
</div> </div>
} }
} }
#[function_component]
pub fn IdentitySpan(
IdentityProps {
ident:
PublicIdentity {
name,
pronouns,
number,
},
class,
}: &IdentityProps,
) -> Html {
let pronouns = pronouns.as_ref().map(|p| {
html! {
<span class="pronouns">{"("}{p}{")"}</span>
}
});
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-span", class.clone())}>
<span class={classes!("number", not_set)}><b>{number}</b></span>
<span>{name}</span>
{pronouns}
</div>
}
}

View File

@ -0,0 +1,75 @@
// Copyright (C) 2025 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convert_case::{Case, Casing};
use werewolves_proto::role::RoleTitle;
use yew::prelude::*;
use crate::components::Button;
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct AddRoleCategoryProps {
pub category: werewolves_proto::game::Category,
pub roles: Box<[RoleTitle]>,
pub add_role: Callback<RoleTitle>,
}
#[function_component]
pub fn AddRoleCategory(
AddRoleCategoryProps {
category,
roles,
add_role,
}: &AddRoleCategoryProps,
) -> Html {
let class = category.class();
let hidden = use_state(|| true);
let roles = roles
.iter()
.copied()
.map(|title| {
let on_click = {
let add_role = add_role.clone();
let role = title;
Callback::from(move |_| add_role.emit(role))
};
let name = title.to_string().to_case(Case::Title);
html! {
<div class="slot">
<Button on_click={on_click} classes={classes!("role", class)}>
{name}
</Button>
</div>
}
})
.collect::<Html>();
let on_toggle = {
let hidden = hidden.clone();
Callback::from(move |_| hidden.set(!*hidden))
};
let hidden = (*hidden).then_some("hidden");
html! {
<div class="category add-list">
<div class={classes!("title", class)} onclick={on_toggle}>
{category.to_string().to_case(Case::Sentence)}
</div>
<div class={classes!("category-list", hidden)}>
{roles}
</div>
</div>
}
}

View File

@ -0,0 +1,350 @@
// Copyright (C) 2025 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use core::{num::NonZeroU8, ops::Not};
use std::rc::Rc;
use convert_case::{Case, Casing};
use werewolves_proto::{
aura::AuraTitle,
error::GameError,
game::{Category, GameSettings, OrRandom, SetupRole, SetupSlot, SlotId},
message::{Identification, PlayerState, PublicIdentity},
role::RoleTitle,
};
use yew::prelude::*;
use crate::{
class::Class,
components::{
Button, ClickableField, Icon, IconType, Identity, PartialAssociatedIcon,
client::Signin,
settings::{AddRoleCategory, SettingSlotAction, SettingsSlot},
},
};
#[derive(Debug, PartialEq, Properties)]
pub struct SettingsProps {
pub settings: GameSettings,
pub players_in_lobby: Rc<[PlayerState]>,
pub on_update: Callback<GameSettings>,
pub on_start: Callback<()>,
pub on_add_player: Callback<PublicIdentity>,
pub qr_mode_button: Html,
}
#[function_component]
pub fn Settings(
SettingsProps {
settings,
players_in_lobby,
on_update,
on_start,
on_add_player,
qr_mode_button,
}: &SettingsProps,
) -> Html {
let players = players_in_lobby
.iter()
.map(|p| p.identification.clone())
.collect::<Rc<[_]>>();
let disabled_reason = settings
.check_with_player_list(&players)
.err()
.map(|err| err.to_string());
let roles_in_setup: Rc<[RoleTitle]> = settings
.slots()
.iter()
.map(|s| Into::<RoleTitle>::into(s.role.clone()))
.collect();
let on_update_role = on_update.clone();
let settings = Rc::new(settings.clone());
let on_update_role_settings = settings.clone();
let already_assigned_pids = settings
.slots()
.iter()
.filter_map(|r| r.assign_to)
.collect::<Box<[_]>>();
let players_for_assign = players
.iter()
.filter(|p| !already_assigned_pids.contains(&p.player_id))
.cloned()
.collect::<Rc<[_]>>();
let update_role_card = Callback::from(move |act: SettingSlotAction| {
let mut new_settings = (*on_update_role_settings).clone();
match act {
SettingSlotAction::Remove(slot_id) => new_settings.remove_slot(slot_id),
SettingSlotAction::Update(slot) => new_settings.update_slot(slot),
}
on_update_role.emit(new_settings);
});
let roles = settings
.slots()
.iter()
.map(|slot| {
html! {
<SettingsSlot
all_players={players.clone()}
players_for_assign={players_for_assign.clone()}
roles_in_setup={roles_in_setup.clone()}
slot={slot.clone()}
update={update_role_card.clone()}
/>
}
})
.collect::<Html>();
let add_roles_update = on_update.clone();
let add_role = {
let settings = settings.clone();
let update = add_roles_update.clone();
Callback::from(move |role: RoleTitle| {
let mut settings = (*settings).clone();
settings.new_slot(role);
update.emit(settings);
})
};
let add_roles_buttons = Category::ALL
.iter()
.copied()
.map(|cat| {
let roles = cat
.entire_category()
.into_iter()
.map(|c| c.into_role().title())
.collect::<Box<[_]>>();
html! {
<AddRoleCategory category={cat} roles={roles} add_role={add_role.clone()}/>
}
})
.collect::<Html>();
let clear_bad_assigned = matches!(
settings.check_with_player_list(&players),
Err(GameError::AssignedMultipleTimes(_, _)) | Err(GameError::AssignedPlayerMissing(_))
)
.then(|| {
let clear_settings = settings.clone();
let clear_update = on_update.clone();
let clear_players = players.clone();
let clear_bad_assigned = Callback::from(move |_| {
let mut settings = (*clear_settings).clone();
settings.remove_assignments_not_in_list(&clear_players);
settings.remove_duplicate_assignments();
clear_update.emit(settings);
});
html! {
<Button on_click={clear_bad_assigned}>
{"clear bad assignments"}
</Button>
}
});
let clear_all_assignments = settings
.slots()
.iter()
.any(|s| s.assign_to.is_some())
.then(|| {
let settings = settings.clone();
let update = on_update.clone();
let on_click = Callback::from(move |_| {
let mut settings = (*settings).clone();
settings
.slots()
.iter()
.filter(|s| s.assign_to.is_some())
.map(|s| {
let mut s = s.clone();
s.assign_to.take();
s
})
.collect::<Box<[_]>>()
.into_iter()
.for_each(|s| settings.update_slot(s));
update.emit(settings);
});
html! {
<Button on_click={on_click}>{"clear all assignments"}</Button>
}
});
let assignments = settings
.slots()
.iter()
.any(|s| s.assign_to.is_some())
.then(|| {
let assignments =
settings
.slots()
.iter()
.cloned()
.filter_map(|s| {
s.assign_to
.as_ref()
.map(|a| {
players
.iter()
.find(|p| p.player_id == *a)
.map(|assign| {
let class = s.role.category().class();
(html! {
<Identity ident={assign.public.clone()}/>
}, Some(class))
})
.unwrap_or_else(|| {
(html! {
<span>{"[left the lobby]"}</span>
}, None)
})
})
.map(|(who, class)| {
let assignments_update = on_update.clone();
let assignments_settings = settings.clone();
let click_slot = s.clone();
let on_click = Callback::from(move |_| {
let mut settings = (*assignments_settings).clone();
let mut click_slot = click_slot.clone();
click_slot.assign_to.take();
settings.update_slot(click_slot);
assignments_update.emit(settings);
});
html! {
<Button classes={classes!("assignment", class)} on_click={on_click}>
<label>{s.role.to_string().to_case(Case::Title)}</label>
{who}
</Button>
}
})
})
.collect::<Html>();
html! {
<>
<label>{"assignments"}</label>
<div class="assignments">
{assignments}
</div>
</>
}
});
let clear_setup = {
let update = on_update.clone();
let on_click = Callback::from(move |_| {
update.emit(GameSettings::empty());
});
let disabled_reason = settings.slots().is_empty().then_some("no setup to clear");
html! {
<Button on_click={on_click} disabled_reason={disabled_reason}>
{"clear setup"}
</Button>
}
};
let fill_empty_with_villagers = {
let disabled_reason =
(settings.min_players_needed() >= players.len()).then_some("no empty slots");
let update = on_update.clone();
let settings = settings.clone();
let player_count = players.len();
let on_click = Callback::from(move |_| {
let mut settings = (*settings).clone();
settings.fill_remaining_slots_with_villagers(player_count);
update.emit(settings);
});
html! {
<Button on_click={on_click} disabled_reason={disabled_reason}>
{"fill empty slots with villagers"}
</Button>
}
};
let add_player_open = use_state(|| false);
let add_player_opts = html! {
<div class="add-player">
<Signin callback={on_add_player.clone()}/>
</div>
};
let current_roles = settings.slots().len();
let min_roles = settings.min_players_needed();
let min_roles_class = (current_roles < min_roles).then_some("red");
let player_count = players_in_lobby.len();
let player_count_class = (player_count != current_roles).then_some("red");
html! {
<div class="settings">
<div class="top-settings">
{qr_mode_button.clone()}
{fill_empty_with_villagers}
{clear_setup}
{clear_all_assignments}
{clear_bad_assigned}
</div>
<ClickableField
options={add_player_opts}
state={add_player_open}
>
{"add player"}
</ClickableField>
<div class="roles-add-list">
{add_roles_buttons}
</div>
<div class="settings-info">
<span class="current-min-role-count">
<span class="dimmed">{"current/minimum roles: "}</span>
<span class={classes!(min_roles_class)}>
<span class="current-roles">
{current_roles}
</span>
{"/"}
<span class="min-roles">
{min_roles}
</span>
</span>
</span>
<span class="player-role-count">
<span class="dimmed">{"players/roles: "}</span>
<span class={classes!(player_count_class)}>
<span>
{player_count}
</span>
{"/"}
<span>
{current_roles}
</span>
</span>
</span>
</div>
<div class="roles-in-setup">
<h3>{"roles in the game"}</h3>
<div class="role-list">
{roles}
</div>
</div>
{assignments}
<Button
disabled_reason={disabled_reason}
classes={classes!("start-game")}
on_click={on_start.clone()}
>
{"start game"}
</Button>
</div>
}
}

View File

@ -12,348 +12,27 @@
// //
// 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::{num::NonZeroU8, ops::Not}; use core::{num::NonZeroU8, ops::Not};
use std::rc::Rc; use std::rc::Rc;
use convert_case::{Case, Casing}; use convert_case::{Case, Casing};
use werewolves_proto::{ use werewolves_proto::{
aura::AuraTitle, aura::AuraTitle,
error::GameError, game::{OrRandom, SetupRole, SetupSlot, SlotId},
game::{GameSettings, OrRandom, SetupRole, SetupSlot, SlotId}, message::Identification,
message::{Identification, PlayerState, PublicIdentity},
role::RoleTitle, role::RoleTitle,
}; };
use yew::prelude::*; use yew::prelude::*;
use crate::{ use crate::{
class::Class, class::Class,
components::{ components::{
Button, ClickableField, Icon, IconType, Identity, PartialAssociatedIcon, client::Signin, Button, ClickableField, Icon, IconType, Identity, IdentitySpan, PartialAssociatedIcon,
}, },
}; };
#[derive(Debug, PartialEq, Properties)]
pub struct SettingsProps {
pub settings: GameSettings,
pub players_in_lobby: Rc<[PlayerState]>,
pub on_update: Callback<GameSettings>,
pub on_start: Callback<()>,
pub on_add_player: Callback<PublicIdentity>,
pub qr_mode_button: Html,
}
#[function_component]
pub fn Settings(
SettingsProps {
settings,
players_in_lobby,
on_update,
on_start,
on_add_player,
qr_mode_button,
}: &SettingsProps,
) -> Html {
let players = players_in_lobby
.iter()
.map(|p| p.identification.clone())
.collect::<Rc<[_]>>();
let disabled_reason = settings
.check_with_player_list(&players)
.err()
.map(|err| err.to_string());
let roles_in_setup: Rc<[RoleTitle]> = settings
.slots()
.iter()
.map(|s| Into::<RoleTitle>::into(s.role.clone()))
.collect();
let on_update_role = on_update.clone();
let settings = Rc::new(settings.clone());
let on_update_role_settings = settings.clone();
let already_assigned_pids = settings
.slots()
.iter()
.filter_map(|r| r.assign_to)
.collect::<Box<[_]>>();
let players_for_assign = players
.iter()
.filter(|p| !already_assigned_pids.contains(&p.player_id))
.cloned()
.collect::<Rc<[_]>>();
let update_role_card = Callback::from(move |act: SettingSlotAction| {
let mut new_settings = (*on_update_role_settings).clone();
match act {
SettingSlotAction::Remove(slot_id) => new_settings.remove_slot(slot_id),
SettingSlotAction::Update(slot) => new_settings.update_slot(slot),
}
on_update_role.emit(new_settings);
});
let roles = settings
.slots()
.iter()
.map(|slot| {
html! {
<SettingsSlot
all_players={players.clone()}
players_for_assign={players_for_assign.clone()}
roles_in_setup={roles_in_setup.clone()}
slot={slot.clone()}
update={update_role_card.clone()}
/>
}
})
.collect::<Html>();
let add_roles_update = on_update.clone();
let sorted_role_tiles = {
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| {
let update = add_roles_update.clone();
let settings = settings.clone();
let on_click = Callback::from(move |_| {
let mut settings = (*settings).clone();
settings.new_slot(r);
update.emit(settings);
});
let class = Into::<SetupRole>::into(r).category().class();
let name = r.to_string().to_case(Case::Title);
// let icon = r.icon().map(|icon| {
// html! {
// <Icon source={icon} icon_type={IconType::Small}/>
// }
// });
html! {
<button
onclick={on_click}
class={classes!(class, "add-role")}
>
<span>{name}</span>
</button>
}
})
.collect::<Html>();
let clear_bad_assigned = matches!(
settings.check_with_player_list(&players),
Err(GameError::AssignedMultipleTimes(_, _)) | Err(GameError::AssignedPlayerMissing(_))
)
.then(|| {
let clear_settings = settings.clone();
let clear_update = on_update.clone();
let clear_players = players.clone();
let clear_bad_assigned = Callback::from(move |_| {
let mut settings = (*clear_settings).clone();
settings.remove_assignments_not_in_list(&clear_players);
settings.remove_duplicate_assignments();
clear_update.emit(settings);
});
html! {
<Button on_click={clear_bad_assigned}>
{"clear bad assignments"}
</Button>
}
});
let clear_all_assignments = settings
.slots()
.iter()
.any(|s| s.assign_to.is_some())
.then(|| {
let settings = settings.clone();
let update = on_update.clone();
let on_click = Callback::from(move |_| {
let mut settings = (*settings).clone();
settings
.slots()
.iter()
.filter(|s| s.assign_to.is_some())
.map(|s| {
let mut s = s.clone();
s.assign_to.take();
s
})
.collect::<Box<[_]>>()
.into_iter()
.for_each(|s| settings.update_slot(s));
update.emit(settings);
});
html! {
<Button on_click={on_click}>{"clear all assignments"}</Button>
}
});
let assignments = settings
.slots()
.iter()
.any(|s| s.assign_to.is_some())
.then(|| {
let assignments =
settings
.slots()
.iter()
.cloned()
.filter_map(|s| {
s.assign_to
.as_ref()
.map(|a| {
players
.iter()
.find(|p| p.player_id == *a)
.map(|assign| {
let class = s.role.category().class();
(html! {
<Identity ident={assign.public.clone()}/>
}, Some(class))
})
.unwrap_or_else(|| {
(html! {
<span>{"[left the lobby]"}</span>
}, None)
})
})
.map(|(who, class)| {
let assignments_update = on_update.clone();
let assignments_settings = settings.clone();
let click_slot = s.clone();
let on_click = Callback::from(move |_| {
let mut settings = (*assignments_settings).clone();
let mut click_slot = click_slot.clone();
click_slot.assign_to.take();
settings.update_slot(click_slot);
assignments_update.emit(settings);
});
html! {
<Button classes={classes!("assignment", class)} on_click={on_click}>
<label>{s.role.to_string().to_case(Case::Title)}</label>
{who}
</Button>
}
})
})
.collect::<Html>();
html! {
<>
<label>{"assignments"}</label>
<div class="assignments">
{assignments}
</div>
</>
}
});
let clear_setup = {
let update = on_update.clone();
let on_click = Callback::from(move |_| {
update.emit(GameSettings::empty());
});
let disabled_reason = settings.slots().is_empty().then_some("no setup to clear");
html! {
<Button on_click={on_click} disabled_reason={disabled_reason}>
{"clear setup"}
</Button>
}
};
let fill_empty_with_villagers = {
let disabled_reason =
(settings.min_players_needed() >= players.len()).then_some("no empty slots");
let update = on_update.clone();
let settings = settings.clone();
let player_count = players.len();
let on_click = Callback::from(move |_| {
let mut settings = (*settings).clone();
settings.fill_remaining_slots_with_villagers(player_count);
update.emit(settings);
});
html! {
<Button on_click={on_click} disabled_reason={disabled_reason}>
{"fill empty slots with villagers"}
</Button>
}
};
let add_player_open = use_state(|| false);
let add_player_opts = html! {
<div class="add-player">
<Signin callback={on_add_player.clone()}/>
</div>
};
let current_roles = settings.slots().len();
let min_roles = settings.min_players_needed();
let min_roles_class = (current_roles < min_roles).then_some("red");
let player_count = players_in_lobby.len();
let player_count_class = (player_count != current_roles).then_some("red");
html! {
<div class="settings">
<div class="top-settings">
{qr_mode_button.clone()}
{fill_empty_with_villagers}
{clear_setup}
{clear_all_assignments}
{clear_bad_assigned}
</div>
<ClickableField
options={add_player_opts}
state={add_player_open}
>
{"add player"}
</ClickableField>
<div class="roles-add-list">
{add_roles_buttons}
</div>
<div class="settings-info">
<span class="current-min-role-count">
<span class="dimmed">{"current/minimum roles: "}</span>
<span class={classes!(min_roles_class)}>
<span class="current-roles">
{current_roles}
</span>
{"/"}
<span class="min-roles">
{min_roles}
</span>
</span>
</span>
<span class="player-role-count">
<span class="dimmed">{"players/roles: "}</span>
<span class={classes!(player_count_class)}>
<span>
{player_count}
</span>
{"/"}
<span>
{current_roles}
</span>
</span>
</span>
</div>
<div class="roles-in-setup">
<h3>{"roles in the game"}</h3>
<div class="role-list">
{roles}
</div>
</div>
{assignments}
<Button
disabled_reason={disabled_reason}
classes={classes!("start-game")}
on_click={on_start.clone()}
>
{"start game"}
</Button>
</div>
}
}
pub enum SettingSlotAction { pub enum SettingSlotAction {
Remove(SlotId), Remove(SlotId),
Update(SetupSlot), Update(SetupSlot),
@ -418,7 +97,7 @@ pub fn SettingsSlot(
}) })
.unwrap_or_else(|| html! {{"assign"}}); .unwrap_or_else(|| html! {{"assign"}});
html! { html! {
<> <div class="option-menu">
<Button on_click={on_kick} classes={classes!("red")}> <Button on_click={on_kick} classes={classes!("red")}>
{"remove"} {"remove"}
</Button> </Button>
@ -430,20 +109,62 @@ pub fn SettingsSlot(
{assign_text} {assign_text}
</ClickableField> </ClickableField>
{options} {options}
</> </div>
} }
}; };
let class = slot.role.category().class(); let class = slot.role.category().class();
let assigned_to = slot
.assign_to
.as_ref()
.copied()
.and_then(|assigned_to| all_players.iter().find(|p| p.player_id == assigned_to))
.map(|ident| {
html! {
<IdentitySpan ident={ident.public.clone()}/>
}
});
let auras = slot.auras.is_empty().not().then_some({
let list = slot
.auras
.iter()
.map(|aura| {
html! {
<span>{aura.to_string()}</span>
}
})
.collect::<Html>();
let title = (slot.auras.len() > 1).then_some(html! {
<span>{"auras:"}</span>
});
html! {
<div class="slot-auras">
{title}
<div class="slot-aura-list">
{list}
</div>
</div>
}
});
let other_options = display_options_for_slot(slot);
html! { html! {
<ClickableField <div class="slot-container">
class={classes!("setup-slot")} <ClickableField
button_class={classes!(class)} class={classes!("setup-slot")}
options={submenu} button_class={classes!(class, "faint")}
state={open} options={submenu}
with_backdrop_exit=true state={open}
> with_backdrop_exit=true
<label>{role_name}</label> >
</ClickableField> <span>{role_name}</span>
</ClickableField>
<div class="slot-options">
{assigned_to}
{auras}
{other_options}
</div>
</div>
} }
} }
@ -481,6 +202,81 @@ fn assign_to_submenu(
} }
} }
fn display_options_for_slot(slot: &SetupSlot) -> Html {
let options = match &slot.role {
SetupRole::Seer
| SetupRole::Arcanist
| SetupRole::Gravedigger
| SetupRole::Hunter
| SetupRole::Militia
| SetupRole::MapleWolf
| SetupRole::Guardian
| SetupRole::Protector
| SetupRole::Werewolf
| SetupRole::AlphaWolf
| SetupRole::DireWolf
| SetupRole::Shapeshifter
| SetupRole::LoneWolf
| SetupRole::Bloodletter
| SetupRole::Adjudicator
| SetupRole::Insomniac
| SetupRole::PowerSeer
| SetupRole::Mortician
| SetupRole::Beholder
| SetupRole::Empath
| SetupRole::Vindicator
| SetupRole::Diseased
| SetupRole::BlackKnight
| SetupRole::Weightlifter
| SetupRole::PyreMaster
| SetupRole::Villager => return html! {},
SetupRole::Elder { knows_on_night } => html! {
<span>{"wakes night "}{knows_on_night.get()}</span>
},
SetupRole::MasonLeader { recruits_available } => html! {
<span>{recruits_available.get()}{
if recruits_available.get() == 1 {
" recruit"
} else {
" recruits"
}
}</span>
},
SetupRole::Apprentice { to: None } => html! {
<span>{"random mentor"}</span>
},
SetupRole::Apprentice { to: Some(mentor) } => {
let class = Into::<SetupRole>::into(*mentor).category().class();
html! {
<span>
{"apprentice to "}
<span class={classes!(class, "faint")}>{mentor.to_string().to_case(Case::Sentence)}</span>
</span>
}
}
SetupRole::Scapegoat {
redeemed: OrRandom::Random,
} => html! {
<span>{"redemption random"}</span>
},
SetupRole::Scapegoat {
redeemed: OrRandom::Determined(false),
} => html! {
<span>{"not redeemed"}</span>
},
SetupRole::Scapegoat {
redeemed: OrRandom::Determined(true),
} => html! {
<span>{"redeemed"}</span>
},
};
html! {
<div class="slot-options-display">
{options}
</div>
}
}
fn setup_options_for_slot( fn setup_options_for_slot(
slot: &SetupSlot, slot: &SetupSlot,
update: &Callback<SettingSlotAction>, update: &Callback<SettingSlotAction>,
@ -506,7 +302,7 @@ fn setup_options_for_slot(
if aura_active { if aura_active {
slot.auras.retain(|a| *a != aura); slot.auras.retain(|a| *a != aura);
} else { } else {
slot.auras.push(aura); slot.auras = aura.try_add_to_list(&slot.auras);
} }
update.emit(SettingSlotAction::Update(slot)) update.emit(SettingSlotAction::Update(slot))
}) })
@ -547,7 +343,7 @@ fn setup_options_for_slot(
state={open_aura_assign} state={open_aura_assign}
options={options} options={options}
> >
{"auras"} {slot.auras.len()}{" auras"}
</ClickableField> </ClickableField>
} }
}) })
@ -597,10 +393,10 @@ fn setup_options_for_slot(
let decrement_disabled_reason = prev.is_none().then_some("at minimum"); let decrement_disabled_reason = prev.is_none().then_some("at minimum");
Some(html! { Some(html! {
<> <>
<label>{"recruits"}</label> <span>{"recruits"}</span>
<div class={classes!("increment-decrement")}> <div class={classes!("increment-decrement")}>
<Button on_click={on_decrement} disabled_reason={decrement_disabled_reason}>{"-"}</Button> <Button on_click={on_decrement} disabled_reason={decrement_disabled_reason}>{"-"}</Button>
<label>{recruits_available.get().to_string()}</label> <span>{recruits_available.get().to_string()}</span>
<Button on_click={on_increment}>{"+"}</Button> <Button on_click={on_increment}>{"+"}</Button>
</div> </div>
</> </>
@ -630,9 +426,9 @@ fn setup_options_for_slot(
}; };
Some(html! { Some(html! {
<> <>
<label>{"redeemed?"}</label> <span>{"redeemed?"}</span>
<Button on_click={on_click}> <Button on_click={on_click}>
<label>{body}</label> <span>{body}</span>
</Button> </Button>
</> </>
}) })
@ -751,10 +547,10 @@ fn setup_options_for_slot(
}); });
Some(html! { Some(html! {
<> <>
<label>{"knows on night"}</label> <span>{"knows on night"}</span>
<div class={classes!("increment-decrement")}> <div class={classes!("increment-decrement")}>
<Button on_click={decrement}>{"-"}</Button> <Button on_click={decrement}>{"-"}</Button>
<label>{knows_on_night.to_string()}</label> <span>{knows_on_night.to_string()}</span>
<Button on_click={increment}>{"+"}</Button> <Button on_click={increment}>{"+"}</Button>
</div> </div>
</> </>

View File

@ -31,6 +31,9 @@ mod components {
pub mod action { pub mod action {
werewolves_macros::include_path!("werewolves/src/components/action"); werewolves_macros::include_path!("werewolves/src/components/action");
} }
pub mod settings {
werewolves_macros::include_path!("werewolves/src/components/settings");
}
} }
mod pages { mod pages {
werewolves_macros::include_path!("werewolves/src/pages"); werewolves_macros::include_path!("werewolves/src/pages");