screen test screen + minor ui fixups

This commit is contained in:
emilis 2025-11-17 20:03:08 +00:00
parent 6be2263f40
commit 7a50c3320a
No known key found for this signature in database
19 changed files with 1589 additions and 197 deletions

View File

@ -556,7 +556,7 @@ pub enum ActionResponse {
ContinueToResult,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Titles)]
pub enum ActionResult {
RoleBlocked,
Drunk,
@ -609,7 +609,7 @@ impl ActionResult {
pub struct Visits(Box<[CharacterIdentity]>);
impl Visits {
pub(crate) const fn new(visits: Box<[CharacterIdentity]>) -> Self {
pub const fn new(visits: Box<[CharacterIdentity]>) -> Self {
Self(visits)
}
}

View File

@ -15,7 +15,7 @@
use core::{fmt::Display, num::NonZeroU8, ops::Not};
use serde::{Deserialize, Serialize};
use werewolves_macros::{ChecksAs, Titles};
use werewolves_macros::{All, ChecksAs, Titles};
use crate::{
character::CharacterId,
@ -455,7 +455,7 @@ impl RoleTitle {
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, All)]
pub enum Alignment {
Village,
Wolves,

View File

@ -106,10 +106,6 @@ impl Host {
let slice: &[u8] = &bytes;
ciborium::from_reader(slice)?
};
if let HostMessage::Echo(echo) = &msg {
self.send_message(echo).await.log_warn();
return Ok(());
}
log::debug!(
"{} {}",
"[host::incoming::message]".bold(),

View File

@ -2,95 +2,27 @@
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="98.388664mm"
height="98.388702mm"
viewBox="0 0 98.388664 98.388702"
width="98.38868mm"
height="98.388695mm"
viewBox="0 0 98.38868 98.388695"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
sodipodi:docname="icons.svg"
xml:space="preserve"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="true"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="0.5"
inkscape:cx="1224"
inkscape:cy="1639"
inkscape:window-width="1918"
inkscape:window-height="1042"
inkscape:window-x="0"
inkscape:window-y="17"
inkscape:window-maximized="0"
inkscape:current-layer="layer4"><inkscape:grid
id="grid1"
units="mm"
originx="-298.47592"
originy="-475.7392"
spacingx="0.26458333"
spacingy="0.26458334"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="5"
enabled="true"
visible="false" /><inkscape:page
x="0"
y="0"
width="98.388664"
height="98.388702"
id="page2"
margin="0"
bleed="0" /></sodipodi:namedview><defs
id="defs1"><inkscape:path-effect
effect="mirror_symmetry"
start_point="373.14932,480.05752"
end_point="373.14932,569.65152"
center_point="373.14932,524.85452"
id="path-effect66"
is_visible="true"
lpeversion="1.2"
lpesatellites=""
mode="free"
discard_orig_path="false"
fuse_paths="true"
oposite_fuse="false"
split_items="false"
split_open="false"
link_styles="false" /></defs><g
inkscape:groupmode="layer"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><g
id="layer4"
inkscape:label="Layer 4"
transform="translate(-298.47593,-475.73921)"><path
d="m 282.57053,402.28177 -9.24492,22.95571 7.37371,2.96984 13.68651,5.51181 6.76496,2.7249 7.55613,-18.76268 -17.83044,6.70088 z m 18.58026,34.16226 -2.96985,7.37371 -5.5118,13.6865 -2.7249,6.76496 18.76268,7.55613 -6.70089,-17.83044 22.10046,-8.30595 z m -11.20655,27.82517 -7.37371,-2.96984 -13.68651,-5.51181 -6.76496,-2.72438 -7.55612,18.76216 17.83043,-6.70088 8.30595,22.10046 z m -27.82518,-11.20603 2.96985,-7.37423 5.51181,-13.6865 2.72489,-6.76496 -18.76267,-7.55613 6.70088,17.83043 -22.10098,8.30596 z"
style="fill:#3c34ff;stroke:#0f07ff;stroke-width:0.483509"
id="path55" /><g
transform="translate(-239.73841,-338.68501)"><g
id="g93"
transform="rotate(45,343.56148,515.63613)"><path
transform="rotate(45,479.63174,376.20661)"><path
id="path65"
style="fill:#643e00;fill-opacity:1;stroke:#000000;stroke-width:2.454;stroke-dasharray:none;stroke-opacity:1"
d="m 335.22656,480.05664 v 29.23242 h 10.47461 v -6.27148 h 8.22461 v 6.27148 h 12.50781 v 60.36328 h 6.71485 6.71679 v -60.36328 h 12.50782 v -6.27148 h 8.22461 v 6.27148 h 10.47461 v -29.23242 l -10.47461,0.002 v 6.26953 h -8.22461 v -6.27148 h -19.22461 -19.22266 v 6.27148 h -8.22461 v -6.26953 z"
sodipodi:nodetypes="ccccccccccccccc"
transform="translate(-20.108333,10.054166)"
inkscape:original-d="m 373.14932,480.05752 v 89.59401 h -6.71536 v -60.36272 h -12.50879 l 1.6e-4,-6.27098 -8.22395,1.7e-4 v 6.271 l -10.4743,-1.9e-4 v -29.23129 l 10.4743,4.8e-4 -5e-5,6.27083 8.224,1.7e-4 -1.6e-4,-6.27148 z"
inkscape:path-effect="#path-effect66" /><g
transform="translate(-20.108333,10.054166)" /><g
id="g91"><path
id="rect82"
style="fill:#1a1a1a;fill-opacity:1;stroke:#000000;stroke-width:2.454;stroke-dasharray:none;stroke-opacity:1"
d="m 325.593,496.383 v 16.689 h 8.224 v -16.689 z"
sodipodi:nodetypes="ccccc" /><path
d="m 325.593,496.383 v 16.689 h 8.224 v -16.689 z" /><path
id="rect82-3"
style="fill:#1a1a1a;fill-opacity:1;stroke:#000000;stroke-width:2.454;stroke-dasharray:none;stroke-opacity:1"
d="m 372.267,496.383 v 16.689 h 8.224 v -16.689 z"
sodipodi:nodetypes="ccccc" /></g></g></g></svg>
d="m 372.267,496.383 v 16.689 h 8.224 v -16.689 z" /></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -3,114 +3,26 @@
<svg
width="48.950741mm"
height="46.091217mm"
viewBox="0 0 48.950741 46.091217"
height="46.091213mm"
viewBox="0 0 48.950741 46.091213"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
sodipodi:docname="icons.svg"
xml:space="preserve"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="true"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="0.50000001"
inkscape:cx="1243"
inkscape:cy="22.999999"
inkscape:window-width="1918"
inkscape:window-height="1042"
inkscape:window-x="0"
inkscape:window-y="17"
inkscape:window-maximized="0"
inkscape:current-layer="layer4"><inkscape:grid
id="grid1"
units="mm"
originx="-111.78421"
originy="-150.91972"
spacingx="0.26458333"
spacingy="0.26458334"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="5"
enabled="true"
visible="false" /><inkscape:page
x="0"
y="-1.003508e-12"
width="48.950741"
height="46.091213"
id="page2"
margin="0"
bleed="0" /></sodipodi:namedview><defs
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><g
inkscape:groupmode="layer"
id="layer4"
inkscape:label="Layer 4"
transform="translate(-111.78421,-150.91973)"><path
id="path145"
style="fill:#878787;fill-opacity:0.8;stroke:#4c4c4c;stroke-width:0.861;stroke-dasharray:none;stroke-opacity:1"
d="m 183.52738,191.20695 c -2.41435,0.0449 -4.82586,0.45785 -7.10241,1.26865 -8.69357,2.98468 -16.70982,4.81246 -15.17168,20.63595 -0.32878,4.70686 -0.39306,9.48738 0.31884,14.16245 0.38831,2.68165 1.91493,5.49905 4.44572,6.62182 2.23089,0.44558 4.92605,-0.0675 6.56291,1.96318 4.44235,3.40984 1.74827,10.34937 5.14025,9.86452 2.132,-0.30475 0.68242,-3.69585 1.30638,-4.01164 0.96346,-0.48761 0.90617,-0.30265 1.20872,0.90588 0.16213,1.91232 -0.26173,3.48859 1.40611,3.3104 2.75286,-0.29411 1.43353,-2.40746 1.6397,-4.79144 0.37937,-0.0842 0.43999,-0.0618 0.44803,-0.0568 h 5.2e-4 l 5.1e-4,5.2e-4 5.2e-4,-5.2e-4 h 5.2e-4 c 0.008,-0.005 0.0686,-0.0274 0.44803,0.0568 0.20617,2.38398 -1.11317,4.49733 1.63969,4.79144 1.66784,0.17819 1.24399,-1.39808 1.40612,-3.3104 0.30255,-1.20853 0.24525,-1.39349 1.20871,-0.90588 0.62396,0.31579 -0.82562,3.70689 1.30638,4.01164 3.39198,0.48485 0.69791,-6.45468 5.14026,-9.86452 1.63686,-2.03072 4.33201,-1.5176 6.5629,-1.96318 2.53079,-1.12277 4.05742,-3.94017 4.44573,-6.62182 0.7119,-4.67507 0.64762,-9.45559 0.31884,-14.16245 1.53814,-15.82349 -6.47811,-17.65127 -15.17168,-20.63595 -2.27655,-0.8108 -4.68806,-1.22375 -7.10241,-1.26865 -0.0675,0 -0.13528,3e-5 -0.20361,5.2e-4 -0.0683,-4.9e-4 -0.1361,-5.2e-4 -0.2036,-5.2e-4 z m -10.36216,20.92224 c 3.5177,-0.0177 5.10805,2.49566 5.4715,3.52846 2.31327,5.55922 -1.86498,7.28273 -5.86993,7.12825 -3.49992,0.49438 -6.29206,0.25354 -6.71276,-5.06481 -0.05,-1.66043 1.54504,-4.80832 5.48338,-5.41775 0.58325,-0.11752 1.12528,-0.17163 1.62781,-0.17415 z m 21.13153,0 c 0.50252,0.003 1.04404,0.0566 1.62729,0.17415 3.93834,0.60943 5.53395,3.75732 5.4839,5.41775 -0.4207,5.31835 -3.21336,5.55919 -6.71328,5.06481 -4.00495,0.15448 -8.18268,-1.56903 -5.86941,-7.12825 0.36344,-1.0328 1.9538,-3.54611 5.4715,-3.52846 z m -10.56577,14.70866 c 1.07599,0.10793 1.42136,1.92498 2.91817,3.88142 1.38429,2.50152 -0.8238,3.48208 -2.04897,2.41535 -0.78178,-0.62609 -0.86916,-0.65369 -0.8692,-0.65371 -4e-5,2e-5 -0.0874,0.0276 -0.86919,0.65371 -1.22517,1.06673 -3.43326,0.0862 -2.04897,-2.41535 1.49681,-1.95644 1.84217,-3.77349 2.91816,-3.88142 z"
inkscape:export-filename="../../src/werewolves/werewolves/img/skull.svg"
inkscape:export-xdpi="900.08"
inkscape:export-ydpi="900.08" /><g
transform="translate(-111.78421,-150.91971)"><g
id="g1"><path
id="path148"
style="fill:#ff2424;fill-opacity:1;stroke:#ff2424;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 98.470476,189.15435 c -5.126341,-0.0936 -11.08233,2.61633 -12.495878,8.56846 -1.651423,-6.7938 -9.212596,-9.32255 -14.669906,-8.28166 -5.503903,1.04977 -9.783107,8.15614 -9.656258,13.75782 0.288519,12.74111 22.205993,30.02582 24.425899,31.74431 2.21079,-1.7302 24.034727,-19.12846 24.255887,-31.87092 0.0972,-5.60227 -4.21923,-12.68858 -9.72861,-13.70924 -0.68284,-0.1265 -1.3988,-0.1954 -2.131134,-0.20877 z"
transform="translate(50.270833,-38.1)" /><path
sodipodi:type="star"
style="fill:#fff001;fill-opacity:1;stroke:#ff2424;stroke-width:0.15;stroke-dasharray:none;stroke-opacity:1"
id="path150"
inkscape:flatsided="false"
sodipodi:sides="4"
sodipodi:cx="79.886864"
sodipodi:cy="208.97804"
sodipodi:r1="5.2418227"
sodipodi:r2="2.8830025"
sodipodi:arg1="0.607802"
sodipodi:arg2="1.3932002"
inkscape:rounded="0.5"
inkscape:randomized="0"
d="m 84.189903,211.97146 c -1.084141,1.55845 -1.92512,-0.49115 -3.793716,-0.15576 -1.868596,0.33539 -1.944289,2.54952 -3.502742,1.46538 -1.558453,-1.08414 0.491151,-1.92512 0.155762,-3.79371 -0.335389,-1.8686 -2.549524,-1.94429 -1.465383,-3.50275 1.084141,-1.55845 1.925121,0.49116 3.793717,0.15577 1.868596,-0.33539 1.944289,-2.54953 3.502742,-1.46539 1.558452,1.08414 -0.491152,1.92512 -0.155763,3.79372 0.335389,1.8686 2.549524,1.94429 1.465383,3.50274 z"
transform="rotate(28.628814,162.35113,272.55182)" /><path
sodipodi:type="star"
style="fill:#fff001;fill-opacity:1;stroke:#ff2424;stroke-width:0.15;stroke-dasharray:none;stroke-opacity:1"
id="path151"
inkscape:flatsided="false"
sodipodi:sides="4"
sodipodi:cx="68.848625"
sodipodi:cy="200.93324"
sodipodi:r1="6.7481718"
sodipodi:r2="3.3373971"
sodipodi:arg1="0.80500349"
sodipodi:arg2="1.5925996"
inkscape:rounded="0.5"
inkscape:randomized="0"
d="m 73.525842,205.79755 c -1.802133,1.72521 -2.255654,-1.4788 -4.749977,-1.5277 -2.4873,-0.0488 -3.071191,3.13767 -4.791545,1.34061 -1.725212,-1.80213 1.478793,-2.25565 1.527701,-4.74998 0.04877,-2.4873 -3.137671,-3.07119 -1.340613,-4.79154 1.802133,-1.72521 2.255655,1.47879 4.749978,1.5277 2.487299,0.0488 3.07119,-3.13767 4.791545,-1.34061 1.725212,1.80213 -1.478794,2.25565 -1.527702,4.74997 -0.04877,2.4873 3.137671,3.07119 1.340613,4.79155 z"
transform="translate(50.270833,-38.1)" /></g><path
sodipodi:type="star"
style="fill:#7f6500;fill-opacity:1;stroke:#daaf00;stroke-width:0.5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
id="path155-0"
inkscape:flatsided="false"
sodipodi:sides="6"
sodipodi:cx="99.020317"
sodipodi:cy="191.42604"
sodipodi:r1="7.1881213"
sodipodi:r2="4.5083508"
sodipodi:arg1="1.906205"
sodipodi:arg2="1.8672678"
inkscape:rounded="0.5"
inkscape:randomized="0"
d="m 96.65431,198.21361 c -1.309919,0.3028 1.927067,-1.45786 1.048904,-2.4759 -2.111403,-2.44772 -3.538875,-0.60338 -5.744108,-2.96691 -0.917188,-0.98302 2.22608,0.93996 2.668647,-0.32957 1.064081,-3.05239 -1.2469,-3.36644 -0.30264,-6.458 0.392731,-1.28582 0.299014,2.39782 1.619743,2.14633 3.175484,-0.60467 2.291975,-2.76307 5.441464,-3.49109 1.30992,-0.30279 -1.927062,1.45787 -1.0489,2.4759 2.1114,2.44772 3.53888,0.60338 5.74411,2.96691 0.91719,0.98302 -2.22608,-0.93996 -2.66865,0.32957 -1.06408,3.05239 1.2469,3.36645 0.30264,6.458 -0.39273,1.28582 -0.29901,-2.39782 -1.61974,-2.14633 -3.175485,0.60467 -2.291977,2.76307 -5.44147,3.49109 z"
transform="translate(6.9985536,-38.801292)" /></g></svg>
transform="translate(50.270833,-38.1)" /></g></g></svg>

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -1194,6 +1194,11 @@ input {
}
.village {
--faction-color: $village_color;
--faction-border: $village_border;
--faction-color-faint: $village_color_faint;
--faction-border-faint: $village_border_faint;
background-color: $village_color;
border: 1px solid $village_border;
@ -1213,6 +1218,11 @@ input {
}
.wolves {
--faction-color: $wolves_color;
--faction-border: $wolves_border;
--faction-color-faint: $wolves_color_faint;
--faction-border-faint: $wolves_border_faint;
background-color: $wolves_color;
border: 1px solid $wolves_border;
@ -1232,6 +1242,11 @@ input {
}
.intel {
--faction-color: $intel_color;
--faction-border: $intel_border;
--faction-color-faint: $intel_color_faint;
--faction-border-faint: $intel_border_faint;
background-color: $intel_color;
border: 1px solid $intel_border;
@ -1251,6 +1266,11 @@ input {
}
.defensive {
--faction-color: $defensive_color;
--faction-border: $defensive_border;
--faction-color-faint: $defensive_color_faint;
--faction-border-faint: $defensive_border_faint;
background-color: $defensive_color;
border: 1px solid $defensive_border;
@ -1270,6 +1290,11 @@ input {
}
.offensive {
--faction-color: $offensive_color;
--faction-border: $offensive_border;
--faction-color-faint: $offensive_color_faint;
--faction-border-faint: $offensive_border_faint;
background-color: $offensive_color;
border: 1px solid $offensive_border;
@ -1289,6 +1314,11 @@ input {
}
.starts-as-villager {
--faction-color: $starts_as_villager_color;
--faction-border: $starts_as_villager_border;
--faction-color-faint: $starts_as_villager_color_faint;
--faction-border-faint: $starts_as_villager_border_faint;
background-color: $starts_as_villager_color;
border: 1px solid $starts_as_villager_border;
@ -1308,6 +1338,11 @@ input {
}
.traitor {
--faction-color: $traitor_color;
--faction-border: $traitor_border;
--faction-color-faint: $traitor_color_faint;
--faction-border-faint: $traitor_border_faint;
background-color: $traitor_color;
border: 1px solid $traitor_border;
@ -1919,9 +1954,41 @@ li.choice {
&.masons,
&.large {
font-size: 2rem;
flex-wrap: wrap;
}
}
.info-player-boxes {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
justify-content: center;
gap: 10px;
overflow-y: hidden;
.identity {
// flex-grow: 1;
font-size: auto;
flex-direction: row;
align-items: center;
padding: 5px 10px 5px 10px;
.number {
color: rgba(255, 255, 0, 0.7);
font-size: 2em;
padding-right: 1cm;
}
.pronouns {
display: none;
}
}
flex-wrap: wrap;
text-align: center;
}
.two-column {
display: grid;
grid-template-columns: 3fr 2fr;
@ -2241,3 +2308,107 @@ li.choice {
.dimmed {
filter: opacity(70%);
}
.test-screen {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 10px;
align-items: center;
width: 80vw;
margin-left: 10vw;
margin-right: 10vw;
.test-screen-selector {
.category {
.selected {
background-color: rgba(0, 255, 0, 0.7);
border: 1px solid rgba(0, 255, 0, 1.0);
color: black;
}
label {
font-size: 2em;
}
button {
color: white;
}
gap: 10px;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
max-width: 80vw;
.test-screens {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin: 0;
padding-left: 0;
li {
margin: 0;
list-style: none;
}
}
}
}
}
.prompt-test,
.result-test {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
min-width: 40vw;
align-items: center;
.close {
width: 100%;
}
font-size: 2em;
.result-number,
.prompt-number {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 20px;
}
.result-options,
.prompt-options {
display: flex;
flex-direction: column;
flex-wrap: wrap;
align-items: center;
gap: 5px;
margin-top: 10px;
.option-set {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 5px;
}
.result-option,
.prompt-option {
width: 100%;
padding: 5px;
border: 1px solid white;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 5px;
}
}
}

View File

@ -190,6 +190,7 @@ async fn worker(mut recv: Receiver<HostMessage>, scope: Scope<Host>) {
}
}
#[derive(Debug)]
pub enum HostEvent {
SetErrorCallback(Callback<Option<WerewolfError>>),
SetBigScreenState(bool),
@ -488,11 +489,13 @@ impl Component for Host {
}));
})
};
html! {
<>
<Button on_click={on_error_click}>{"error"}</Button>
{screen}
<Button on_click={client_click}>{"client"}</Button>
<a href="/host/test"><Button on_click={|_|()}>{"test screens"}</Button></a>
<Button on_click={story_on_click}>{"story"}</Button>
</>
}
@ -520,6 +523,7 @@ impl Component for Host {
}
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
log::debug!("update: {msg:?}, current: {:?}", self.state);
match msg {
HostEvent::QrMode(mode) => {
self.qr_mode = mode;

View File

@ -22,6 +22,7 @@ pub mod host {
pub mod story_test;
pub use host::*;
}
pub mod test_remote;
// mod socket;
const DEBUG_URL: &str = "ws://192.168.1.162:8080/connect/";

View File

@ -0,0 +1,70 @@
// 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 futures::{SinkExt, StreamExt, TryStreamExt, lock::Mutex};
use gloo::net::websocket::{Message, futures::WebSocket};
use werewolves_proto::message::host::{HostLobbyMessage, HostMessage, ServerToHostMessage};
use yew::prelude::*;
use crate::test_util::TestScreens;
fn url() -> String {
format!(
"{}host",
option_env!("LOCAL")
.map(|_| crate::clients::DEBUG_URL)
.unwrap_or(crate::clients::LIVE_URL)
)
}
#[function_component]
pub fn TestClientRemote() -> Html {
gloo::utils::document().set_title("werewolves — remote test");
let send = use_memo((), |_| Mutex::new(WebSocket::open(url().as_str()).unwrap()));
let send_cb = {
let send = send.clone();
Callback::from(move |msg: ServerToHostMessage| {
let send = send.clone();
yew::platform::spawn_local(async move {
send.lock()
.await
.send(gloo::net::websocket::Message::Bytes({
let mut v = Vec::new();
ciborium::into_writer(&HostMessage::Echo(msg.clone()), &mut v).unwrap();
v
}))
.await
.unwrap();
loop {
match send.lock().await.try_next().await {
Ok(Some(Message::Bytes(b))) => {
let recv: ServerToHostMessage =
ciborium::from_reader(b.as_slice()).unwrap();
if recv == msg {
log::debug!("recv'd echo");
return;
}
}
Ok(_) => panic!("got text message"),
Err(err) => panic!("{err}"),
}
}
});
})
};
html! {
<TestScreens send={send_cb} />
}
}

View File

@ -72,14 +72,18 @@ pub fn CharacterCard(CharacterCardProps { faint, char, dead }: &CharacterCardPro
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct CharacterTargetCardProps {
pub ident: CharacterIdentity,
#[prop_or_default]
pub classes: Classes,
}
#[function_component]
pub fn CharacterTargetCard(CharacterTargetCardProps { ident }: &CharacterTargetCardProps) -> Html {
pub fn CharacterTargetCard(
CharacterTargetCardProps { ident, classes }: &CharacterTargetCardProps,
) -> Html {
let name = ident.name.clone();
html! {
<span class={classes!("character-span")}>
<span class={classes!("character-span", classes.clone())}>
<span class={classes!("number")}><b>{ident.number.get()}</b></span>
<span class="name">{name}</span>
</span>

View File

@ -1,3 +1,17 @@
// 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 werewolves_proto::game::GameOver;
use yew::prelude::*;

View File

@ -16,6 +16,7 @@ mod assets;
mod class;
mod clients;
mod storage;
mod test_util;
mod components {
werewolves_macros::include_path!("werewolves/src/components");
pub mod attributes {
@ -45,9 +46,13 @@ const BUILD_ID_LONG: &str = werewolves_macros::build_id_long!();
const BUILD_DIRTY: bool = werewolves_macros::build_dirty!();
const BUILD_TIME: &str = werewolves_macros::build_time!();
use crate::clients::{
client::{Client2, ClientContext},
host::{Host, HostEvent},
use crate::{
clients::{
client::{Client2, ClientContext},
host::{Host, HostEvent},
test_remote::TestClientRemote,
},
test_util::TestScreens,
};
fn main() {
@ -65,7 +70,9 @@ fn main() {
let error_callback =
Callback::from(move |err: Option<WerewolfError>| cb_clone.send_message(err));
if path.starts_with("/host") {
if path.starts_with("/host/test") {
yew::Renderer::<TestClientRemote>::with_root(app_element).render();
} else if path.starts_with("/host") {
let host = yew::Renderer::<Host>::with_root(app_element).render();
if path.starts_with("/host/big") {
host.send_message(HostEvent::SetBigScreenState(true));

View File

@ -37,7 +37,9 @@ pub fn BeholderSawNothing() -> Html {
<h1 class="intel">{"BEHOLDER"}</h1>
<div class="information intel faint">
<h1>{"YOUR TARGET HAS DIED"}</h1>
<h1><Icon source={IconSource::RedX} icon_type={IconType::Informational}/></h1>
<div class="info-icon-grow">
<Icon source={IconSource::RedX}/>
</div>
<h1>{"BUT SAW NOTHING"}</h1>
</div>
</div>

View File

@ -58,6 +58,9 @@ pub fn GuardianPagePreviousProtect1(GuardianPageProps { previous }: &GuardianPag
<h1 class="defensive">{"GUARDIAN"}</h1>
<div class="information defensive faint">
<h2>{"LAST TIME YOU PROTECTED"}</h2>
<div class="info-icon-grow">
<Icon source={IconSource::ShieldAndSword} />
</div>
<div class="info-player-list">
<CharacterTargetCard ident={previous.clone()} />
</div>
@ -74,6 +77,9 @@ pub fn GuardianPagePreviousProtect2(GuardianPageProps { previous }: &GuardianPag
<h1 class="defensive">{"GUARDIAN"}</h1>
<div class="information defensive faint">
<h2>{"LAST TIME YOU PROTECTED"}</h2>
<div class="info-icon-grow">
<Icon source={IconSource::ShieldAndSword} />
</div>
<div class="info-player-list">
<CharacterTargetCard ident={previous.clone()} />
</div>

View File

@ -15,7 +15,7 @@
use werewolves_proto::message::night::Visits;
use yew::prelude::*;
use crate::components::{CharacterTargetCard, Icon, IconSource};
use crate::components::{CharacterTargetCard, Icon, IconSource, Identity};
#[function_component]
pub fn InsomniacPage1() -> Html {
@ -46,7 +46,10 @@ pub fn InsomniacResult(InsomniacResultProps { visits }: &InsomniacResultProps) -
.iter()
.map(|visitor| {
html! {
<CharacterTargetCard ident={visitor.clone()}/>
<div class="identity intel">
<span class="number">{visitor.number.get()}</span>
<span class="name">{visitor.name.clone()}</span>
</div>
}
})
.collect::<Html>();
@ -55,7 +58,7 @@ pub fn InsomniacResult(InsomniacResultProps { visits }: &InsomniacResultProps) -
<h1 class="intel">{"INSOMNIAC"}</h1>
<div class="information intel faint">
<h2>{"YOU WERE VISITED IN THE NIGHT BY:"}</h2>
<div class="info-player-list large">
<div class="info-player-boxes">
{visitors}
</div>
</div>

View File

@ -28,8 +28,8 @@ pub fn MorticianPage1() -> Html {
<span class="yellow">{"DEAD"}</span>
{" PLAYER"}
</h2>
<div class="icons">
<Icon source={IconSource::Mortician} icon_type={IconType::Informational}/>
<div class="info-icon-grow">
<Icon source={IconSource::Mortician}/>
</div>
<h3 class="yellow">
{"YOU WILL LEARN THE CAUSE "}
@ -70,12 +70,9 @@ pub fn MorticianResultPage(
<h1 class="intel">{"MORTICIAN"}</h1>
<div class="information intel faint">
<h2>{"YOUR TARGET DIED TO"}</h2>
<h4>
<Icon
source={icon}
icon_type={IconType::Informational}
/>
</h4>
<div class="info-icon-grow">
<Icon source={icon}/>
</div>
<h3 class="yellow">{text}</h3>
</div>
</div>

View File

@ -0,0 +1,449 @@
// 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/>.
mod prompt;
mod result;
use core::num::NonZeroU8;
use werewolves_proto::{
character::CharacterId,
diedto::DiedToTitle,
message::{
CharacterIdentity,
host::{HostMessage, ServerToHostMessage},
night::{
ActionPrompt, ActionPromptTitle, ActionResult, ActionResultTitle, ActionType, Visits,
},
},
role::{Alignment, AlignmentEq, Killer, Powerful, RoleTitle},
};
use yew::prelude::*;
use crate::{
components::Button,
test_util::{prompt::PromptScreenTest, result::ResultScreenTest},
};
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct TestScreensProps {
pub send: Callback<ServerToHostMessage>,
}
#[function_component]
pub fn TestScreens(TestScreensProps { send }: &TestScreensProps) -> Html {
let screen: UseStateHandle<Option<TestScreen>> = use_state(|| None);
let page = use_state(|| 0usize);
let back = {
let screen = screen.setter();
Callback::from(move |_| screen.set(None))
};
let send = {
let send = send.clone();
let page = page.clone();
let screen = screen.clone();
Callback::from(move |msg: ServerToHostMessage| {
match &msg {
ServerToHostMessage::ActionPrompt(prompt, new_page) => {
if *page != *new_page {
page.set(*new_page);
}
if let Some(TestScreen::Prompt(current)) = &*screen
&& current != prompt
{
screen.set(Some(TestScreen::Prompt(prompt.clone())));
}
}
ServerToHostMessage::ActionResult(_, result) => {
screen.set(Some(TestScreen::Result(result.clone())));
}
_ => {}
}
send.emit(msg);
})
};
let screen_settings = screen.as_ref().map(|screen| match screen {
TestScreen::Prompt(p) => html! {
<PromptScreenTest
prompt={p.clone()}
page={*page}
back={back}
send={send.clone()}
/>
},
TestScreen::Result(result) => html! {
<ResultScreenTest
result={result.clone()}
send={send.clone()}
back={back}
/>
},
});
html! {
<div class="test-screen">
<TestScreenSelector screen={screen.clone()} send={send.clone()}/>
{screen_settings}
</div>
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct TestScreenSelectorProps {
pub screen: UseStateHandle<Option<TestScreen>>,
pub send: Callback<ServerToHostMessage>,
}
#[function_component]
pub fn TestScreenSelector(
TestScreenSelectorProps { screen, send }: &TestScreenSelectorProps,
) -> Html {
let prompts = ActionPromptTitle::ALL
.into_iter()
.map(|title| {
let TestScreen::Prompt(prompt) = Into::<TestScreen>::into(title) else {
unreachable!()
};
let picked_class = if let Some(TestScreen::Prompt(current)) = &**screen
&& current.title() == title
{
Some("selected")
} else {
None
};
let callback = {
let screen = screen.clone();
let send = send.clone();
Callback::from(move |_| {
let TestScreen::Prompt(prompt) = Into::<TestScreen>::into(title) else {
unreachable!()
};
screen.set(Some(TestScreen::Prompt(prompt.clone())));
send.emit(ServerToHostMessage::ActionPrompt(prompt, 0));
})
};
let class = prompt_class(&prompt);
html! {
<li>
<Button on_click={callback} classes={classes!(class, picked_class)}>
{format!("{title:?}")}
</Button>
</li>
}
})
.collect::<Html>();
let results = ActionResultTitle::ALL
.into_iter()
.filter(|title| !matches!(title, ActionResultTitle::Continue))
.map(|title| {
let TestScreen::Result(result) = Into::<TestScreen>::into(title) else {
unreachable!()
};
let picked_class = if let Some(TestScreen::Result(current)) = &**screen
&& current.title() == title
{
Some("selected")
} else {
None
};
let callback = {
let screen = screen.clone();
let send = send.clone();
Callback::from(move |_| {
let TestScreen::Result(result) = Into::<TestScreen>::into(title) else {
unreachable!()
};
screen.set(Some(TestScreen::Result(result.clone())));
send.emit(ServerToHostMessage::ActionResult(None, result));
})
};
let class = result_class(&result);
html! {
<li>
<Button on_click={callback} classes={classes!(picked_class, class)}>
{format!("{title:?}")}
</Button>
</li>
}
})
.collect::<Html>();
html! {
<div class="test-screen-selector">
<div class="category">
<label>{"prompts"}</label>
<ul class="test-screens">
{prompts}
</ul>
</div>
<div class="category">
<label>{"results"}</label>
<ul class="test-screens">
{results}
</ul>
</div>
</div>
}
}
fn identities(num: usize) -> Box<[CharacterIdentity]> {
(1..=num)
.map(|num| {
CharacterIdentity::new(
CharacterId::from_u128(num as _),
format!("Player {num}"),
Some("they/them".into()),
NonZeroU8::new(num as _).unwrap(),
)
})
.collect()
}
fn identity() -> CharacterIdentity {
identities(1).into_iter().next().unwrap()
}
#[derive(Debug, Clone, PartialEq)]
pub enum TestScreen {
Prompt(ActionPrompt),
Result(ActionResult),
}
impl From<ActionResultTitle> for TestScreen {
fn from(value: ActionResultTitle) -> Self {
TestScreen::Result(match value {
ActionResultTitle::RoleBlocked => ActionResult::RoleBlocked,
ActionResultTitle::Drunk => ActionResult::Drunk,
ActionResultTitle::Seer => ActionResult::Seer(Alignment::Village),
ActionResultTitle::PowerSeer => ActionResult::PowerSeer {
powerful: Powerful::Powerful,
},
ActionResultTitle::Adjudicator => ActionResult::Adjudicator {
killer: Killer::Killer,
},
ActionResultTitle::Arcanist => ActionResult::Arcanist(AlignmentEq::Same),
ActionResultTitle::GraveDigger => ActionResult::GraveDigger(None),
ActionResultTitle::Mortician => ActionResult::Mortician(DiedToTitle::Execution),
ActionResultTitle::Insomniac => ActionResult::Insomniac(Visits::new(identities(2))),
ActionResultTitle::Empath => ActionResult::Empath { scapegoat: true },
ActionResultTitle::BeholderSawNothing => ActionResult::BeholderSawNothing,
ActionResultTitle::BeholderSawEverything => ActionResult::BeholderSawEverything,
ActionResultTitle::GoBackToSleep => ActionResult::GoBackToSleep,
ActionResultTitle::ShiftFailed => ActionResult::ShiftFailed,
ActionResultTitle::Continue => ActionResult::Continue,
})
}
}
impl From<ActionPromptTitle> for TestScreen {
fn from(value: ActionPromptTitle) -> Self {
Self::Prompt(match value {
ActionPromptTitle::CoverOfDarkness => ActionPrompt::CoverOfDarkness,
ActionPromptTitle::WolvesIntro => ActionPrompt::WolvesIntro {
wolves: identities(5)
.into_iter()
.zip([
RoleTitle::Werewolf,
RoleTitle::AlphaWolf,
RoleTitle::DireWolf,
RoleTitle::LoneWolf,
RoleTitle::Bloodletter,
])
.collect(),
},
ActionPromptTitle::RoleChange => ActionPrompt::RoleChange {
character_id: identities(1).into_iter().next().unwrap(),
new_role: RoleTitle::Adjudicator,
},
ActionPromptTitle::ElderReveal => ActionPrompt::ElderReveal {
character_id: identities(1).into_iter().next().unwrap(),
},
ActionPromptTitle::Seer => ActionPrompt::Seer {
character_id: identities(1).into_iter().next().unwrap(),
living_players: identities(20),
marked: None,
},
ActionPromptTitle::Protector => ActionPrompt::Protector {
character_id: identities(1).into_iter().next().unwrap(),
targets: identities(20),
marked: None,
},
ActionPromptTitle::Arcanist => ActionPrompt::Arcanist {
character_id: identities(1).into_iter().next().unwrap(),
living_players: identities(20),
marked: (None, None),
},
ActionPromptTitle::Gravedigger => ActionPrompt::Gravedigger {
character_id: identities(1).into_iter().next().unwrap(),
dead_players: identities(20),
marked: None,
},
ActionPromptTitle::Hunter => ActionPrompt::Hunter {
character_id: identities(1).into_iter().next().unwrap(),
current_target: None,
living_players: identities(20),
marked: None,
},
ActionPromptTitle::Militia => ActionPrompt::Militia {
character_id: identities(1).into_iter().next().unwrap(),
living_players: identities(20),
marked: None,
},
ActionPromptTitle::MapleWolf => ActionPrompt::MapleWolf {
character_id: identities(1).into_iter().next().unwrap(),
kill_or_die: false,
living_players: identities(20),
marked: None,
},
ActionPromptTitle::Guardian => ActionPrompt::Guardian {
character_id: identities(1).into_iter().next().unwrap(),
previous: None,
living_players: identities(20),
marked: None,
},
ActionPromptTitle::Adjudicator => ActionPrompt::Adjudicator {
character_id: identities(1).into_iter().next().unwrap(),
living_players: identities(20),
marked: None,
},
ActionPromptTitle::PowerSeer => ActionPrompt::PowerSeer {
character_id: identities(1).into_iter().next().unwrap(),
living_players: identities(20),
marked: None,
},
ActionPromptTitle::Mortician => ActionPrompt::Mortician {
character_id: identities(1).into_iter().next().unwrap(),
dead_players: identities(20),
marked: None,
},
ActionPromptTitle::Beholder => ActionPrompt::Beholder {
character_id: identities(1).into_iter().next().unwrap(),
living_players: identities(20),
marked: None,
},
ActionPromptTitle::MasonsWake => ActionPrompt::MasonsWake {
leader: identities(1).into_iter().next().unwrap(),
masons: identities(3),
},
ActionPromptTitle::MasonLeaderRecruit => ActionPrompt::MasonLeaderRecruit {
character_id: identities(1).into_iter().next().unwrap(),
recruits_left: NonZeroU8::new(3).unwrap(),
potential_recruits: identities(20),
marked: None,
},
ActionPromptTitle::Empath => ActionPrompt::Empath {
character_id: identities(1).into_iter().next().unwrap(),
living_players: identities(20),
marked: None,
},
ActionPromptTitle::Vindicator => ActionPrompt::Vindicator {
character_id: identities(1).into_iter().next().unwrap(),
living_players: identities(20),
marked: None,
},
ActionPromptTitle::PyreMaster => ActionPrompt::PyreMaster {
character_id: identities(1).into_iter().next().unwrap(),
living_players: identities(20),
marked: None,
},
ActionPromptTitle::WolfPackKill => ActionPrompt::WolfPackKill {
living_villagers: identities(20),
marked: None,
},
ActionPromptTitle::Shapeshifter => ActionPrompt::Shapeshifter {
character_id: identities(1).into_iter().next().unwrap(),
},
ActionPromptTitle::AlphaWolf => ActionPrompt::AlphaWolf {
character_id: identities(1).into_iter().next().unwrap(),
living_villagers: identities(20),
marked: None,
},
ActionPromptTitle::DireWolf => ActionPrompt::DireWolf {
character_id: identities(1).into_iter().next().unwrap(),
living_players: identities(20),
marked: None,
},
ActionPromptTitle::LoneWolfKill => ActionPrompt::LoneWolfKill {
character_id: identities(1).into_iter().next().unwrap(),
living_players: identities(20),
marked: None,
},
ActionPromptTitle::Insomniac => ActionPrompt::Insomniac {
character_id: identities(1).into_iter().next().unwrap(),
},
ActionPromptTitle::Bloodletter => ActionPrompt::Bloodletter {
character_id: identities(1).into_iter().next().unwrap(),
living_players: identities(20),
marked: None,
},
ActionPromptTitle::TraitorIntro => ActionPrompt::TraitorIntro {
character_id: identities(1).into_iter().next().unwrap(),
},
})
}
}
fn result_class(result: &ActionResult) -> Option<&'static str> {
match result.title() {
ActionResultTitle::Drunk | ActionResultTitle::RoleBlocked => Some("drunk"),
ActionResultTitle::PowerSeer
| ActionResultTitle::Adjudicator
| ActionResultTitle::Arcanist
| ActionResultTitle::GraveDigger
| ActionResultTitle::Mortician
| ActionResultTitle::Insomniac
| ActionResultTitle::Empath
| ActionResultTitle::BeholderSawNothing
| ActionResultTitle::BeholderSawEverything
| ActionResultTitle::Seer => Some("intel"),
ActionResultTitle::ShiftFailed => Some("wolves"),
ActionResultTitle::GoBackToSleep | ActionResultTitle::Continue => None,
}
}
fn prompt_class(prompt: &ActionPrompt) -> Option<&'static str> {
match prompt {
ActionPrompt::ElderReveal { .. }
| ActionPrompt::RoleChange { .. }
| ActionPrompt::CoverOfDarkness => None,
ActionPrompt::Seer { .. }
| ActionPrompt::Arcanist { .. }
| ActionPrompt::Gravedigger { .. }
| ActionPrompt::Adjudicator { .. }
| ActionPrompt::PowerSeer { .. }
| ActionPrompt::Mortician { .. }
| ActionPrompt::Beholder { .. }
| ActionPrompt::MasonsWake { .. }
| ActionPrompt::MasonLeaderRecruit { .. }
| ActionPrompt::Empath { .. } => Some("intel"),
ActionPrompt::Protector { .. }
| ActionPrompt::Guardian { .. }
| ActionPrompt::Vindicator { .. } => Some("defensive"),
ActionPrompt::Hunter { .. }
| ActionPrompt::Militia { .. }
| ActionPrompt::MapleWolf { .. }
| ActionPrompt::PyreMaster { .. } => Some("offensive"),
ActionPrompt::WolvesIntro { .. }
| ActionPrompt::WolfPackKill { .. }
| ActionPrompt::Shapeshifter { .. }
| ActionPrompt::AlphaWolf { .. }
| ActionPrompt::DireWolf { .. }
| ActionPrompt::LoneWolfKill { .. }
| ActionPrompt::Insomniac { .. }
| ActionPrompt::Bloodletter { .. } => Some("wolves"),
ActionPrompt::TraitorIntro { .. } => Some("traitor"),
}
}

View File

@ -0,0 +1,453 @@
// 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 yew::prelude::*;
use core::num::NonZeroU8;
use web_sys::HtmlSelectElement;
use werewolves_proto::{
message::{
host::{HostGameMessage, HostMessage, HostNightMessage, ServerToHostMessage},
night::ActionPrompt,
},
player::RoleChange,
role::{PreviousGuardianAction, RoleTitle},
};
use crate::{components::Button, pages::RolePage, test_util::identities};
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct PromptScreenTestProps {
pub prompt: ActionPrompt,
pub page: usize,
pub send: Callback<ServerToHostMessage>,
pub back: Callback<()>,
}
#[function_component]
pub fn PromptScreenTest(
PromptScreenTestProps {
prompt,
page,
send,
back,
}: &PromptScreenTestProps,
) -> Html {
let options = match prompt {
ActionPrompt::WolvesIntro { wolves } => {
let dec_disabled = wolves
.is_empty()
.then_some(String::from("already have zero wolves"));
let dec = {
let current = wolves.len();
let send = send.clone();
Callback::from(move |_| {
let new_prompt = ActionPrompt::WolvesIntro {
wolves: super::identities(current - 1)
.into_iter()
.zip(RoleTitle::ALL.into_iter().filter(|w| w.wolf()).cycle())
.collect(),
};
send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0));
})
};
let inc_disabled =
(wolves.len() + 1 > 0xFF).then_some(String::from("already at max wolves"));
let inc = {
let current = wolves.len();
let send = send.clone();
Callback::from(move |_| {
let new_prompt = ActionPrompt::WolvesIntro {
wolves: super::identities(current + 1)
.into_iter()
.zip(RoleTitle::ALL.into_iter().filter(|w| w.wolf()).cycle())
.collect(),
};
send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0));
})
};
html! {
<div class="prompt-number">
<Button
on_click={dec}
disabled_reason={dec_disabled}
>
{"decrease"}
</Button>
<label>{wolves.len()}{" wolves"}</label>
<Button
on_click={inc}
disabled_reason={inc_disabled}
>
{"increase"}
</Button>
</div>
}
}
ActionPrompt::RoleChange { new_role, .. } => {
let roles = RoleTitle::ALL
.into_iter()
.map(|role| {
html! {
<option selected={role == *new_role}>{role.to_string()}</option>
}
})
.collect::<Html>();
let on_change_cb = {
let send = send.clone();
Callback::from(move |ev: Event| {
if let Some(select) = ev.target_dyn_into::<HtmlSelectElement>() {
let selected = select.selected_index();
if selected == -1 {
return;
}
if let Some(new_role) = RoleTitle::ALL.into_iter().nth(selected as _) {
let new_prompt = ActionPrompt::RoleChange {
character_id: super::identity(),
new_role,
};
send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0));
}
}
})
};
let on_wheel = {
let send = send.clone();
Callback::from(move |ev: WheelEvent| {
let Some(target) = ev.target_dyn_into::<HtmlSelectElement>() else {
return;
};
let index = target.selected_index();
let new_index = match ev.delta_y().total_cmp(&0.0) {
core::cmp::Ordering::Equal => return,
core::cmp::Ordering::Less => {
if index != 0 {
index - 1
} else {
(target.children().length() - 1) as i32
}
}
core::cmp::Ordering::Greater => {
if index + 1 < target.children().length() as i32 {
index + 1
} else {
0
}
}
};
target.set_selected_index(new_index);
if let Some(new_role) = RoleTitle::ALL.into_iter().nth(new_index as _) {
let new_prompt = ActionPrompt::RoleChange {
character_id: super::identity(),
new_role,
};
send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0));
}
})
};
html! {
<div class="prompt-option">
<label>{"new role"}</label>
<select onwheel={on_wheel} onchange={on_change_cb}>
{roles}
</select>
</div>
}
}
ActionPrompt::Hunter { current_target, .. } => {
let toggle_target = current_target.is_none().then_some(super::identity());
let on_toggle = {
let toggle_target = toggle_target.clone();
let send = send.clone();
Callback::from(move |_| {
let new_prompt = ActionPrompt::Hunter {
character_id: super::identity(),
current_target: toggle_target.clone(),
living_players: identities(20),
marked: None,
};
send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0));
})
};
let button_text = if toggle_target.is_some() {
"remove previous target"
} else {
"set previous target"
};
html! {
<div class="prompt-options">
<Button on_click={on_toggle}>{button_text}</Button>
</div>
}
}
ActionPrompt::MapleWolf { kill_or_die, .. } => {
let toggle = !*kill_or_die;
let on_toggle = {
let send = send.clone();
Callback::from(move |_| {
let new_prompt = ActionPrompt::MapleWolf {
character_id: super::identity(),
kill_or_die: toggle,
living_players: identities(20),
marked: None,
};
send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0));
})
};
let button_text = if toggle {
"turn starving"
} else {
"feed the poor boy"
};
html! {
<div class="prompt-options">
<Button on_click={on_toggle}>{button_text}</Button>
</div>
}
}
ActionPrompt::Guardian { previous, .. } => {
let none_disabled = previous.is_none().then_some(String::from("already set"));
let prev_none = {
let send = send.clone();
Callback::from(move |_| {
let new_prompt = ActionPrompt::Guardian {
character_id: super::identity(),
previous: None,
living_players: super::identities(20),
marked: None,
};
send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0));
})
};
let prot_disabled = previous.as_ref().and_then(|prev| match prev {
PreviousGuardianAction::Protect(_) => Some(String::from("already set")),
PreviousGuardianAction::Guard(_) => None,
});
let prev_prot = {
let send = send.clone();
Callback::from(move |_| {
let new_prompt = ActionPrompt::Guardian {
character_id: super::identity(),
previous: Some(PreviousGuardianAction::Protect(
super::identities(2).into_iter().nth(1).unwrap(),
)),
living_players: super::identities(20),
marked: None,
};
send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0));
})
};
let guard_disabled = previous.as_ref().and_then(|prev| match prev {
PreviousGuardianAction::Guard(_) => Some(String::from("already set")),
PreviousGuardianAction::Protect(_) => None,
});
let prev_guard = {
let send = send.clone();
Callback::from(move |_| {
let new_prompt = ActionPrompt::Guardian {
character_id: super::identity(),
previous: Some(PreviousGuardianAction::Guard(
super::identities(2).into_iter().nth(1).unwrap(),
)),
living_players: super::identities(20),
marked: None,
};
send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0));
})
};
html! {
<>
<label>{"previous protect"}</label>
<div class="option-set">
<Button disabled_reason={none_disabled} on_click={prev_none}>{"none"}</Button>
<Button disabled_reason={prot_disabled} on_click={prev_prot}>{"protected"}</Button>
<Button disabled_reason={guard_disabled} on_click={prev_guard}>{"guarded"}</Button>
</div>
</>
}
}
ActionPrompt::MasonsWake { masons, .. } => {
let dec_disabled =
(masons.len() == 1).then_some(String::from("already at min recruits"));
let dec = {
let current = masons.len();
let send = send.clone();
Callback::from(move |_| {
let new_prompt = ActionPrompt::MasonsWake {
leader: super::identity(),
masons: super::identities(current.saturating_sub(1)),
};
send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0));
})
};
let inc_disabled =
(masons.len() > 0xFF).then_some(String::from("already at max wolves"));
let inc = {
let current = masons.len();
let send = send.clone();
Callback::from(move |_| {
let new_prompt = ActionPrompt::MasonsWake {
leader: super::identity(),
masons: super::identities(current.saturating_add(1)),
};
send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0));
})
};
html! {
<div class="prompt-number">
<Button
on_click={dec}
disabled_reason={dec_disabled}
>
{"decrease"}
</Button>
<label>{masons.len()}{" masons"}</label>
<Button
on_click={inc}
disabled_reason={inc_disabled}
>
{"increase"}
</Button>
</div>
}
}
ActionPrompt::MasonLeaderRecruit { recruits_left, .. } => {
let dec_disabled =
(recruits_left.get() == 1).then_some(String::from("already at min recruits"));
let dec = {
let current = recruits_left.get();
let send = send.clone();
Callback::from(move |_| {
let new_prompt = ActionPrompt::MasonLeaderRecruit {
character_id: super::identity(),
recruits_left: NonZeroU8::new(current.saturating_sub(1)).unwrap(),
potential_recruits: identities(20),
marked: None,
};
send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0));
})
};
let inc_disabled =
(recruits_left.get() == 0xFF).then_some(String::from("already at max wolves"));
let inc = {
let current = recruits_left.get();
let send = send.clone();
Callback::from(move |_| {
let new_prompt = ActionPrompt::MasonLeaderRecruit {
character_id: super::identity(),
recruits_left: NonZeroU8::new(current.saturating_add(1)).unwrap(),
potential_recruits: identities(20),
marked: None,
};
send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0));
})
};
html! {
<div class="prompt-number">
<Button
on_click={dec}
disabled_reason={dec_disabled}
>
{"decrease"}
</Button>
<label>{recruits_left.get()}{" recruits"}</label>
<Button
on_click={inc}
disabled_reason={inc_disabled}
>
{"increase"}
</Button>
</div>
}
}
ActionPrompt::Protector { .. }
| ActionPrompt::Arcanist { .. }
| ActionPrompt::Gravedigger { .. }
| ActionPrompt::Militia { .. }
| ActionPrompt::Adjudicator { .. }
| ActionPrompt::PowerSeer { .. }
| ActionPrompt::Mortician { .. }
| ActionPrompt::Beholder { .. }
| ActionPrompt::Empath { .. }
| ActionPrompt::Vindicator { .. }
| ActionPrompt::PyreMaster { .. }
| ActionPrompt::WolfPackKill { .. }
| ActionPrompt::AlphaWolf { .. }
| ActionPrompt::DireWolf { .. }
| ActionPrompt::LoneWolfKill { .. }
| ActionPrompt::Bloodletter { .. }
| ActionPrompt::Seer { .. }
| ActionPrompt::ElderReveal { .. }
| ActionPrompt::TraitorIntro { .. }
| ActionPrompt::Insomniac { .. }
| ActionPrompt::Shapeshifter { .. }
| ActionPrompt::CoverOfDarkness => html! {},
};
let prev_page_disabled = (*page == 0).then_some(String::from("on page zero"));
let prev_page = {
let send = send.clone();
let prompt = prompt.clone();
let page = *page;
Callback::from(move |_| {
send.emit(ServerToHostMessage::ActionPrompt(
prompt.clone(),
page.saturating_sub(1),
));
})
};
let next_page_disabled = (*page + 1 >= prompt.role_pages(true).len())
.then_some(String::from("already at last page"));
let next_page = {
let send = send.clone();
let prompt = prompt.clone();
let page = *page;
Callback::from(move |_| {
send.emit(ServerToHostMessage::ActionPrompt(
prompt.clone(),
page.saturating_add(1),
));
})
};
html! {
<div class="prompt-test">
<Button on_click={back.clone()} classes={classes!("close")}>{"close"}</Button>
<label>{prompt.title().to_string()}</label>
<div class="prompt-number">
<Button
on_click={prev_page}
disabled_reason={prev_page_disabled}
>
{"previous page"}
</Button>
<span>{page.saturating_add(1)}{"/"}{prompt.role_pages(true).len().max(1)}</span>
<Button
on_click={next_page}
disabled_reason={next_page_disabled}
>
{"next page"}
</Button>
</div>
<div class="prompt-options">
{options}
</div>
</div>
}
}

View File

@ -0,0 +1,371 @@
// 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 yew::prelude::*;
use core::num::NonZeroU8;
use std::rc::Rc;
use web_sys::HtmlSelectElement;
use werewolves_proto::{
diedto::DiedToTitle,
message::{
host::{HostGameMessage, HostMessage, HostNightMessage, ServerToHostMessage},
night::{ActionPrompt, ActionResult, ActionResultTitle, Visits},
},
player::RoleChange,
role::{Alignment, PreviousGuardianAction, RoleTitle},
};
use crate::{components::Button, pages::RolePage, test_util::identities};
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct ResultScreenTestProps {
pub result: ActionResult,
pub send: Callback<ServerToHostMessage>,
pub back: Callback<()>,
}
#[function_component]
pub fn ResultScreenTest(
ResultScreenTestProps { result, send, back }: &ResultScreenTestProps,
) -> Html {
let options = match result {
ActionResult::BeholderSawNothing
| ActionResult::BeholderSawEverything
| ActionResult::GoBackToSleep
| ActionResult::ShiftFailed
| ActionResult::Continue
| ActionResult::Drunk
| ActionResult::RoleBlocked => html! {},
ActionResult::Seer(alignment) => {
let all = Alignment::ALL
.into_iter()
.map(|align| {
let on_click = {
let send = send.clone();
Callback::from(move |_| {
send.emit(ServerToHostMessage::ActionResult(
None,
ActionResult::Seer(align),
));
})
};
let disabled = (align == *alignment).then_some(String::from("already selected")) ;
html! {
<Button on_click={on_click} disabled_reason={disabled}>{align.to_string()}</Button>
}
})
.collect::<Html>();
html! {
<div class="prompt-options">
<div class="option-set">
{all}
</div>
</div>
}
}
ActionResult::PowerSeer { powerful } => {
let on_toggle = {
let set = !*powerful;
let send = send.clone();
Callback::from(move |_| {
send.emit(ServerToHostMessage::ActionResult(
None,
ActionResult::PowerSeer { powerful: set },
));
})
};
let text = if powerful.powerful() {
"make not powerful"
} else {
"make powerful"
};
html! {
<div class="prompt-options">
<Button on_click={on_toggle}>{text}</Button>
</div>
}
}
ActionResult::Adjudicator { killer } => {
let on_toggle = {
let set = !*killer;
let send = send.clone();
Callback::from(move |_| {
send.emit(ServerToHostMessage::ActionResult(
None,
ActionResult::Adjudicator { killer: set },
));
})
};
let text = if killer.killer() {
"make not killer"
} else {
"make killer"
};
html! {
<div class="prompt-options">
<Button on_click={on_toggle}>{text}</Button>
</div>
}
}
ActionResult::Arcanist(alignment_eq) => {
let on_toggle = {
let set = !*alignment_eq;
let send = send.clone();
Callback::from(move |_| {
send.emit(ServerToHostMessage::ActionResult(
None,
ActionResult::Arcanist(set),
));
})
};
let text = if alignment_eq.same() {
"make different"
} else {
"make same"
};
html! {
<div class="prompt-options">
<Button on_click={on_toggle}>{text}</Button>
</div>
}
}
ActionResult::GraveDigger(role_title) => {
let possibilities = [None]
.into_iter()
.chain(RoleTitle::ALL.into_iter().map(Some))
.collect::<Rc<[_]>>();
let roles = possibilities
.iter()
.map(|role| {
let text = role
.as_ref()
.map(|r| r.to_string())
.unwrap_or(String::from("empty grave"));
html! {
<option selected={role == role_title}>{text}</option>
}
})
.collect::<Html>();
let on_change_cb = {
let send = send.clone();
let possibilities = possibilities.clone();
Callback::from(move |ev: Event| {
if let Some(select) = ev.target_dyn_into::<HtmlSelectElement>() {
let selected = select.selected_index();
if selected == -1 {
return;
}
if let Some(new_role) = possibilities.get(selected as usize) {
send.emit(ServerToHostMessage::ActionResult(
None,
ActionResult::GraveDigger(*new_role),
));
}
}
})
};
let on_wheel = {
let send = send.clone();
let possibilities = possibilities.clone();
Callback::from(move |ev: WheelEvent| {
let Some(target) = ev.target_dyn_into::<HtmlSelectElement>() else {
return;
};
let index = target.selected_index();
let new_index = match ev.delta_y().total_cmp(&0.0) {
core::cmp::Ordering::Equal => return,
core::cmp::Ordering::Less => {
if index != 0 {
index - 1
} else {
(target.children().length() - 1) as i32
}
}
core::cmp::Ordering::Greater => {
if index + 1 < target.children().length() as i32 {
index + 1
} else {
0
}
}
};
target.set_selected_index(new_index);
if let Some(new_role) = possibilities.get(new_index as usize) {
send.emit(ServerToHostMessage::ActionResult(
None,
ActionResult::GraveDigger(*new_role),
));
}
})
};
html! {
<div class="result-option">
<label>{"dug up"}</label>
<select onwheel={on_wheel} onchange={on_change_cb}>
{roles}
</select>
</div>
}
}
ActionResult::Mortician(died_to_title) => {
let roles = DiedToTitle::ALL
.into_iter()
.map(|died_to| {
html! {
<option selected={died_to == *died_to_title}>{died_to.to_string()}</option>
}
})
.collect::<Html>();
let on_change_cb = {
let send = send.clone();
Callback::from(move |ev: Event| {
if let Some(select) = ev.target_dyn_into::<HtmlSelectElement>() {
let selected = select.selected_index();
if selected == -1 {
return;
}
if let Some(died_to) = DiedToTitle::ALL.into_iter().nth(selected as _) {
send.emit(ServerToHostMessage::ActionResult(
None,
ActionResult::Mortician(died_to),
));
}
}
})
};
let on_wheel = {
let send = send.clone();
Callback::from(move |ev: WheelEvent| {
let Some(target) = ev.target_dyn_into::<HtmlSelectElement>() else {
return;
};
let index = target.selected_index();
let new_index = match ev.delta_y().total_cmp(&0.0) {
core::cmp::Ordering::Equal => return,
core::cmp::Ordering::Less => {
if index != 0 {
index - 1
} else {
(target.children().length() - 1) as i32
}
}
core::cmp::Ordering::Greater => {
if index + 1 < target.children().length() as i32 {
index + 1
} else {
0
}
}
};
target.set_selected_index(new_index);
if let Some(died_to) = DiedToTitle::ALL.into_iter().nth(new_index as _) {
send.emit(ServerToHostMessage::ActionResult(
None,
ActionResult::Mortician(died_to),
));
}
})
};
html! {
<div class="prompt-option">
<label>{"died to"}</label>
<select onwheel={on_wheel} onchange={on_change_cb}>
{roles}
</select>
</div>
}
}
ActionResult::Insomniac(visits) => {
let dec_disabled =
(visits.len() <= 1).then_some(String::from("already have minimum visits"));
let dec = {
let current = visits.len();
let send = send.clone();
Callback::from(move |_| {
send.emit(ServerToHostMessage::ActionResult(
None,
ActionResult::Insomniac(Visits::new(super::identities(
current.saturating_sub(1).max(1),
))),
));
})
};
let inc_disabled =
(visits.len() + 1 > 0xFF).then_some(String::from("already at max visits"));
let inc = {
let current = visits.len();
let send = send.clone();
Callback::from(move |_| {
send.emit(ServerToHostMessage::ActionResult(
None,
ActionResult::Insomniac(Visits::new(super::identities(
current.saturating_add(1).max(1),
))),
));
})
};
html! {
<div class="result-number">
<Button
on_click={dec}
disabled_reason={dec_disabled}
>
{"decrease"}
</Button>
<label>{visits.len()}{" visits"}</label>
<Button
on_click={inc}
disabled_reason={inc_disabled}
>
{"increase"}
</Button>
</div>
}
}
ActionResult::Empath { scapegoat } => {
let on_toggle = {
let set = !*scapegoat;
let send = send.clone();
Callback::from(move |_| {
send.emit(ServerToHostMessage::ActionResult(
None,
ActionResult::Empath { scapegoat: set },
));
})
};
let text = if *scapegoat {
"make not scapegoat"
} else {
"make scapegoat"
};
html! {
<div class="prompt-options">
<Button on_click={on_toggle}>{text}</Button>
</div>
}
}
};
html! {
<div class="result-test">
<Button on_click={back.clone()} classes={classes!("close")}>{"close"}</Button>
<label>{result.title().to_string()}</label>
<div class="result-options">
{options}
</div>
</div>
}
}