overrides + arcanist fixes

This commit is contained in:
emilis 2026-02-22 23:15:03 +00:00
parent c8e51f36e2
commit 5a452b220c
No known key found for this signature in database
25 changed files with 1870 additions and 5389 deletions

1
.gitignore vendored
View File

@ -7,6 +7,7 @@ werewolves/img/icons.svg
license_headers.fish license_headers.fish
util/ util/
werewolves/Trunk-local.toml werewolves/Trunk-local.toml
public/img/icons.svg
werewolves-old-client/ werewolves-old-client/
werewolves-old-server/ werewolves-old-server/

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="69.549118mm"
height="56.160782mm"
viewBox="0 0 69.549118 56.160782"
version="1.1"
id="svg1"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><g
id="layer4"
transform="translate(-747.27464,-854.93728)"><g
id="g114"><g
id="g74-6-0"
transform="matrix(0.49535939,0,0,0.49535939,742.24755,744.64487)"><path
d="m 41.438814,253.85737 v -11.44064 c -0.10314,0.002 -0.206411,0.006 -0.309542,0.0114 -1.713539,0.0921 -3.402067,0.64565 -4.275191,1.61799 -1.746251,1.94469 -1.477015,6.95296 0.467672,8.69921 0.577742,0.51878 1.869333,0.91432 2.82205,1.08623 0.05301,0.04 0.6259,0.0432 1.295011,0.0258 z m 0,0 v -11.44064 c 0.10314,0.002 0.206411,0.006 0.309542,0.0114 1.713539,0.0921 3.402067,0.64565 4.275191,1.61799 1.746251,1.94469 1.477015,6.95296 -0.467672,8.69921 -0.577742,0.51878 -1.869333,0.91432 -2.82205,1.08623 -0.05301,0.04 -0.6259,0.0432 -1.295011,0.0258 z"
style="fill:#0f07ff;stroke:#0f07ff;stroke-width:0.646547"
id="path73-2-2" /><path
d="m 41.437781,281.85256 v -26.00719 c -0.537817,0.003 -1.074156,0.0344 -1.599386,0.0879 -2.633998,0.53128 -5.422716,2.05847 -6.299357,4.75888 -1.013707,3.1868 -0.65843,6.59772 -0.366903,9.8702 0.3233,3.36773 0.971622,6.70664 1.07487,10.09344 0.63992,0.8794 1.985837,0.77429 2.985348,0.97565 1.39519,0.12946 2.800457,0.21075 4.205428,0.22117 z m 0.0021,0 v -26.00719 c 0.537817,0.003 1.074156,0.0344 1.599386,0.0879 2.633998,0.53128 5.422716,2.05847 6.299357,4.75888 1.013707,3.1868 0.65843,6.59772 0.366903,9.8702 -0.3233,3.36773 -0.971622,6.70664 -1.07487,10.09344 -0.63992,0.8794 -1.985837,0.77429 -2.985348,0.97565 -1.39519,0.12946 -2.800457,0.21075 -4.205428,0.22117 z"
style="fill:#0f07ff;stroke:#0f07ff;stroke-width:0.646547"
id="path74-6-3" /></g><g
id="g74-6-0-0"
transform="matrix(0.49535939,0,0,0.49535939,780.79656,744.64437)"><path
d="m 41.438814,253.85737 v -11.44064 c -0.10314,0.002 -0.206411,0.006 -0.309542,0.0114 -1.713539,0.0921 -3.402067,0.64565 -4.275191,1.61799 -1.746251,1.94469 -1.477015,6.95296 0.467672,8.69921 0.577742,0.51878 1.869333,0.91432 2.82205,1.08623 0.05301,0.04 0.6259,0.0432 1.295011,0.0258 z m 0,0 v -11.44064 c 0.10314,0.002 0.206411,0.006 0.309542,0.0114 1.713539,0.0921 3.402067,0.64565 4.275191,1.61799 1.746251,1.94469 1.477015,6.95296 -0.467672,8.69921 -0.577742,0.51878 -1.869333,0.91432 -2.82205,1.08623 -0.05301,0.04 -0.6259,0.0432 -1.295011,0.0258 z"
style="fill:#0f07ff;stroke:#0f07ff;stroke-width:0.646547"
id="path73-2-2-3" /><path
d="m 41.437781,281.85256 v -26.00719 c -0.537817,0.003 -1.074156,0.0344 -1.599386,0.0879 -2.633998,0.53128 -5.422716,2.05847 -6.299357,4.75888 -1.013707,3.1868 -0.65843,6.59772 -0.366903,9.8702 0.3233,3.36773 0.971622,6.70664 1.07487,10.09344 0.63992,0.8794 1.985837,0.77429 2.985348,0.97565 1.39519,0.12946 2.800457,0.21075 4.205428,0.22117 z m 0.0021,0 v -26.00719 c 0.537817,0.003 1.074156,0.0344 1.599386,0.0879 2.633998,0.53128 5.422716,2.05847 6.299357,4.75888 1.013707,3.1868 0.65843,6.59772 0.366903,9.8702 -0.3233,3.36773 -0.971622,6.70664 -1.07487,10.09344 -0.63992,0.8794 -1.985837,0.77429 -2.985348,0.97565 -1.39519,0.12946 -2.800457,0.21075 -4.205428,0.22117 z"
style="fill:#0f07ff;stroke:#0f07ff;stroke-width:0.646547"
id="path74-6-3-0" /></g><g
id="g46-3"
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.5;stroke-dasharray:none;stroke-opacity:1"
transform="translate(89.910176,11.519392)"><path
style="fill:none;fill-opacity:1;stroke:#fff800;stroke-width:0.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:0.595237"
d="m 688.06805,869.55518 -15.10948,-20.06719 -15.30685,19.99488"
id="path46-0-6" /><path
style="fill:none;fill-opacity:1;stroke:#fff800;stroke-width:0.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:0.595237"
d="m 672.95522,849.48086 -0.008,20.1008"
id="path46-5" /></g><path
style="fill:#fffc82;fill-opacity:1;stroke:#67653e;stroke-width:0.734271;stroke-opacity:1"
id="path45-6"
d="m 777.90756,881.06818 a 15.132905,8.016284 0 0 1 -7.56645,6.9423 15.132905,8.016284 0 0 1 -15.1329,0 15.132905,8.016284 0 0 1 -7.56646,-6.9423 h 15.13291 z" /><g
id="g46-3-6"
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.5;stroke-dasharray:none;stroke-opacity:1"
transform="translate(128.45917,11.519442)"><path
style="fill:none;fill-opacity:1;stroke:#fff800;stroke-width:0.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:0.595237"
d="m 688.06805,869.55518 -15.10948,-20.06719 -15.30685,19.99488"
id="path46-0-6-1" /><path
style="fill:none;fill-opacity:1;stroke:#fff800;stroke-width:0.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:0.595237"
d="m 672.95522,849.48086 -0.008,20.1008"
id="path46-5-1" /></g><path
style="fill:#fffc82;fill-opacity:1;stroke:#67653e;stroke-width:0.734271;stroke-opacity:1"
id="path45-6-5"
d="m 816.45657,881.06824 a 15.132905,8.016284 0 0 1 -7.56645,6.9423 15.132905,8.016284 0 0 1 -15.1329,0 15.132905,8.016284 0 0 1 -7.56646,-6.9423 h 15.13291 z" /><rect
style="fill:#fffa65;fill-opacity:1;stroke:#67653e;stroke-width:1;stroke-opacity:1"
id="rect31-9"
width="59.770382"
height="2.958041"
x="752.13153"
y="858.67627"
rx="11.1125"
ry="1.0782195" /><path
d="m 781.92294,855.43738 c -1.08829,0 -1.96422,1.53814 -1.96422,3.44888 v 46.49773 c 0,0.0628 7.2e-4,0.12512 0.003,0.18707 h -10.46448 c -6.15632,0 -11.1125,1.12104 -11.1125,2.51354 0,1.3925 -0.0661,2.51355 -0.0661,2.51355 h 47.42657 v -2.51355 c 0,-1.3925 -4.95617,-2.51354 -11.1125,-2.51354 h -10.5606 c 0.002,-0.062 0.003,-0.12426 0.003,-0.18707 v -46.49773 c 0,-1.91074 -0.87593,-3.44888 -1.96422,-3.44888 z"
style="fill:#fff965;fill-opacity:1;stroke:#67653e;stroke-opacity:1"
id="path33-0" /></g></g></svg>

After

Width:  |  Height:  |  Size: 6.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 866 KiB

View File

@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="81.261642mm"
height="66.812714mm"
viewBox="0 0 81.261642 66.812714"
version="1.1"
id="svg1"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><g
id="layer4"
transform="translate(-658.32806,-803.4284)"><g
id="g111"><g
id="g112"><g
id="g106"
transform="translate(-1.7773424,0.18708867)"><g
id="g100"
transform="translate(7.1414687,-15.751776)"><g
id="g1-3"
transform="matrix(0.44604857,0,0,0.44604857,607.75722,785.83144)"><path
id="path148-7"
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
style="fill:#fff001;fill-opacity:1;stroke:#ff2424;stroke-width:0.15;stroke-dasharray:none;stroke-opacity:1"
id="path150-4"
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
style="fill:#fff001;fill-opacity:1;stroke:#ff2424;stroke-width:0.15;stroke-dasharray:none;stroke-opacity:1"
id="path151-5"
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><g
id="g46"
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.5;stroke-dasharray:none;stroke-opacity:1"
transform="translate(-4.4004781,0.4458306)"><path
style="fill:none;fill-opacity:1;stroke:#fff800;stroke-width:0.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:0.595237"
d="m 688.06805,869.55518 -15.10948,-20.06719 -15.30685,19.99488"
id="path46-0" /><path
style="fill:none;fill-opacity:1;stroke:#fff800;stroke-width:0.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:0.595237"
d="m 672.95522,849.48086 -0.008,20.1008"
id="path46" /></g><path
style="fill:#fffc82;fill-opacity:1;stroke:#67653e;stroke-width:0.734271;stroke-opacity:1"
id="path45"
d="m 683.59689,869.99463 a 15.132905,8.016284 0 0 1 -7.56645,6.9423 15.132905,8.016284 0 0 1 -15.1329,0 15.132905,8.016284 0 0 1 -7.56646,-6.9423 h 15.13291 z" /></g><g
id="g104"
transform="translate(-5.4255714,1.122532)"><g
id="g97"
transform="rotate(-107.4873,716.6014,857.88295)"><path
d="m 741.45011,864.47313 c -0.61965,0.46041 -4.08011,3.1101 -5.51427,5.86399 l 1.64993,3.62001 -2.43068,-1.72258 c -0.29115,0.61767 -1.3281,3.67079 -1.5476,4.52025 l 1.45896,3.01928 -1.86206,-1.31473 c -0.18852,0.80806 -0.54956,4.17462 -0.55848,5.07496 l 2.31516,2.45068 -2.25264,-0.65679 c 0.13308,2.85064 1.26,5.03306 2.5083,5.34423 0.15431,0.0384 0.31307,0.0535 0.47654,0.045 0.14493,0.0761 0.29668,0.12925 0.45291,0.15891 1.26393,0.23992 3.20053,-1.27052 4.51461,-3.8037 l -2.32328,-0.3465 3.1284,-1.25554 c 0.36875,-0.82142 1.45023,-4.03153 1.61725,-4.8443 l -2.23934,0.41316 2.58842,-2.12932 c 0.15619,-0.86335 0.49096,-4.07031 0.48509,-4.75312 l -2.92603,0.54524 3.01258,-2.59499 c -0.14977,-3.10134 -2.18373,-6.95671 -2.55377,-7.63423 z"
style="fill:#ededed;stroke:#676767;stroke-width:0.507712;stroke-opacity:1"
id="path85" /><path
style="fill:none;stroke:#666666;stroke-width:1;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="m 739.51041,895.87916 c -0.77616,1.36482 -6.52512,-3.59608 0.85657,-25.75538"
id="path60" /></g><g
id="g46-5"
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.5;stroke-dasharray:none;stroke-opacity:1"
transform="translate(56.871733,-35.227408)"><path
style="fill:none;fill-opacity:1;stroke:#fff800;stroke-width:0.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:0.596078"
d="m 688.06805,869.55518 -15.10948,-28.79844 -15.30685,28.72613"
id="path46-0-4" /><path
style="fill:none;fill-opacity:1;stroke:#fff800;stroke-width:0.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:0.596078"
d="m 672.95522,840.74961 -0.008,28.83205"
id="path46-7" /></g><path
style="fill:#fffc82;fill-opacity:1;stroke:#67653e;stroke-width:0.734271;stroke-opacity:1"
id="path45-15"
d="m 744.86905,834.32141 a 15.132905,8.016284 0 0 1 -7.56645,6.94231 15.132905,8.016284 0 0 1 -15.13291,0 15.132905,8.016284 0 0 1 -7.56645,-6.94231 h 15.13291 z" /></g><rect
style="fill:#fffa65;fill-opacity:1;stroke:#67653e;stroke-width:1;stroke-opacity:1"
id="rect31"
width="59.770382"
height="2.958041"
x="167.49957"
y="1058.3093"
rx="11.1125"
ry="1.0782195"
transform="rotate(-30)" /></g><path
d="m 700.74059,814.58039 c -1.08829,0 -1.96422,1.53814 -1.96422,3.44888 V 864.527 c 0,0.0628 7.2e-4,0.12512 0.003,0.18707 h -10.46448 c -6.15632,0 -11.1125,1.12104 -11.1125,2.51354 0,1.3925 -0.0661,2.51355 -0.0661,2.51355 h 47.42657 v -2.51355 c 0,-1.3925 -4.95617,-2.51354 -11.1125,-2.51354 h -10.5606 c 0.002,-0.062 0.003,-0.12426 0.003,-0.18707 v -46.49773 c 0,-1.91074 -0.87593,-3.44888 -1.96422,-3.44888 z"
style="fill:#fff965;fill-opacity:1;stroke:#67653e;stroke-opacity:1"
id="path33" /></g></g></g></svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@ -17,4 +17,5 @@
.icon-shrink { .icon-shrink {
flex-shrink: 1; flex-shrink: 1;
height: 1em;
} }

View File

@ -185,6 +185,10 @@ nav.header {
white-space: nowrap; white-space: nowrap;
align-items: center; align-items: center;
&[hidden] {
display: none;
}
font-size: 1.5em; font-size: 1.5em;
.username { .username {
@ -292,6 +296,23 @@ dialog::backdrop {
} }
} }
.host-in-game-wrapper {
z-index: 1;
background-color: black;
height: 100vh;
width: 100vw;
margin: 0;
position: fixed;
display: block;
top: 0;
left: 0;
&>div,
&>nav {
padding: 1ch;
}
}
#change-password, #change-password,
#update-profile { #update-profile {
.pwless-notice { .pwless-notice {
@ -999,6 +1020,10 @@ form {
gap: 0.5ch; gap: 0.5ch;
padding-bottom: 1ch; padding-bottom: 1ch;
max-height: 75vh;
overflow-y: scroll;
scrollbar-width: thin;
.character { .character {
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
@ -1028,3 +1053,118 @@ form {
} }
} }
} }
.overrides-screen {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 10px;
align-items: center;
width: 80vw;
margin-left: 10vw;
margin-right: 10vw;
.override-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:not(:hover) {
color: white;
}
gap: 10px;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
max-width: 80vw;
.overrides-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-overrides,
.result-overrides {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
min-width: 40vw;
align-items: center;
.close {
width: 100%;
}
font-size: 2em;
.result-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;
}
}
.prompt-number {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 1ch;
button {
padding-left: 0.5ch;
padding-right: 0.5ch;
}
}
}

View File

@ -38,6 +38,8 @@
font-size: 1.75em; font-size: 1.75em;
height: 100%; height: 100%;
max-height: 100%;
max-width: 100%;
.subtext { .subtext {
font-size: 1.5rem; font-size: 1.5rem;
@ -71,6 +73,9 @@
font-size: 2em; font-size: 2em;
font-weight: bold; font-weight: bold;
display: block; display: block;
max-width: 100%;
overflow: hidden;
min-height: 2ch;
} }
@ -123,17 +128,53 @@
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5ch; gap: 0.5ch;
row-gap: 0px;
width: 100%; width: 100%;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
.icon-fit {
padding: 0px;
}
} }
.bool-picker {
width: calc(100% - 6ch);
height: calc(100% - 6ch);
display: flex;
flex-direction: row;
flex-wrap: nowrap;
padding: 3ch;
gap: 3ch;
&>button {
font-size: 3em;
width: 30vw;
flex-grow: 1;
background-color: color.change($red1, $alpha: 0.1);
border: 1px solid color.change($red1, $alpha: 0.6);
&:hover {
background-color: color.change($blue1, $alpha: 0.3);
border: 1px solid $blue1;
}
}
}
.target-picker { .target-picker {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
&.allow-scroll {
max-height: 70vh;
overflow-y: scroll;
scrollbar-width: thin;
justify-content: unset;
}
height: 100%; height: 100%;
font-size: 2em; font-size: 2em;
@ -161,6 +202,12 @@
flex-grow: 1; flex-grow: 1;
flex-shrink: 1; flex-shrink: 1;
gap: 10%; gap: 10%;
@media only screen and (min-width : 1200px) {
&>img {
height: auto;
}
}
} }
.two-column { .two-column {

View File

@ -78,6 +78,8 @@ decl_icon!(
Mason: "/img/mason.svg", Mason: "/img/mason.svg",
NotEqual: "/img/not-equal.svg", NotEqual: "/img/not-equal.svg",
Equal: "/img/equal.svg", Equal: "/img/equal.svg",
UnbalancedScales: "/img/unbalanced-scales.svg",
BalancedScales: "/img/balanced-scales.svg",
RedX: "/img/red-x.svg", RedX: "/img/red-x.svg",
Damned: "/img/damned.svg", Damned: "/img/damned.svg",
Bloodlet: "/img/bloodlet.svg", Bloodlet: "/img/bloodlet.svg",

View File

@ -0,0 +1,96 @@
use core::ops::{Add, AddAssign, Not, RangeInclusive, SubAssign};
use leptos::prelude::*;
#[component]
pub fn IncDecU8(
value: RwSignal<u8>,
#[prop(default = 0..=0xFFu8)] value_range: RangeInclusive<u8>,
) -> impl IntoView {
let dec_disabled = {
let value_range = value_range.clone();
move || !value_range.contains(&value.get().saturating_sub(1))
};
view! {
<div class="inc-dec">
<button
on:click=move |_| value.set(value.get().saturating_sub(1))
disabled=dec_disabled
>
"-"
</button>
<span class="value">{move || value.get()}</span>
<button
on:click=move |_| value.set(value.get().saturating_add(1))
disabled=move || !value_range.contains(&value.get().saturating_add(1))
>
"+"
</button>
</div>
}
}
pub trait Increment: Copy + AddAssign<Self> {
fn increment(self) -> Self;
}
pub trait Decrement: Copy + SubAssign<Self> {
fn decrement(self) -> Self;
}
macro_rules! inc_dec_impl {
($($n:ty),*) => {
$(
impl Increment for $n {
fn increment(self) -> Self {
self.saturating_add(1)
}
}
impl Decrement for $n {
fn decrement(self) -> Self {
self.saturating_sub(1)
}
}
)*
};
}
inc_dec_impl!(
u8, u16, u32, u64, u128, usize, i8, i16, i32, i64, i128, isize
);
#[component]
pub fn IncDec<V>(
value: RwSignal<V>,
#[prop(default = V::default()..=V::default().not())] value_range: RangeInclusive<V>,
) -> impl IntoView
where
V: Increment
+ Decrement
+ Eq
+ Ord
+ Not<Output = V>
+ Default
+ Send
+ Sync
+ ToString
+ 'static,
{
let dec_disabled = {
let value_range = value_range.clone();
move || !value_range.contains(&value.get().decrement())
};
view! {
<div class="inc-dec">
<button on:click=move |_| value.set(value.get().decrement()) disabled=dec_disabled>
"-"
</button>
<span class="value">{move || value.get().to_string()}</span>
<button
on:click=move |_| value.set(value.get().increment())
disabled=move || !value_range.contains(&value.get().increment())
>
"+"
</button>
</div>
}
}

View File

@ -4,11 +4,17 @@ use leptos::{ev::MouseEvent, prelude::*};
use leptos_router::hooks::use_url; use leptos_router::hooks::use_url;
use reactive_stores::Store; use reactive_stores::Store;
use uuid::Uuid; use uuid::Uuid;
use werewolves_proto::{error::ServerError, game::GameId, token::TokenString}; use werewolves_proto::{
error::ServerError,
game::GameId,
message::host::{HostGameMessage, HostMessage, HostNightMessage},
token::TokenString,
};
use crate::{ use crate::{
app::{ app::{
components::LinkButton, components::LinkButton,
pages::host::HostPage,
storage::user::{AuthContext, AuthContextStoreFields}, storage::user::{AuthContext, AuthContextStoreFields},
}, },
db::AppState, db::AppState,
@ -140,3 +146,46 @@ fn is_big_screen_path(path: &str) -> bool {
&& Uuid::parse_str(parts[1]).is_ok() && Uuid::parse_str(parts[1]).is_ok()
&& parts[2] == "big" && parts[2] == "big"
} }
#[component]
pub fn HostInGameNav(
reply: WriteSignal<Option<HostMessage>>,
night: ReadSignal<bool>,
show_back_button_only: RwSignal<bool>,
overrides_set: RwSignal<bool>,
) -> impl IntoView {
let previous = move |_| reply.set(Some(HostMessage::InGame(HostGameMessage::PreviousState)));
let skip = move |_| {
reply.set(Some(HostMessage::InGame(HostGameMessage::Night(
HostNightMessage::SkipAction,
))))
};
let back = move |_| {
show_back_button_only.set(false);
overrides_set.set(false);
reply.set(Some(HostMessage::GetState));
};
let hide_if_not_back_button = move || show_back_button_only.get();
let hide_if_not_night = move || show_back_button_only.get() || !night.get();
view! {
<nav class="header" hidden=hide_if_not_night>
<button on:click=back hidden=move || !show_back_button_only.get()>
"back"
</button>
<button on:click=previous hidden=hide_if_not_night>
"previous"
</button>
<button
on:click=move |_| {
overrides_set.set(true);
}
hidden=move || show_back_button_only.get() || !night.get()
>
"overrides"
</button>
<button on:click=skip hidden=hide_if_not_night>
"skip"
</button>
</nav>
}
}

View File

@ -1,5 +1,5 @@
pub mod big; pub mod big;
mod host; pub mod host;
mod player; mod player;
use codee::binary::MsgpackSerdeCodec; use codee::binary::MsgpackSerdeCodec;
@ -10,6 +10,7 @@ use leptos_use::{
use_websocket_with_options, use_websocket_with_options,
}; };
use reactive_stores::Store; use reactive_stores::Store;
use werewolves_proto::message::host::ServerToHostMessage;
use werewolves_proto::message::{ClientMessage, host::HostMessage}; use werewolves_proto::message::{ClientMessage, host::HostMessage};
use werewolves_proto::message::{IntoClientResponse, WrappedServerMessage}; use werewolves_proto::message::{IntoClientResponse, WrappedServerMessage};
@ -91,7 +92,18 @@ pub fn GamePage(error: WriteSignal<Option<WolfError>>) -> impl IntoView {
} }
match message.clone() { match message.clone() {
Some(IntoClientResponse::Host(host_msg)) => { Some(IntoClientResponse::Host(host_msg)) => {
log::debug!("got host message: {:?}", host_msg.title()); match &host_msg {
ServerToHostMessage::ActionPrompt(prompt, page) => {
log::debug!(
"got host message: ActionPrompt({page}, {:?})",
prompt.title()
)
}
ServerToHostMessage::ActionResult(_, result) => {
log::debug!("got host message: ActionResult({:?})", result.title())
}
_ => log::debug!("got host message: {:?}", host_msg.title()),
};
host_message.set(Some(host_msg)); host_message.set(Some(host_msg));
} }
Some(IntoClientResponse::Player(player_msg)) => { Some(IntoClientResponse::Player(player_msg)) => {

View File

@ -236,6 +236,7 @@ pub fn BigScreen() -> impl IntoView {
<div class:big-screen-wrapper=move || { <div class:big-screen-wrapper=move || {
!matches!(page.get(), BigScreenPage::Setup) !matches!(page.get(), BigScreenPage::Setup)
}>{content}</div> }>{content}</div>
}.into_any() }
.into_any()
} }
} }

View File

@ -1,6 +1,9 @@
werewolves_macros::include_path!("werewolves/src/app/pages/game/host"); werewolves_macros::include_path!("werewolves/src/app/pages/game/host");
mod overrides {
werewolves_macros::include_path!("werewolves/src/app/pages/game/host/overrides");
}
use core::num::NonZeroU8; use core::{num::NonZeroU8, ops::Not};
use std::collections::HashMap; use std::collections::HashMap;
use leptos::prelude::*; use leptos::prelude::*;
@ -16,13 +19,17 @@ use werewolves_proto::{
use crate::app::{ use crate::app::{
Preferences, Preferences,
components::DialogModal, components::{DialogModal, HostInGameNav},
pages::night_actions::{RolePrompt, RoleResult}, pages::{
game::host::overrides::Overrides,
host::overrides::OverrideScreen,
night_actions::{RolePrompt, RoleResult},
},
}; };
use crate::{ConsoleLogError, app::error::WolfError}; use crate::{ConsoleLogError, app::error::WolfError};
#[derive(Debug, Clone, PartialEq, Default)] #[derive(Debug, Clone, PartialEq, Default)]
enum HostPage { pub enum HostPage {
#[default] #[default]
None, None,
Settings, Settings,
@ -56,6 +63,13 @@ pub fn HostGamePage(
let players: RwSignal<Box<[PlayerState]>> = RwSignal::new(Box::new([])); let players: RwSignal<Box<[PlayerState]>> = RwSignal::new(Box::new([]));
let dialog_open = RwSignal::new(HashMap::new()); let dialog_open = RwSignal::new(HashMap::new());
let acks: RwSignal<Box<[RoleRevealCharacter]>> = RwSignal::new(Box::new([])); let acks: RwSignal<Box<[RoleRevealCharacter]>> = RwSignal::new(Box::new([]));
let night = RwSignal::new(false);
let show_back_button_only = RwSignal::new(false);
let on_overrides_screen = RwSignal::new(false);
let overrides_screen: RwSignal<Option<OverrideScreen>> = RwSignal::new(None);
let override_page_number = RwSignal::new(0usize);
let prompt_page = RwSignal::new(0usize);
let target_picker_count = RwSignal::new(16u8);
let open_categories = RwSignal::new( let open_categories = RwSignal::new(
Category::ALL Category::ALL
@ -115,6 +129,7 @@ pub fn HostGamePage(
page.set(HostPage::RoleRevealAcks); page.set(HostPage::RoleRevealAcks);
} }
Srv2Host::ActionPrompt(prompt, prompt_page) => { Srv2Host::ActionPrompt(prompt, prompt_page) => {
override_page_number.set(prompt_page);
page.set(HostPage::ActionPrompt { page.set(HostPage::ActionPrompt {
prompt, prompt,
page: prompt_page, page: prompt_page,
@ -146,7 +161,27 @@ pub fn HostGamePage(
.is_some() .is_some()
.then_some(view! { <CancelGame reply=reply prefs=prefs /> }) .then_some(view! { <CancelGame reply=reply prefs=prefs /> })
}; };
let content = move || match page.get() { Effect::new(move || match page.get() {
HostPage::None
| HostPage::Settings
| HostPage::RoleRevealAcks
| HostPage::Daytime { .. } => night.set(false),
HostPage::ActionPrompt { .. } | HostPage::ActionResult { .. } => night.set(true),
});
let content = move || {
if on_overrides_screen.get() {
show_back_button_only.set(true);
return view! {
<Overrides
reply=reply
screen=overrides_screen
page=prompt_page
target_picker_count=target_picker_count
/>
}
.into_any();
}
match page.get() {
HostPage::None => ().into_any(), HostPage::None => ().into_any(),
HostPage::Settings => view! { HostPage::Settings => view! {
<Settings <Settings
@ -188,14 +223,39 @@ pub fn HostGamePage(
characters, characters,
marked, marked,
reply, reply,
} => view! { } => {
<DaytimePlayerList day=day characters=characters marked=marked reply=reply/> view! { <DaytimePlayerList day=day characters=characters marked=marked reply=reply /> }
.into_any()
}
}
};
move || match page.get() {
HostPage::Settings | HostPage::None => view! {
{cancel}
{content}
} }
.into_any(), .into_any(),
}; HostPage::RoleRevealAcks
view! { | HostPage::ActionPrompt { .. }
{cancel} | HostPage::ActionResult { .. }
{content} | HostPage::Daytime { .. } => {
let cancel = matches!(&*page.read(), HostPage::Daytime { .. })
.not()
.then_some(cancel);
view! {
<div class="host-in-game-wrapper">
<HostInGameNav
reply=reply
night=night.read_only()
show_back_button_only=show_back_button_only
overrides_set=on_overrides_screen
/>
<div>{cancel} {content}</div>
</div>
}
.into_any()
}
} }
} }

View File

@ -65,7 +65,7 @@ pub fn DaytimePlayerList(
.collect::<Box<[_]>>(); .collect::<Box<[_]>>();
kills.is_empty().not().then_some(view! { kills.is_empty().not().then_some(view! {
<div class="info-tidbit"> <div class="info-tidbit">
<label>{"died last night"}</label> <span class="breakable">"died last night"</span>
<div class="last-nights-kills">{kills.into_iter().collect_view()}</div> <div class="last-nights-kills">{kills.into_iter().collect_view()}</div>
</div> </div>
}) })
@ -127,10 +127,7 @@ pub fn DaytimePlayerList(
<DialogModal button_content=button_text.clone()> <DialogModal button_content=button_text.clone()>
<h3>{confirmation_text.clone()}</h3> <h3>{confirmation_text.clone()}</h3>
<button on:click=move |_| { <button on:click=move |_| {
reply reply.set(Some(HostMessage::InGame(HostGameMessage::Day(HostDayMessage::Execute))))
.set(
Some(HostMessage::InGame(HostGameMessage::Day(HostDayMessage::Execute))),
)
}>{button_text.clone()}</button> }>{button_text.clone()}</button>
</DialogModal> </DialogModal>
}; };
@ -174,7 +171,12 @@ fn DaytimePlayer(
let text = role.to_string().to_case(Case::Title); let text = role.to_string().to_case(Case::Title);
let align_class = role.wolf().then_some("red"); let align_class = role.wolf().then_some("red");
view! { view! {
<button on:click=select class="character no-hover" class:dead=died_to.is_some() class:marked=marked> <button
on:click=select
class="character no-hover"
class:dead=died_to.is_some()
class:marked=marked
>
<div class="day-char"> <div class="day-char">
<span class="headline"> <span class="headline">
<Icon source=icon r#type=IconType::Small /> <Icon source=icon r#type=IconType::Small />

View File

@ -0,0 +1,452 @@
// Copyright (C) 2025-2026 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;
use leptos::prelude::*;
use werewolves_proto::message::host::HostMessage;
use werewolves_proto::{
character::CharacterId,
diedto::DiedToTitle,
message::{
CharacterIdentity,
host::ServerToHostMessage,
night::{ActionPrompt, ActionPromptTitle, ActionResult, ActionResultTitle, Visits},
},
role::{Alignment, AlignmentEq, Killer, Powerful, RoleTitle},
};
use crate::app::class::AsClasses;
use crate::app::components::IncDecU8;
use crate::app::pages::game::host::overrides::{PromptScreenTest, ResultScreenTest};
#[component]
pub fn Overrides(
reply: WriteSignal<Option<HostMessage>>,
screen: RwSignal<Option<OverrideScreen>>,
page: RwSignal<usize>,
target_picker_count: RwSignal<u8>,
) -> impl IntoView {
let internal_send: RwSignal<Option<ServerToHostMessage>> = RwSignal::new(None);
let prompt: RwSignal<Option<ActionPrompt>> = RwSignal::new(None);
Effect::new(move || {
let Some(msg) = internal_send.get() else {
return;
};
match &msg {
ServerToHostMessage::ActionPrompt(prompt, p) => {
page.set(*p);
screen.set(Some(OverrideScreen::Prompt(prompt.clone())))
}
ServerToHostMessage::ActionResult(_, result) => {
screen.set(Some(OverrideScreen::Result(result.clone())))
}
_ => {}
}
reply.set(Some(HostMessage::Echo(msg)));
});
Effect::new(move || {
if let Some(OverrideScreen::Prompt(p)) = screen.get() {
prompt.set(Some(p));
}
});
let screen_opts = move || {
screen.get().map(|screen| match screen {
OverrideScreen::Prompt(p) => {
view! { <PromptScreenTest prompt=prompt page=page send=internal_send.write_only() /> }
.into_any()
}
OverrideScreen::Result(result) => {
view! { <ResultScreenTest result=result.clone() send=internal_send.write_only() /> }
.into_any()
}
OverrideScreen::TargetPicker(_) => {
Effect::new(move || reply.set(Some(HostMessage::Echo(ServerToHostMessage::ActionPrompt(ActionPrompt::WolfPackKill { living_villagers: identities(target_picker_count.get() as _), marked: None }, usize::MAX)))));
view! {
<span>"targets"</span>
<IncDecU8 value=target_picker_count value_range=1..=0xFFu8 />
}.into_any()
},
})
};
view! {
<div class="overrides-screen">
<TestScreenSelector screen=screen send=internal_send.write_only() />
<button on:click=move |_| {
screen.set(Some(OverrideScreen::TargetPicker(identities(16))))
}>"target picker"</button>
{screen_opts}
</div>
}
}
#[component]
pub fn TestScreenSelector(
screen: RwSignal<Option<OverrideScreen>>,
send: WriteSignal<Option<ServerToHostMessage>>,
) -> impl IntoView {
let prompts = move || {
ActionPromptTitle::ALL
.into_iter()
.map(|title| {
let OverrideScreen::Prompt(prompt) = Into::<OverrideScreen>::into(title) else {
unreachable!()
};
let picked_class = if let Some(OverrideScreen::Prompt(current)) = screen.get()
&& current.title() == title
{
Some("selected")
} else {
None
};
let callback = move |_| {
let OverrideScreen::Prompt(prompt) = Into::<OverrideScreen>::into(title) else {
unreachable!()
};
screen.set(Some(OverrideScreen::Prompt(prompt.clone())));
send.set(Some(ServerToHostMessage::ActionPrompt(prompt, 0)));
};
let class = prompt_class(&prompt);
view! {
<li>
<button
on:click=callback
class=[class, picked_class, Some("hover"), Some("box")].as_classes()
>
{format!("{title:?}")}
</button>
</li>
}
})
.collect_view()
};
let results = move || {
ActionResultTitle::ALL
.into_iter()
.filter(|title| !matches!(title, ActionResultTitle::Continue))
.map(|title| {
let OverrideScreen::Result(result) = Into::<OverrideScreen>::into(title) else {
unreachable!()
};
let picked_class = if let Some(OverrideScreen::Result(current)) = screen.get()
&& current.title() == title
{
Some("selected")
} else {
None
};
let callback = move |_| {
let OverrideScreen::Result(result) = Into::<OverrideScreen>::into(title) else {
unreachable!()
};
screen.set(Some(OverrideScreen::Result(result.clone())));
send.set(Some(ServerToHostMessage::ActionResult(None, result)));
};
let class = result_class(&result);
view! {
<li>
<button
on:click=callback
class=[picked_class, class, Some("hover"), Some("box")].as_classes()
>
{format!("{title:?}")}
</button>
</li>
}
})
.collect_view()
};
view! {
<div class="override-selector">
<div class="category">
<span>"prompts"</span>
<ul class="overrides-screens">{prompts}</ul>
</div>
<div class="category">
<span>"results"</span>
<ul class="overrides-screens">{results}</ul>
</div>
</div>
}
}
pub(super) 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()
}
pub(super) fn identity() -> CharacterIdentity {
identities(1).into_iter().next().unwrap()
}
#[derive(Debug, Clone, PartialEq)]
pub enum OverrideScreen {
Prompt(ActionPrompt),
Result(ActionResult),
TargetPicker(Box<[CharacterIdentity]>),
}
impl From<ActionResultTitle> for OverrideScreen {
fn from(value: ActionResultTitle) -> Self {
OverrideScreen::Result(match value {
ActionResultTitle::SkippedByHost => ActionResult::SkippedByHost,
ActionResultTitle::RoleBlocked => ActionResult::RoleBlocked,
ActionResultTitle::Drunk => ActionResult::Drunk,
ActionResultTitle::Seer => ActionResult::Seer(identity(), Alignment::Village),
ActionResultTitle::PowerSeer => ActionResult::PowerSeer {
target: identity(),
powerful: Powerful::Powerful,
},
ActionResultTitle::Adjudicator => ActionResult::Adjudicator {
target: identity(),
killer: Killer::Killer,
},
ActionResultTitle::Arcanist => ActionResult::Arcanist(
(identity(), identities(2).last().cloned().unwrap()),
AlignmentEq::Same,
),
ActionResultTitle::GraveDigger => ActionResult::GraveDigger(identity(), None),
ActionResultTitle::Mortician => {
ActionResult::Mortician(identity(), DiedToTitle::Execution)
}
ActionResultTitle::Insomniac => ActionResult::Insomniac(Visits::new(identities(2))),
ActionResultTitle::Empath => ActionResult::Empath {
target: identity(),
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 OverrideScreen {
fn from(value: ActionPromptTitle) -> Self {
Self::Prompt(match value {
ActionPromptTitle::BeholderWakes => ActionPrompt::BeholderWakes {
character_id: identity(),
},
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(),
nights_til_starvation: 0,
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::BeholderChooses => ActionPrompt::BeholderChooses {
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::DamnedIntro => ActionPrompt::DamnedIntro {
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
| ActionResultTitle::SkippedByHost => None,
}
}
fn prompt_class(prompt: &ActionPrompt) -> Option<&'static str> {
match prompt {
ActionPrompt::ElderReveal { .. }
| ActionPrompt::RoleChange { .. }
| ActionPrompt::CoverOfDarkness => None,
ActionPrompt::BeholderWakes { .. }
| ActionPrompt::Seer { .. }
| ActionPrompt::Arcanist { .. }
| ActionPrompt::Gravedigger { .. }
| ActionPrompt::Adjudicator { .. }
| ActionPrompt::PowerSeer { .. }
| ActionPrompt::Mortician { .. }
| ActionPrompt::BeholderChooses { .. }
| 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::DamnedIntro { .. } => Some("damned"),
}
}

View File

@ -0,0 +1,378 @@
use convert_case::{Case, Casing};
use gloo::dialogs::prompt;
// Copyright (C) 2025-2026 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 leptos::{
ev::{Event, Targeted, WheelEvent},
prelude::*,
web_sys::HtmlSelectElement,
};
use core::num::NonZeroU8;
use werewolves_proto::{
message::{host::ServerToHostMessage, night::ActionPrompt},
role::{PreviousGuardianAction, RoleTitle},
};
use crate::app::{
components::IncDecU8,
pages::{game::host::overrides, night_actions},
};
#[component]
pub fn PromptScreenTest(
prompt: RwSignal<Option<ActionPrompt>>,
page: RwSignal<usize>,
send: WriteSignal<Option<ServerToHostMessage>>,
) -> impl IntoView {
let options = move || {
let prompt_signal = prompt;
let Some(prompt) = prompt.get() else {
return ().into_any();
};
match prompt {
ActionPrompt::WolvesIntro { wolves } => {
let new_count = RwSignal::new(wolves.len() as u8);
Effect::new(move || {
let new_prompt = ActionPrompt::WolvesIntro {
wolves: overrides::identities(new_count.get() as _)
.into_iter()
.zip(RoleTitle::ALL.into_iter().filter(|w| w.wolf()).cycle())
.collect(),
};
send.set(Some(ServerToHostMessage::ActionPrompt(
new_prompt.clone(),
0,
)));
});
view! {
<span>"wolf count"</span>
<IncDecU8 value=new_count value_range=1..=0xFF />
}
.into_any()
}
ActionPrompt::RoleChange { new_role, .. } => {
let roles = RoleTitle::ALL
.into_iter()
.map(|role| {
view! { <option selected=role == new_role>{role.to_string().to_case(Case::Title)}</option> }
})
.collect_view();
let on_change_cb = move |t: Targeted<Event, HtmlSelectElement>| {
let select = t.target();
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.set(Some(ServerToHostMessage::ActionPrompt(
new_prompt.clone(),
0,
)));
}
};
let on_wheel = move |t: Targeted<WheelEvent, HtmlSelectElement>| {
let target = t.target();
let index = target.selected_index();
let new_index = match t.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.set(Some(ServerToHostMessage::ActionPrompt(
new_prompt.clone(),
0,
)));
}
};
view! {
<div class="prompt-option">
<span>{"new role"}</span>
<select on:wheel:target=on_wheel on:change:target=on_change_cb>
{roles}
</select>
</div>
}
.into_any()
}
ActionPrompt::Hunter { current_target, .. } => {
let toggle_target = current_target.is_none().then_some(super::identity());
let button_text = if toggle_target.is_some() {
"remove previous target"
} else {
"set previous target"
};
let on_toggle = move |_| {
let new_prompt = ActionPrompt::Hunter {
character_id: super::identity(),
current_target: toggle_target.clone(),
living_players: overrides::identities(20),
marked: None,
};
send.set(Some(ServerToHostMessage::ActionPrompt(
new_prompt.clone(),
0,
)));
};
view! {
<div class="prompt-options">
<button on:click=on_toggle>{button_text}</button>
</div>
}
.into_any()
}
ActionPrompt::MapleWolf {
nights_til_starvation,
..
} => {
let nights_til_starvation = RwSignal::new(nights_til_starvation);
Effect::new(move || {
let new_prompt = ActionPrompt::MapleWolf {
character_id: super::identity(),
nights_til_starvation: nights_til_starvation.get(),
living_players: overrides::identities(20),
marked: None,
};
send.set(Some(ServerToHostMessage::ActionPrompt(
new_prompt.clone(),
0,
)));
});
view! {
<div class="prompt-options">
<span>"nights til starvation"</span>
<IncDecU8 value=nights_til_starvation />
</div>
}
.into_any()
}
ActionPrompt::Guardian { .. } => {
let none_disabled = move || {
matches!(
prompt_signal.get(),
Some(ActionPrompt::Guardian { previous: None, .. })
)
};
let prev_none = move |_| {
let new_prompt = ActionPrompt::Guardian {
character_id: super::identity(),
previous: None,
living_players: super::identities(20),
marked: None,
};
send.set(Some(ServerToHostMessage::ActionPrompt(
new_prompt.clone(),
0,
)));
};
let p = prompt.clone();
let prot_disabled = move || {
matches!(
p,
ActionPrompt::Guardian {
previous: Some(PreviousGuardianAction::Protect(_)),
..
}
)
};
let prev_prot = 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.set(Some(ServerToHostMessage::ActionPrompt(
new_prompt.clone(),
0,
)));
};
let p = prompt.clone();
let guard_disabled = move || {
matches!(
p,
ActionPrompt::Guardian {
previous: Some(PreviousGuardianAction::Guard(_)),
..
}
)
};
let prev_guard = 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.set(Some(ServerToHostMessage::ActionPrompt(
new_prompt.clone(),
0,
)));
};
view! {
<span>"previous protect"</span>
<div class="option-set">
<button disabled=none_disabled on:click=prev_none>
"none"
</button>
<button disabled=prot_disabled on:click=prev_prot>
"protected"
</button>
<button disabled=guard_disabled on:click=prev_guard>
"guarded"
</button>
</div>
}
.into_any()
}
ActionPrompt::MasonsWake { masons, .. } => {
let mason_count = RwSignal::new(masons.len() as u8);
Effect::new(move || {
let new_prompt = ActionPrompt::MasonsWake {
leader: super::identity(),
masons: super::identities(mason_count.get() as _),
};
send.set(Some(ServerToHostMessage::ActionPrompt(
new_prompt.clone(),
0,
)));
});
view! {
<span>"masons"</span>
<IncDecU8 value=mason_count value_range=1..=0xFF />
}
.into_any()
}
ActionPrompt::MasonLeaderRecruit { recruits_left, .. } => {
let recruits_left = RwSignal::new(recruits_left.get());
Effect::new(move || {
let new_prompt = ActionPrompt::MasonLeaderRecruit {
character_id: super::identity(),
recruits_left: NonZeroU8::new(recruits_left.get()).unwrap(),
potential_recruits: overrides::identities(20),
marked: None,
};
send.set(Some(ServerToHostMessage::ActionPrompt(
new_prompt.clone(),
0,
)));
});
view! { <IncDecU8 value=recruits_left value_range=1..=0xFF /> }.into_any()
}
ActionPrompt::BeholderWakes { .. }
| ActionPrompt::Protector { .. }
| ActionPrompt::Arcanist { .. }
| ActionPrompt::Gravedigger { .. }
| ActionPrompt::Militia { .. }
| ActionPrompt::Adjudicator { .. }
| ActionPrompt::PowerSeer { .. }
| ActionPrompt::Mortician { .. }
| ActionPrompt::BeholderChooses { .. }
| ActionPrompt::Empath { .. }
| ActionPrompt::Vindicator { .. }
| ActionPrompt::PyreMaster { .. }
| ActionPrompt::WolfPackKill { .. }
| ActionPrompt::AlphaWolf { .. }
| ActionPrompt::DireWolf { .. }
| ActionPrompt::LoneWolfKill { .. }
| ActionPrompt::Bloodletter { .. }
| ActionPrompt::Seer { .. }
| ActionPrompt::ElderReveal { .. }
| ActionPrompt::DamnedIntro { .. }
| ActionPrompt::Insomniac { .. }
| ActionPrompt::Shapeshifter { .. }
| ActionPrompt::CoverOfDarkness => ().into_any(),
}
};
let prev_page_disabled = move || page.get() == 0;
let prev_page = move |_| {
if let Some(prompt) = prompt.get() {
let new_page = page.get().saturating_sub(1);
page.set(new_page);
send.set(Some(ServerToHostMessage::ActionPrompt(prompt, new_page)));
}
};
let next_page = move |_| {
if let Some(prompt) = prompt.get() {
let new_page = page.get().saturating_add(1);
page.set(new_page);
send.set(Some(ServerToHostMessage::ActionPrompt(prompt, new_page)));
}
};
let title = move || {
prompt
.get()
.map(|p| p.title().to_string())
.unwrap_or_default()
};
let is_at_max_page = move || {
let Some(prompt) = prompt.get() else {
return true;
};
night_actions::pages_for_prompt(prompt, None).len() <= page.get().saturating_add(1)
};
view! {
<div class="prompt-overrides">
<span>{title}</span>
<div class="prompt-number">
<span>"page:"</span>
<button on:click=prev_page disabled=prev_page_disabled>
"-"
</button>
<span>{move || page.get().saturating_add(1)}</span>
<button on:click=next_page disabled=is_at_max_page>
"+"
</button>
</div>
<div class="prompt-options">{options}</div>
</div>
}
}

View File

@ -0,0 +1,312 @@
// Copyright (C) 2025-2026 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 leptos::{
ev::{Event, Targeted, WheelEvent},
prelude::*,
web_sys::HtmlSelectElement,
};
use std::rc::Rc;
use werewolves_proto::{
diedto::DiedToTitle,
message::{
host::ServerToHostMessage,
night::{ActionResult, Visits},
},
role::{Alignment, RoleTitle},
};
use crate::app::{components::IncDecU8, pages::game::host::overrides};
#[component]
pub fn ResultScreenTest(
result: ActionResult,
send: WriteSignal<Option<ServerToHostMessage>>,
) -> impl IntoView {
let options = match result.clone() {
ActionResult::BeholderSawNothing
| ActionResult::BeholderSawEverything
| ActionResult::GoBackToSleep
| ActionResult::ShiftFailed
| ActionResult::Continue
| ActionResult::Drunk
| ActionResult::RoleBlocked
| ActionResult::SkippedByHost => ().into_any(),
ActionResult::Seer(target, alignment) => {
let all = Alignment::ALL
.into_iter()
.map(|align| {
let target = target.clone();
let on_click = move |_| {
send.set(Some(ServerToHostMessage::ActionResult(
None,
ActionResult::Seer(target.clone(), align),
)));
};
view! {
<button on:click=on_click disabled=align == alignment>
{align.to_string()}
</button>
}
})
.collect_view();
view! {
<div class="prompt-options">
<div class="option-set">{all}</div>
</div>
}
.into_any()
}
ActionResult::PowerSeer { target, powerful } => {
let on_toggle = move |_| {
send.set(Some(ServerToHostMessage::ActionResult(
None,
ActionResult::PowerSeer {
target: target.clone(),
powerful: !powerful,
},
)));
};
let text = if powerful.powerful() {
"make not powerful"
} else {
"make powerful"
};
view! {
<div class="prompt-options">
<button on:click=on_toggle>{text}</button>
</div>
}
.into_any()
}
ActionResult::Adjudicator { target, killer } => {
let on_toggle = move |_| {
send.set(Some(ServerToHostMessage::ActionResult(
None,
ActionResult::Adjudicator {
target: target.clone(),
killer: !killer,
},
)));
};
let text = if killer.killer() {
"make not killer"
} else {
"make killer"
};
view! {
<div class="prompt-options">
<button on:click=on_toggle>{text}</button>
</div>
}
.into_any()
}
ActionResult::Arcanist(targets, alignment_eq) => {
let on_toggle = move |_| {
send.set(Some(ServerToHostMessage::ActionResult(
None,
ActionResult::Arcanist(targets.clone(), !alignment_eq),
)));
};
let text = if alignment_eq.same() {
"make different"
} else {
"make same"
};
view! {
<div class="prompt-options">
<button on:click=on_toggle>{text}</button>
</div>
}
.into_any()
}
ActionResult::GraveDigger(target, 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"));
view! { <option selected=*role == role_title>{text}</option> }
})
.collect_view();
let res_target = target.clone();
let p = possibilities.clone();
let on_change_cb = move |ev: Targeted<Event, HtmlSelectElement>| {
let select = ev.target();
let selected = select.selected_index();
if selected == -1 {
return;
}
if let Some(new_role) = p.get(selected as usize) {
send.set(Some(ServerToHostMessage::ActionResult(
None,
ActionResult::GraveDigger(res_target.clone(), *new_role),
)));
}
};
let res_target = target.clone();
let on_wheel = move |ev: Targeted<WheelEvent, HtmlSelectElement>| {
let target = ev.target();
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.set(Some(ServerToHostMessage::ActionResult(
None,
ActionResult::GraveDigger(res_target.clone(), *new_role),
)));
}
};
view! {
<div class="result-option">
<label>{"dug up"}</label>
<select on:wheel:target=on_wheel on:change:target=on_change_cb>
{roles}
</select>
</div>
}
.into_any()
}
ActionResult::Mortician(target, died_to_title) => {
let roles = DiedToTitle::ALL
.into_iter()
.map(|died_to| {
view! { <option selected=died_to == died_to_title>{died_to.to_string()}</option> }
})
.collect_view();
let res_target = target.clone();
let on_change_cb = move |ev: Targeted<Event, HtmlSelectElement>| {
let select = ev.target();
let selected = select.selected_index();
if selected == -1 {
return;
}
if let Some(died_to) = DiedToTitle::ALL.into_iter().nth(selected as _) {
send.set(Some(ServerToHostMessage::ActionResult(
None,
ActionResult::Mortician(res_target.clone(), died_to),
)));
}
};
let res_target = target.clone();
let on_wheel = move |ev: Targeted<WheelEvent, HtmlSelectElement>| {
let target = ev.target();
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.set(Some(ServerToHostMessage::ActionResult(
None,
ActionResult::Mortician(res_target.clone(), died_to),
)));
}
};
view! {
<div class="prompt-option">
<label>{"died to"}</label>
<select on:wheel:target=on_wheel on:change:target=on_change_cb>
{roles}
</select>
</div>
}
.into_any()
}
ActionResult::Insomniac(visits) => {
let visits = RwSignal::new(visits.len() as u8);
Effect::new(move || {
send.set(Some(ServerToHostMessage::ActionResult(
None,
ActionResult::Insomniac(Visits::new(overrides::identities(visits.get() as _))),
)))
});
view! {
<div class="result-number">
<span>"visits"</span>
<IncDecU8 value=visits value_range=1..=0xFF />
</div>
}
.into_any()
}
ActionResult::Empath { target, scapegoat } => {
let on_toggle = move |_| {
send.set(Some(ServerToHostMessage::ActionResult(
None,
ActionResult::Empath {
scapegoat: !scapegoat,
target: target.clone(),
},
)));
};
let text = if scapegoat {
"make not scapegoat"
} else {
"make scapegoat"
};
view! {
<div class="prompt-options">
<button on:click=on_toggle>{text}</button>
</div>
}
.into_any()
}
};
view! {
<div class="result-overrides">
<span>{result.title().to_string()}</span>
<div class="result-options">{options}</div>
</div>
}
.into_any()
}

View File

@ -1,4 +0,0 @@
use leptos::prelude::*;
#[component]
pub fn HostLobby() -> impl IntoView {}

View File

@ -1,3 +1,4 @@
use convert_case::{Case, Casing};
use leptos::prelude::*; use leptos::prelude::*;
use werewolves_proto::{ use werewolves_proto::{
character::CharacterId, character::CharacterId,
@ -6,12 +7,12 @@ use werewolves_proto::{
host::HostNightMessage, host::HostNightMessage,
night::{ActionPrompt, ActionResponse}, night::{ActionPrompt, ActionResponse},
}, },
role::PreviousGuardianAction, role::{PreviousGuardianAction, RoleTitle},
}; };
use crate::app::{ use crate::app::{
class::AsClasses, class::AsClasses,
components::{Cover, IdentityInline}, components::{Cover, IdentityInline, Sample, TutorialBox},
error::WolfError, error::WolfError,
pages::night_actions::{ pages::night_actions::{
DamnedIntroPage1, DamnedIntroPage2, RoleChange, WolfPackKill, WolvesIntro, DamnedIntroPage1, DamnedIntroPage2, RoleChange, WolfPackKill, WolvesIntro,
@ -22,7 +23,7 @@ use crate::app::{
GuardianPagePreviousProtect2, HunterPage1, InsomniacPage1, LoneWolfPage1, GuardianPagePreviousProtect2, HunterPage1, InsomniacPage1, LoneWolfPage1,
MapleWolfPage1, MasonRecruitPage1, MasonRecruitPage2, MasonsWake, MilitiaPage1, MapleWolfPage1, MasonRecruitPage1, MasonRecruitPage2, MasonsWake, MilitiaPage1,
MorticianPage1, PowerSeerPage1, ProtectorPage1, PyremasterPage1, SeerPage1, MorticianPage1, PowerSeerPage1, ProtectorPage1, PyremasterPage1, SeerPage1,
VindicatorPage1, ShapeshifterPage1, VindicatorPage1,
}, },
}, },
}; };
@ -31,27 +32,23 @@ pub trait RolePage {
fn role_pages(&self, big_screen: bool) -> ViewFn; fn role_pages(&self, big_screen: bool) -> ViewFn;
} }
#[component] pub fn target_picker_tutorials(prompt: &ActionPrompt) -> Vec<AnyView> {
pub fn RolePrompt( #[allow(clippy::match_single_binding)]
match prompt {
_ => vec![],
}
}
pub fn pages_for_prompt(
prompt: ActionPrompt, prompt: ActionPrompt,
page: usize, reply: Option<WriteSignal<Option<HostNightMessage>>>,
#[prop(optional)] reply: Option<WriteSignal<Option<HostNightMessage>>>, ) -> Vec<AnyView> {
#[prop(optional)] error: Option<WriteSignal<Option<WolfError>>>,
) -> impl IntoView {
let ident = move |character_id: CharacterIdentity| { let ident = move |character_id: CharacterIdentity| {
reply.map(|_| { reply.map(|_| {
view! { <IdentityInline ident=character_id.into() /> } view! { <IdentityInline ident=character_id.into() /> }
}) })
}; };
let interactive = prompt.interactive(); match prompt {
let targets = prompt.targets().map(|t| t.to_vec().into_boxed_slice());
let marked = prompt
.marked()
.map(|t| [t.0].into_iter().chain(t.1))
.into_iter()
.flatten()
.collect::<Box<_>>();
let mut pages: Vec<AnyView> = match prompt {
ActionPrompt::CoverOfDarkness => vec![match reply { ActionPrompt::CoverOfDarkness => vec![match reply {
Some(reply) => view! { <Cover reply=reply /> }.into_any(), Some(reply) => view! { <Cover reply=reply /> }.into_any(),
None => view! { <Cover /> }.into_any(), None => view! { <Cover /> }.into_any(),
@ -180,8 +177,8 @@ pub fn RolePrompt(
ActionPrompt::PyreMaster { character_id, .. } => { ActionPrompt::PyreMaster { character_id, .. } => {
vec![view! { {ident(character_id)} <PyremasterPage1 /> }.into_any()] vec![view! { {ident(character_id)} <PyremasterPage1 /> }.into_any()]
} }
ActionPrompt::Shapeshifter { .. } => { ActionPrompt::Shapeshifter { character_id } => {
vec![] vec![view! {{ident(character_id)} <ShapeshifterPage1 />}.into_any()]
} }
ActionPrompt::AlphaWolf { character_id, .. } => { ActionPrompt::AlphaWolf { character_id, .. } => {
vec![view! { {ident(character_id)} <AlphaWolfPage1 /> }.into_any()] vec![view! { {ident(character_id)} <AlphaWolfPage1 /> }.into_any()]
@ -224,7 +221,27 @@ pub fn RolePrompt(
ActionPrompt::WolvesIntro { wolves } => { ActionPrompt::WolvesIntro { wolves } => {
vec![view! { <WolvesIntro wolves=wolves /> }.into_any()] vec![view! { <WolvesIntro wolves=wolves /> }.into_any()]
} }
}; }
}
#[component]
pub fn RolePrompt(
prompt: ActionPrompt,
page: usize,
#[prop(optional)] reply: Option<WriteSignal<Option<HostNightMessage>>>,
#[prop(optional)] error: Option<WriteSignal<Option<WolfError>>>,
) -> impl IntoView {
let interactive = prompt.interactive();
let targets = prompt.targets().map(|t| t.to_vec().into_boxed_slice());
let marked = prompt
.marked()
.map(|t| [t.0].into_iter().chain(t.1))
.into_iter()
.flatten()
.collect::<Box<_>>();
let tutorials = target_picker_tutorials(&prompt);
let shapeshifter = matches!(prompt, ActionPrompt::Shapeshifter { .. });
let mut pages: Vec<AnyView> = pages_for_prompt(prompt, reply);
// isn't it great that AnyView isn't Clone???? // isn't it great that AnyView isn't Clone????
if let Some(page) = pages.get(page).is_some().then(|| { if let Some(page) = pages.get(page).is_some().then(|| {
let p = pages.swap_remove(page); let p = pages.swap_remove(page);
@ -249,6 +266,23 @@ pub fn RolePrompt(
}) { }) {
return page.into_any(); return page.into_any();
} }
// shapeshifter gets a yes/no
if shapeshifter {
let decision = RwSignal::new(None);
if let Some(reply) = reply {
Effect::new(move || match decision.get() {
Some(true) => reply.set(Some(HostNightMessage::ActionResponse(
ActionResponse::Shapeshift,
))),
Some(false) => reply.set(Some(HostNightMessage::ActionResponse(
ActionResponse::Continue,
))),
None => {}
});
}
return view! { <BooleanPicker decision=decision.write_only() /> }.into_any();
}
let target_picker = match targets { let target_picker = match targets {
Some(targets) => { Some(targets) => {
let pick = RwSignal::new(None); let pick = RwSignal::new(None);
@ -288,8 +322,11 @@ pub fn RolePrompt(
</button> </button>
} }
}); });
let tutorials = (continue_btn.is_some() && !tutorials.is_empty())
.then(|| tutorials.into_iter().collect_view());
view! { view! {
{target_picker} {target_picker}
{tutorials}
{continue_btn} {continue_btn}
} }
.into_any() .into_any()
@ -323,5 +360,19 @@ pub fn TargetPicker(
}) })
.collect_view(); .collect_view();
view! { <div class="target-picker">{targets}</div> } view! { <div class="target-picker" class:allow-scroll=pick.is_some()>{targets}</div> }
}
#[component]
pub fn BooleanPicker(decision: WriteSignal<Option<bool>>) -> impl IntoView {
view! {
<div class="bool-picker">
<button class="no-hover" on:click=move |_| decision.set(Some(true))>
"yes"
</button>
<button class="no-hover" on:click=move |_| decision.set(Some(false))>
"no"
</button>
</div>
}
} }

View File

@ -8,10 +8,11 @@ use werewolves_proto::message::{
use crate::app::{ use crate::app::{
components::Cover, components::Cover,
pages::night_actions::{ pages::night_actions::{
DrunkPage, RoleblockPage, DrunkPage, RoleblockPage, ShiftFailed,
role::{ role::{
AdjudicatorResult, ArcanistResult, EmpathResult, GravediggerResultPage, AdjudicatorResult, ArcanistResult, BeholderSawEverything, BeholderSawNothing,
InsomniacResult, MorticianResultPage, PowerSeerResult, SeerResult, EmpathResult, GravediggerResultPage, InsomniacResult, MorticianResultPage,
PowerSeerResult, SeerResult,
}, },
}, },
}; };
@ -23,57 +24,41 @@ pub fn RoleResult(
#[prop(optional)] reply: Option<WriteSignal<Option<HostNightMessage>>>, #[prop(optional)] reply: Option<WriteSignal<Option<HostNightMessage>>>,
) -> impl IntoView { ) -> impl IntoView {
let body = match result { let body = match result {
ActionResult::RoleBlocked => view!{ ActionResult::RoleBlocked => view! { <RoleblockPage /> }.into_any(),
<RoleblockPage /> ActionResult::Drunk => view! { <DrunkPage /> }.into_any(),
}.into_any(), ActionResult::Seer(target, alignment) => view! { <SeerResult target=target.into_public() alignment=alignment /> }.into_any(),
ActionResult::Drunk => view! { ActionResult::PowerSeer { target, powerful } => view! { <PowerSeerResult target=target.into_public() powerful=powerful /> }.into_any(),
<DrunkPage /> ActionResult::Adjudicator { target, killer } => view! { <AdjudicatorResult target=target.into_public() killer=killer /> }.into_any(),
}.into_any(),
ActionResult::Seer(target, alignment) => view!{
<SeerResult target=target.into_public() alignment=alignment/>
}.into_any(),
ActionResult::PowerSeer { target, powerful } => view!{
<PowerSeerResult target=target.into_public() powerful=powerful/>
}.into_any(),
ActionResult::Adjudicator { target, killer } => view!{
<AdjudicatorResult target=target.into_public() killer=killer/>
}.into_any(),
ActionResult::Arcanist((target1, target2), alignment_eq) => view! { ActionResult::Arcanist((target1, target2), alignment_eq) => view! {
<ArcanistResult <ArcanistResult
value=alignment_eq value=alignment_eq
targets=(target1.into_public(), target2.into_public()) targets=(target1.into_public(), target2.into_public())
/> />
}.into_any(), }.into_any(),
ActionResult::GraveDigger(target, role) => view! { ActionResult::GraveDigger(target, role) => view! { <GravediggerResultPage target=target.into_public() role=role /> }.into_any(),
<GravediggerResultPage target=target.into_public() role=role/> ActionResult::Mortician(target, died_to) => view! { <MorticianResultPage target=target.into_public() died_to=died_to /> }.into_any(),
}.into_any(), ActionResult::Insomniac(visits) => view! { <InsomniacResult visits=visits /> }.into_any(),
ActionResult::Mortician(target, died_to) => view!{ ActionResult::Empath { target, scapegoat } => view! { <EmpathResult target=target.into_public() scapegoat=scapegoat /> }.into_any(),
<MorticianResultPage target=target.into_public() died_to=died_to /> ActionResult::BeholderSawNothing => view! { <BeholderSawNothing /> }.into_any(),
}.into_any(), ActionResult::BeholderSawEverything => view! { <BeholderSawEverything /> }.into_any(),
ActionResult::Insomniac(visits) => view!{
<InsomniacResult visits=visits/>
}.into_any(),
ActionResult::Empath { target, scapegoat } => view! {
<EmpathResult target=target.into_public() scapegoat=scapegoat />
}.into_any(),
ActionResult::BeholderSawNothing => todo!(),
ActionResult::BeholderSawEverything => todo!(),
ActionResult::GoBackToSleep => return match reply { ActionResult::GoBackToSleep => return match reply {
Some(reply) => view! { <Cover message="go to sleep" reply=reply reply_to_send=HostNightMessage::Next /> } Some(reply) => view! { <Cover message="go to sleep" reply=reply reply_to_send=HostNightMessage::Next /> }
.into_any(), .into_any(),
None => view! { <Cover message="go to sleep" /> }.into_any(), None => view! { <Cover message="go to sleep" /> }.into_any(),
}, },
ActionResult::ShiftFailed => todo!(), ActionResult::ShiftFailed => view!{<ShiftFailed />}.into_any(),
ActionResult::SkippedByHost |
ActionResult::Continue => { ActionResult::Continue => {
Effect::new(move || { Effect::new(move || {
let Some(reply) = reply else { let Some(reply) = reply else {
return; return;
}; };
reply.set(Some(HostNightMessage::Next)); // NOTE: in the old code this was a GetState and the return was
// a blank <Cover />
reply.set(Some(HostNightMessage::Next));
}); });
().into_any() ().into_any()
} }
ActionResult::SkippedByHost => todo!(),
}; };
let next_btn = reply.map(|reply| { let next_btn = reply.map(|reply| {
view! { view! {

View File

@ -23,9 +23,9 @@ pub fn AdjudicatorPage1() -> impl IntoView {
<div class="role-page"> <div class="role-page">
<span class="defensive box title">"ADJUDICATOR"</span> <span class="defensive box title">"ADJUDICATOR"</span>
<div class="information box defensive faint"> <div class="information box defensive faint">
<h4>"PICK A PLAYER"</h4> <span>"PICK A PLAYER"</span>
<Icon source=IconSource::Killer r#type=IconType::Fit /> <Icon source=IconSource::Killer r#type=IconType::Fit />
<h4 class="yellow">"YOU WILL CHECK IF THEY APPEAR AS A KILLER"</h4> <span class="yellow">"YOU WILL CHECK IF THEY APPEAR AS A KILLER"</span>
</div> </div>
</div> </div>
} }
@ -47,7 +47,7 @@ pub fn AdjudicatorResult(killer: Killer, target: PublicIdentity) -> impl IntoVie
<div class="information box defensive faint"> <div class="information box defensive faint">
<IdentityInline ident=target.clone() /> <IdentityInline ident=target.clone() />
{icon} {icon}
<h3 class="yellow">{text}</h3> <span class="yellow">{text}</span>
</div> </div>
</div> </div>
} }

View File

@ -39,12 +39,14 @@ pub fn ArcanistResult(
targets: (PublicIdentity, PublicIdentity), targets: (PublicIdentity, PublicIdentity),
) -> impl IntoView { ) -> impl IntoView {
let text = match value { let text = match value {
AlignmentEq::Same => "ARE THE SAME", AlignmentEq::Same => view! {"ARE THE SAME"}.into_any(),
AlignmentEq::Different => "ARE DIFFERENT", AlignmentEq::Different => {
view! {"ARE "<span class="wolves underline">"DIFFERENT"</span>}.into_any()
}
}; };
let icons = match value { let icons = match value {
AlignmentEq::Same => view! { <Icon source=IconSource::Equal /> }, AlignmentEq::Same => IconSource::BalancedScales,
AlignmentEq::Different => view! { <Icon source=IconSource::NotEqual /> }, AlignmentEq::Different => IconSource::UnbalancedScales,
}; };
view! { view! {
<div class="role-page"> <div class="role-page">
@ -55,7 +57,7 @@ pub fn ArcanistResult(
<span class="and">"AND"</span> <span class="and">"AND"</span>
<IdentityInline ident=targets.1 /> <IdentityInline ident=targets.1 />
</div> </div>
<div class="icons">{icons}</div> <Icon source=icons />
<span class="yellow">{text}</span> <span class="yellow">{text}</span>
</div> </div>
</div> </div>

View File

@ -49,9 +49,9 @@ pub fn BeholderSawNothing() -> impl IntoView {
<div class="role-page"> <div class="role-page">
<span class="intel box title">"BEHOLDER"</span> <span class="intel box title">"BEHOLDER"</span>
<div class="information box intel faint"> <div class="information box intel faint">
<h1>"YOUR TARGET HAS DIED"</h1> <span>"YOUR TARGET HAS DIED"</span>
<Icon source=IconSource::RedX /> <Icon source=IconSource::RedX />
<h1>"BUT SAW NOTHING"</h1> <span>"BUT SAW NOTHING"</span>
</div> </div>
</div> </div>
} }

View File

@ -14,7 +14,7 @@
// 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 leptos::prelude::*; use leptos::prelude::*;
use crate::app::components::{Icon, IconSource}; use crate::app::components::{Icon, IconSource, IconType};
#[component] #[component]
pub fn BloodletterPage1() -> impl IntoView { pub fn BloodletterPage1() -> impl IntoView {
@ -24,9 +24,13 @@ pub fn BloodletterPage1() -> impl IntoView {
<div class="information box wolves faint"> <div class="information box wolves faint">
<span>"PICK A PLAYER"</span> <span>"PICK A PLAYER"</span>
<span class="inline-icons"> <span class="inline-icons">
"THEY'LL APPEAR AS A WOLF " <Icon source=IconSource::Wolves /> "KILLER" "THEY'LL APPEAR AS A" <span class="wolves underline">"WOLF"</span>
<Icon source=IconSource::Killer /> "AND POWERFUL" <Icon source=IconSource::Wolves r#type=IconType::Shrink />
<Icon source=IconSource::Powerful /> "IN CHECKS FOR 2 NIGHTS" <span class="wolves underline">"KILLER"</span>
<Icon source=IconSource::Killer r#type=IconType::Shrink /> "AND"
<span class="wolves underline">"POWERFUL"</span>
<Icon source=IconSource::Powerful r#type=IconType::Shrink />
"IN CHECKS FOR 2 NIGHTS"
</span> </span>
</div> </div>
</div> </div>

View File

@ -0,0 +1,35 @@
use leptos::prelude::*;
use crate::app::components::{Icon, IconSource};
#[component]
pub fn ShapeshifterPage1() -> impl IntoView {
view! {
<div class="role-page">
<span class="wolves box title">"SHAPESHIFTER"</span>
<div class="information box wolves faint">
<span>
"WOULD YOU LIKE TO USE YOUR " <span class="yellow">{"ONCE PER GAME"}</span>
" SHAPESHIFT ABILITY?"
</span>
<span class="breakable">
<span class="yellow breakable">"YOU WILL DIE"</span>
", BUT THE TARGET OF THE WOLFPACK KILL SHALL INSTEAD BECOME A WOLF"
</span>
</div>
</div>
}
}
#[component]
pub fn ShiftFailed() -> impl IntoView {
view! {
<div class="role-page">
<span class="wolves box title">"SHIFT FAILED"</span>
<div class="information wolves box faint">
"YOUR SHIFT HAS FAILED" <Icon source=IconSource::RedX />
"YOU RETAIN YOUR SHAPESHIFT ABILITY"
</div>
</div>
}
}