Compare commits

..

No commits in common. "leptos-port" and "main" have entirely different histories.

248 changed files with 17141 additions and 16299 deletions

4
.gitignore vendored
View File

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

2620
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,136 +1,8 @@
[workspace]
resolver = "3"
members = [
# "werewolves-old-client",
"werewolves",
"werewolves-macros",
"werewolves-proto",
# "werewolves-server",
"werewolves",
# "api",
"werewolves-server",
]
[[workspace.metadata.leptos]]
watch-additional-files = ["werewolves", "api", "style", "public"]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "werewolves"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
# Defaults to pkg
site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "style/main.scss"
# Assets source dir. All files found here will be copied and synchronized to site-root.
# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir.
#
# Optional. Env: LEPTOS_ASSETS_DIR.
assets-dir = "public"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "127.0.0.1:3000"
# site-addr = "192.168.1.3:3000"
# The port to use for automatic reload monitoring
reload-port = 3001
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
# [Windows] for non-WSL use "npx.cmd playwright test"
# This binary name can be checked in Powershell with Get-Command npx
end2end-cmd = "npx playwright test"
end2end-dir = "end2end"
# The browserlist query used for optimizing the CSS.
browserquery = "defaults"
# The environment Leptos will run in, usually either "DEV" or "PROD"
env = "DEV"
# The features to use when compiling the bin target
#
# Optional. Can be over-ridden with the command line parameter --bin-features
bin-features = ["ssr"]
# If the --no-default-features flag should be used when compiling the bin target
#
# Optional. Defaults to false.
bin-default-features = false
# The features to use when compiling the lib target
#
# Optional. Can be over-ridden with the command line parameter --lib-features
lib-features = ["hydrate"]
# If the --no-default-features flag should be used when compiling the lib target
#
# Optional. Defaults to false.
lib-default-features = false
# The profile to use for the lib target when compiling for release
#
# Optional. Defaults to "release".
lib-profile-release = "wasm-release"
name = "werewolves"
bin-package = "werewolves"
lib-package = "werewolves"
[workspace.dependencies]
axum = "0.8.1"
axum-extra = { version = "0.12", features = ["typed-header"] }
cfg-if = "1.0.0"
console_error_panic_hook = "0.1.7"
console_log = "1.0.0"
http = "1.3.1"
log = "0.4.27"
simple_logger = "5.0.0"
thiserror = "2.0.12"
wasm-bindgen = "0.2.106"
leptos-use = { version = "0.18" }
# leptos-use = { path = "../repos/leptos-use" }
werewolves-macros = { path = "werewolves-macros" }
werewolves-proto = { path = "werewolves-proto" }
serde_json = { version = "1" }
futures = { version = "*" }
codee = { version = "0.3", features = ["msgpack_serde"] }
bytes = { version = "1.10" }
convert_case = { version = "0.11" }
fast_qr = { version = "0.13", features = ["svg"] }
anyhow = { version = "1" }
uuid = { version = "1.18" }
sqlx = { version = "0.8", features = [
"runtime-tokio",
"postgres",
"derive",
"macros",
"uuid",
"chrono",
] }
argon2 = { version = "0.5" }
async-trait = { version = "0.1" }
chrono = { version = "0.4" }
leptos = { version = "0.8.2" }
leptos_axum = { version = "0.8.2" }
leptos_meta = { version = "0.8.2" }
leptos_router = { version = "0.8.2" }
rand = { version = "*" }
serde = { version = "1.0.228" }
tokio = { version = "1.45.0", features = ["full"] }
tower = { version = "0.5.2", features = ["full"] }
tower-http = { version = "0.6.4", features = ["full"] }
ciborium = { version = "0.2" }
pretty_assertions = { version = "1.4" }
colored = { version = "3.1" }
pretty_env_logger = { version = "0.5" }
sorted-vec = { version = "0.8" }
[profile.dev]
opt-level = 0
debug = "full"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1

View File

@ -1,70 +0,0 @@
drop table if exists users cascade;
create table users (
id uuid not null default gen_random_uuid() primary key,
username text not null,
display_name text,
pronouns text,
password_hash text not null,
dummy boolean not null default false,
created_at timestamp with time zone not null,
updated_at timestamp with time zone not null,
check (created_at <= updated_at)
);
drop index if exists users_username_idx;
create index users_username_idx on users (username);
drop index if exists users_username_unique;
create unique index users_username_unique on users (lower(username));
drop table if exists login_tokens cascade;
create table login_tokens (
token text not null primary key,
user_id uuid not null references users(id) on delete cascade,
created_at timestamp with time zone not null,
expires_at timestamp with time zone not null,
check (created_at < expires_at)
);
drop type if exists game_status cascade;
create type game_status as enum (
'Lobby',
'RoleReveal',
'Started',
'GameOver',
'Cancelled'
);
drop table if exists games cascade;
create table games (
id uuid not null primary key,
host uuid not null references users(id) on delete cascade,
created_at timestamp with time zone not null default now(),
game_state jsonb not null,
game_status game_status not null default 'Lobby'
);
drop table if exists game_characters cascade;
create table game_characters (
character_id uuid not null primary key,
game_id uuid not null references games(id) on delete cascade,
player_id uuid not null references users(id) on delete cascade,
number integer
);
drop index if exists game_characters_player_id_game_id_unique;
create unique index game_characters_player_id_game_id_unique on game_characters (player_id, game_id);
drop table if exists dead_chat cascade;
create table dead_chat (
message_id uuid not null primary key,
game_id uuid not null references games(id) on delete cascade,
created_at timestamp with time zone not null,
message jsonb not null
);
drop index if exists dead_chat_created_at;
create index dead_chat_created_at on dead_chat(created_at);

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 41 KiB

View File

@ -1,67 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 6.3 KiB

View File

@ -1,81 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 7.1 KiB

View File

@ -1,48 +0,0 @@
.big-screen-wrapper {
margin: 0;
height: 100vh;
width: 100vw;
position: absolute;
top: 0;
left: 0;
user-select: none;
font-size: 3em;
.target-picker {
font-size: 1.25em;
.target {
flex-grow: 1;
}
}
.role-reveal {
width: 100%;
height: 100%;
// font-size: 2em;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 0.5ch;
align-items: stretch;
.player {
flex-grow: 1;
height: auto;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
align-items: center;
justify-content: center;
background-color: color.change($red1, $alpha: 0.1);
border: 1px solid color.change($red1, $alpha: 0.6);
&.ready {
background-color: color.change($blue1, $alpha: 0.3);
border: 1px solid $blue1;
}
}
}
}

View File

@ -1,409 +0,0 @@
.village {
--faction-color: $village_color;
--faction-border: $village_border;
--faction-color-faint: $village_color_faint;
--faction-border-faint: $village_border_faint;
&.box {
background-color: $village_color;
border: 1px solid $village_border;
.selected:not(.faint) {
color: white;
background-color: $village_border;
}
.selected.faint {
color: white;
background-color: $village_border_faint;
}
&.hover:not(.selected):hover {
color: white;
background-color: $village_border;
}
&.faint:not(.selected) {
border: 1px solid $village_border_faint;
background-color: $village_color_faint;
&.hover:hover {
background-color: $village_border_faint;
}
}
}
&.underline {
text-decoration: $village_color underline;
&.faint {
text-decoration: $village_color_faint underline;
}
}
&.text-color {
color: $village_border;
&.faint {
color: $village_border_faint;
}
}
}
.wolves {
--faction-color: $wolves_color;
--faction-border: $wolves_border;
--faction-color-faint: $wolves_color_faint;
--faction-border-faint: $wolves_border_faint;
&.box {
background-color: $wolves_color;
border: 1px solid $wolves_border;
.selected:not(.faint) {
color: white;
background-color: $wolves_border;
}
.selected.faint {
color: white;
background-color: $wolves_border_faint;
}
&.hover:not(.selected):hover {
color: white;
background-color: $wolves_border;
}
&.faint:not(.selected) {
border: 1px solid $wolves_border_faint;
background-color: $wolves_color_faint;
&.hover:hover {
background-color: $wolves_border_faint;
}
}
}
&.underline {
text-decoration: $wolves_color underline;
&.faint {
text-decoration: $wolves_color_faint underline;
}
}
&.text-color {
color: $wolves_border;
&.faint {
color: $wolves_border_faint;
}
}
}
.offensive {
--faction-color: $offensive_color;
--faction-border: $offensive_border;
--faction-color-faint: $offensive_color_faint;
--faction-border-faint: $offensive_border_faint;
&.box {
background-color: $offensive_color;
border: 1px solid $offensive_border;
.selected:not(.faint) {
color: white;
background-color: $offensive_border;
}
.selected.faint {
color: white;
background-color: $offensive_border_faint;
}
&.hover:not(.selected):hover {
color: white;
background-color: $offensive_border;
}
&.faint:not(.selected) {
border: 1px solid $offensive_border_faint;
background-color: $offensive_color_faint;
&.hover:hover {
background-color: $offensive_border_faint;
}
}
}
&.underline {
text-decoration: $offensive_color underline;
&.faint {
text-decoration: $offensive_color_faint underline;
}
}
&.text-color {
color: $offensive_border;
&.faint {
color: $offensive_border_faint;
}
}
}
.defensive {
--faction-color: $defensive_color;
--faction-border: $defensive_border;
--faction-color-faint: $defensive_color_faint;
--faction-border-faint: $defensive_border_faint;
&.box {
background-color: $defensive_color;
border: 1px solid $defensive_border;
.selected:not(.faint) {
color: white;
background-color: $defensive_border;
}
.selected.faint {
color: white;
background-color: $defensive_border_faint;
}
&.hover:not(.selected):hover {
color: white;
background-color: $defensive_border;
}
&.faint:not(.selected) {
border: 1px solid $defensive_border_faint;
background-color: $defensive_color_faint;
&.hover:hover {
background-color: $defensive_border_faint;
}
}
}
&.underline {
text-decoration: $defensive_color underline;
&.faint {
text-decoration: $defensive_color_faint underline;
}
}
&.text-color {
color: $defensive_border;
&.faint {
color: $defensive_border_faint;
}
}
}
.intel {
--faction-color: $intel_color;
--faction-border: $intel_border;
--faction-color-faint: $intel_color_faint;
--faction-border-faint: $intel_border_faint;
&.box {
background-color: $intel_color;
border: 1px solid $intel_border;
.selected:not(.faint) {
color: white;
background-color: $intel_border;
}
.selected.faint {
color: white;
background-color: $intel_border_faint;
}
&.hover:not(.selected):hover {
color: white;
background-color: $intel_border;
}
&.faint:not(.selected) {
border: 1px solid $intel_border_faint;
background-color: $intel_color_faint;
&.hover:hover {
background-color: $intel_border_faint;
}
}
}
&.underline {
text-decoration: $intel_color underline;
&.faint {
text-decoration: $intel_color_faint underline;
}
}
&.text-color {
color: $intel_border;
&.faint {
color: $intel_border_faint;
}
}
}
.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;
&.box {
background-color: $starts_as_villager_color;
border: 1px solid $starts_as_villager_border;
.selected:not(.faint) {
color: white;
background-color: $starts_as_villager_border;
}
.selected.faint {
color: white;
background-color: $starts_as_villager_border_faint;
}
&.hover:not(.selected):hover {
color: white;
background-color: $starts_as_villager_border;
}
&.faint:not(.selected) {
border: 1px solid $starts_as_villager_border_faint;
background-color: $starts_as_villager_color_faint;
&.hover:hover {
background-color: $starts_as_villager_border_faint;
}
}
}
&.underline {
text-decoration: $starts_as_villager_color underline;
&.faint {
text-decoration: $starts_as_villager_color_faint underline;
}
}
&.text-color {
color: $starts_as_villager_border;
&.faint {
color: $starts_as_villager_border_faint;
}
}
}
.damned {
--faction-color: $damned_color;
--faction-border: $damned_border;
--faction-color-faint: $damned_color_faint;
--faction-border-faint: $damned_border_faint;
&.box {
background-color: $damned_color;
border: 1px solid $damned_border;
.selected:not(.faint) {
color: white;
background-color: $damned_border;
}
.selected.faint {
color: white;
background-color: $damned_border_faint;
}
&.hover:not(.selected):hover {
color: white;
background-color: $damned_border;
}
&.faint:not(.selected) {
border: 1px solid $damned_border_faint;
background-color: $damned_color_faint;
&.hover:hover {
background-color: $damned_border_faint;
}
}
}
&.underline {
text-decoration: $damned_color underline;
&.faint {
text-decoration: $damned_color_faint underline;
}
}
&.text-color {
color: $damned_border;
&.faint {
color: $damned_border_faint;
}
}
}
.drunk {
--faction-color: $drunk_color;
--faction-border: $drunk_border;
--faction-color-faint: $drunk_color_faint;
--faction-border-faint: $drunk_border_faint;
&.box {
background-color: $drunk_color;
border: 1px solid $drunk_border;
.selected:not(.faint) {
color: white;
background-color: $drunk_border;
}
.selected.faint {
color: white;
background-color: $drunk_border_faint;
}
&.hover:not(.selected):hover {
color: white;
background-color: $drunk_border;
}
&.faint:not(.selected) {
border: 1px solid $drunk_border_faint;
background-color: $drunk_color_faint;
&.hover:hover {
background-color: $drunk_border_faint;
}
}
}
&.underline {
text-decoration: $drunk_color underline;
&.faint {
text-decoration: $drunk_color_faint underline;
}
}
&.text-color {
color: $drunk_border;
&.faint {
color: $drunk_border_faint;
}
}
}

View File

@ -1,21 +0,0 @@
.icon-fit {
height: 1em;
flex-grow: 1;
flex-shrink: 1;
padding: 1ch;
}
.icon {
width: 32px;
height: 32px;
&:hover {
filter: contrast(120%) brightness(120%);
}
}
.icon-shrink {
flex-shrink: 1;
height: 1em;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,243 +0,0 @@
.cover-of-darkness {
background-color: black;
font-size: 3em;
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 100vw;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
text-wrap: wrap;
p {
padding: 3ch;
}
& button {
width: fit-content;
text-align: center;
align-self: center;
}
}
.wolves-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.information {
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
gap: 0.5ch;
font-size: 1.75em;
height: 100%;
max-height: 100%;
max-width: 100%;
.subtext {
font-size: 1.5rem;
}
.arcanist-targets {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 1ch;
font-size: 0.7em;
align-items: center;
.and {
font-style: italic;
opacity: 50%;
font-size: 0.7em;
}
}
}
.role-page {
padding: 1vh 1vw 1vh 1vw;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 1ch;
height: 98%;
.title {
font-size: 2em;
font-weight: bold;
display: block;
max-width: 100%;
overflow: hidden;
min-height: 2ch;
}
.character {
padding: 1ch;
}
}
.yellow {
color: $wakes_color;
}
.wolves-list {
padding: 1ch;
display: flex;
flex-direction: row;
flex-wrap: wrap;
height: 100%;
align-items: center;
justify-content: space-around;
.character {
display: flex;
flex-direction: column;
align-items: center;
min-width: 37vw;
font-size: 1.5em;
.role {
font-size: 1.25em;
font-weight: bold;
}
}
}
.continue-button {
font-size: 2.25em;
padding: 0.3ch;
margin: 1ch;
}
.breakable {
word-wrap: normal;
}
.inline-icons {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 0.5ch;
row-gap: 0px;
width: 100%;
align-items: 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 {
display: flex;
flex-direction: row;
flex-wrap: wrap;
&.allow-scroll {
max-height: 70vh;
overflow-y: scroll;
scrollbar-width: thin;
justify-content: unset;
}
height: 100%;
font-size: 2em;
.target {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: color.change($red1, $alpha: 0.1);
border: 1px solid color.change($red1, $alpha: 0.6);
&.marked {
background-color: color.change($blue1, $alpha: 0.3);
border: 1px solid $blue1;
}
}
}
.seer-icons,
.arcanist-icons {
display: flex;
flex-direction: row;
justify-content: space-between;
flex-grow: 1;
flex-shrink: 1;
gap: 10%;
@media only screen and (min-width : 1200px) {
&>img {
height: auto;
}
}
}
.two-column {
display: grid;
grid-template-columns: 3fr 2fr;
height: 100%;
}
.seer-check {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
height: 100%;
align-items: center;
justify-content: space-around;
}
.false-positives {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
font-weight: bold;
font-size: 0.5em;
gap: 10px;
}
.bottom-bound {
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
}

View File

@ -1,107 +0,0 @@
.setup-screen {
.inactive {
filter: brightness(0%);
}
margin: 2%;
font-size: 1.5em;
.setup {
display: grid;
grid: auto-flow / 1fr 1fr 1fr;
gap: 5vw;
row-gap: 2ch;
}
}
.setup-category {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 0.25ch;
text-align: left;
align-items: center;
.title {
padding: 0.1ch;
text-align: center;
text-shadow: black 1px 1px;
width: 100%;
flex-grow: 1;
margin-bottom: 0.25ch;
font-size: 1.25em;
}
.count {
padding: 0 0.5ch 0 0.5ch;
&.invisible {
opacity: 0%;
}
}
.slot {
width: 100%;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
.attributes {
margin-left: 10px;
align-self: flex-end;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 10px;
}
.role {
flex-grow: 1;
text-shadow: black 1px 1px;
width: 100%;
filter: saturate(40%);
padding: 0.25ch 0 0.25ch 1ch;
}
.wakes {
border: 2px solid $wakes_color;
box-shadow: 0 0 3px $wakes_color;
}
}
}
.qrcode {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
z-index: 100;
position: fixed;
top: 0;
left: 0;
margin: 5vw;
width: 90vw;
height: 90vh;
gap: 1cm;
img {
height: 70%;
width: 100%;
}
.details {
font-size: 5vw;
border: 1px solid $village_border;
background-color: color.change($village_color, $alpha: 0.3);
text-align: center;
&>* {
margin-top: 0.5cm;
margin-bottom: 0.5cm;
}
}
}

View File

@ -24,6 +24,7 @@ use proc_macro2::Span;
use quote::{ToTokens, quote};
use syn::{parse::Parse, parse_macro_input};
use crate::ref_and_mut::RefAndMut;
mod all;
mod checks;

View File

@ -4,38 +4,16 @@ version = "0.1.0"
edition = "2024"
[dependencies]
bytes = { version = "1.10.1", features = ["serde"] }
serde = { workspace = true, features = ["derive"] }
uuid = { workspace = true, features = ["serde", "v4"] }
thiserror = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
sqlx = { workspace = true, optional = true }
axum = { workspace = true, optional = true, features = ["macros"] }
axum-extra = { workspace = true, optional = true, features = ["typed-header"] }
argon2 = { workspace = true, optional = true }
ciborium = { workspace = true }
async-trait = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
futures = { workspace = true, optional = true }
log.workspace = true
leptos.workspace = true
anyhow.workspace = true
werewolves-macros.workspace = true
rand.workspace = true
thiserror = { version = "2" }
log = { version = "0.4" }
serde_json = { version = "1.0" }
serde = { version = "1.0", features = ["derive"] }
uuid = { version = "1.17", features = ["v4", "serde"] }
rand = { version = "0.9", features = ["std_rng"] }
werewolves-macros = { path = "../werewolves-macros" }
chrono = { version = "0.4", features = ["serde"] }
[dev-dependencies]
colored.workspace = true
pretty_assertions.workspace = true
pretty_env_logger.workspace = true
[features]
ssr = [
"dep:sqlx",
"dep:axum",
"dep:axum-extra",
"dep:argon2",
"dep:async-trait",
"dep:serde_json",
"dep:futures",
]
pretty_assertions = { version = "1" }
pretty_env_logger = { version = "0.5" }
colored = { version = "3.0" }

View File

@ -1,139 +0,0 @@
use axum::{
body::Bytes,
extract::{FromRequest, Request, rejection::BytesRejection},
http::{HeaderMap, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
};
use axum_extra::headers::Mime;
use bytes::{BufMut, BytesMut};
use core::fmt::Display;
use serde::{Serialize, de::DeserializeOwned};
const CBOR_CONTENT_TYPE: HeaderValue = HeaderValue::from_static("application/cbor");
const PLAIN_CONTENT_TYPE: HeaderValue = HeaderValue::from_static("text/plain");
#[must_use]
pub struct Cbor<T>(pub T);
impl<T> Cbor<T> {
pub const fn new(t: T) -> Self {
Self(t)
}
}
impl<T, S> FromRequest<S> for Cbor<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = CborRejection;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
if !cbor_content_type(req.headers()) {
return Err(CborRejection::MissingCborContentType);
}
let bytes = Bytes::from_request(req, state).await?;
Ok(Self(ciborium::from_reader::<T, _>(&*bytes)?))
}
}
impl<T> IntoResponse for Cbor<T>
where
T: Serialize,
{
fn into_response(self) -> axum::response::Response {
// Extracted into separate fn so it's only compiled once for all T.
fn make_response(buf: BytesMut, ser_result: Result<(), CborRejection>) -> Response {
match ser_result {
Ok(()) => {
([(header::CONTENT_TYPE, CBOR_CONTENT_TYPE)], buf.freeze()).into_response()
}
Err(err) => err.into_response(),
}
}
// Use a small initial capacity of 128 bytes like serde_json::to_vec
// https://docs.rs/serde_json/1.0.82/src/serde_json/ser.rs.html#2189
let mut buf = BytesMut::with_capacity(128).writer();
let res = ciborium::into_writer(&self.0, &mut buf)
.map_err(|err| CborRejection::SerdeRejection(err.to_string()));
make_response(buf.into_inner(), res)
}
}
#[derive(Debug)]
pub enum CborRejection {
MissingCborContentType,
BytesRejection(BytesRejection),
DeserializeRejection(String),
SerdeRejection(String),
}
impl<T: Display> From<ciborium::de::Error<T>> for CborRejection {
fn from(value: ciborium::de::Error<T>) -> Self {
Self::SerdeRejection(match value {
ciborium::de::Error::Io(err) => format!("i/o: {err}"),
ciborium::de::Error::Syntax(offset) => format!("syntax error at {offset}"),
ciborium::de::Error::Semantic(offset, err) => format!(
"semantic parse: {err}{}",
offset
.map(|offset| format!(" at {offset}"))
.unwrap_or_default(),
),
ciborium::de::Error::RecursionLimitExceeded => {
String::from("the input caused serde to recurse too much")
}
})
}
}
impl From<BytesRejection> for CborRejection {
fn from(value: BytesRejection) -> Self {
Self::BytesRejection(value)
}
}
impl IntoResponse for CborRejection {
fn into_response(self) -> axum::response::Response {
match self {
CborRejection::MissingCborContentType => (
StatusCode::BAD_REQUEST,
[(header::CONTENT_TYPE, PLAIN_CONTENT_TYPE)],
String::from("missing cbor content type"),
),
CborRejection::BytesRejection(err) => (
err.status(),
[(header::CONTENT_TYPE, PLAIN_CONTENT_TYPE)],
format!("bytes rejection: {}", err.body_text()),
),
CborRejection::SerdeRejection(err) => (
StatusCode::BAD_REQUEST,
[(header::CONTENT_TYPE, PLAIN_CONTENT_TYPE)],
err,
),
CborRejection::DeserializeRejection(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
[(header::CONTENT_TYPE, PLAIN_CONTENT_TYPE)],
err,
),
}
.into_response()
}
}
fn cbor_content_type(headers: &HeaderMap) -> bool {
let Some(content_type) = headers.get(header::CONTENT_TYPE) else {
return false;
};
let Ok(content_type) = content_type.to_str() else {
return false;
};
let Ok(mime) = content_type.parse::<Mime>() else {
return false;
};
mime.type_() == "application"
&& (mime.subtype() == "cbor" || mime.suffix().is_some_and(|name| name == "cbor"))
}

View File

@ -1,75 +0,0 @@
use bytes::Bytes;
use leptos::{
server::codee::{Decoder, Encoder},
server_fn::{
ContentType, Decodes, Encodes, Format, FormatType,
codec::{Post, Put},
},
};
use serde::{Serialize, de::DeserializeOwned};
pub type CborPost = Post<CborEncoding>;
pub type CborPut = Put<CborEncoding>;
/// Serializes and deserializes JSON with [`serde_json`].
pub struct CborEncoding;
impl<T> Decoder<T> for CborEncoding
where
T: DeserializeOwned,
{
type Error = ciborium::de::Error<std::io::Error>;
type Encoded = [u8];
fn decode(val: &Self::Encoded) -> Result<T, Self::Error> {
ciborium::from_reader::<T, _>(val)
}
}
impl<T> Encoder<T> for CborEncoding
where
T: Serialize,
{
type Error = ciborium::ser::Error<std::io::Error>;
type Encoded = Vec<u8>;
fn encode(val: &T) -> Result<Self::Encoded, Self::Error> {
let mut encoded = vec![];
ciborium::into_writer(val, &mut encoded)?;
Ok(encoded)
}
}
impl ContentType for CborEncoding {
const CONTENT_TYPE: &'static str = "application/cbor";
}
impl FormatType for CborEncoding {
const FORMAT_TYPE: Format = Format::Binary;
}
impl<T> Encodes<T> for CborEncoding
where
T: Serialize,
{
type Error = ciborium::ser::Error<std::io::Error>;
fn encode(output: &T) -> Result<Bytes, Self::Error> {
let mut bytes = Vec::new();
ciborium::into_writer(output, &mut bytes)?;
Ok(Bytes::from_owner(bytes))
}
}
impl<T> Decodes<T> for CborEncoding
where
T: DeserializeOwned,
{
type Error = ciborium::de::Error<std::io::Error>;
fn decode(bytes: Bytes) -> Result<T, Self::Error> {
ciborium::from_reader::<T, _>(&*bytes)
}
}

View File

@ -13,6 +13,7 @@
// 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::{
fmt::Display,
num::NonZeroU8,
ops::{Deref, Not},
};
@ -25,11 +26,10 @@ use crate::{
diedto::DiedTo,
error::GameError,
game::{GameTime, Village, night::changes::NightChange},
id_impl,
message::{CharacterIdentity, Identification, PublicIdentity, night::ActionPrompt},
player::{PlayerId, RoleChange},
role::{
Alignment, Killer, KillingWolfOrder, MAPLE_WOLF_ABSTAIN_LIMIT,
Alignment, HunterMut, HunterRef, Killer, KillingWolfOrder, MAPLE_WOLF_ABSTAIN_LIMIT,
Powerful, PreviousGuardianAction, Role, RoleTitle,
},
team::Team,
@ -37,7 +37,23 @@ use crate::{
type Result<T> = core::result::Result<T, GameError>;
id_impl!(CharacterId);
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct CharacterId(uuid::Uuid);
impl CharacterId {
pub fn new() -> Self {
Self(uuid::Uuid::new_v4())
}
pub const fn from_u128(v: u128) -> Self {
Self(uuid::Uuid::from_u128(v))
}
}
impl Display for CharacterId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Character {
@ -50,11 +66,7 @@ pub struct Character {
}
impl Character {
pub fn new(ident: Identification, role: Role, auras: Vec<Aura>) -> Option<Self> {
Self::new_with_character_id(ident, role, auras, CharacterId::new())
}
pub(crate) fn new_with_character_id(
pub fn new(
Identification {
player_id,
public:
@ -66,7 +78,6 @@ impl Character {
}: Identification,
role: Role,
auras: Vec<Aura>,
character_id: CharacterId,
) -> Option<Self> {
Some(Self {
role,
@ -75,7 +86,7 @@ impl Character {
auras: Auras::new(auras),
role_changes: Vec::new(),
identity: CharacterIdentity {
character_id,
character_id: CharacterId::new(),
name,
pronouns,
number: number?,

View File

@ -19,7 +19,7 @@ use werewolves_macros::Titles;
use crate::{character::CharacterId, game::GameTime};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Titles)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Titles)]
pub enum DiedTo {
Execution {
day: NonZeroU8,

View File

@ -15,13 +15,7 @@
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{
game::{GameId, GameTime},
message::PublicIdentity,
player::PlayerId,
role::RoleTitle,
};
use leptos::prelude::ServerFnErrorErr;
use crate::{game::GameTime, message::PublicIdentity, player::PlayerId, role::RoleTitle};
#[derive(Debug, Clone, PartialEq, Error, Serialize, Deserialize)]
pub enum GameError {
@ -51,10 +45,6 @@ pub enum GameError {
HostChannelClosed,
#[error("too many players: there's {got} players but only {need} roles")]
TooManyPlayers { got: u8, need: u8 },
#[error(
"too few players: there's {got} players and this setup would require {need} roles to not be at instant parity"
)]
TooFewPlayers { got: u32, need: u32 },
#[error("it's already daytime")]
AlreadyDaytime,
#[error("it's not the end of the night yet")]
@ -119,165 +109,4 @@ pub enum GameError {
NoCurrentPromptForAura,
#[error("you're not dead")]
NotDead,
#[error("invalid character id assignment for player ID {for_player}")]
InvalidCharacterIdAssignment { for_player: PlayerId },
#[error("already joined")]
AlreadyJoined,
#[error("cannot join own game")]
CannotJoinOwnGame,
#[error("cannot leave a started game")]
CannotLeaveOnceStarted,
#[error("cannot join a started game")]
CannotJoinStartedGame,
#[error("game already started")]
GameAlreadyStarted,
#[error("you're already in another game: {0}")]
AlreadyInAnotherGame(GameId),
}
#[derive(Debug, Clone, PartialEq, Error, Serialize, Deserialize)]
pub enum ServerError {
#[error("internal server error")]
InternalServerError,
#[error("not found")]
NotFound,
#[error("user already exists")]
UserAlreadyExists,
#[error("invalid credentials")]
InvalidCredentials,
#[error("token expired")]
ExpiredToken,
#[error("connection error")]
ConnectionError,
#[error("invalid request: {0}")]
InvalidRequest(String),
#[error("you're already in an active game: {0}")]
AlreadyInActiveGame(GameId),
#[error("not your game")]
NotYourGame,
#[error("this game is already over")]
GameAlreadyOver,
#[error("{0}")]
GameError(#[from] GameError),
}
impl leptos::prelude::FromServerFnError for ServerError {
type Encoder = leptos::server_fn::codec::JsonEncoding;
fn from_server_fn_error(value: ServerFnErrorErr) -> Self {
match value {
ServerFnErrorErr::ServerError(err) => {
log::error!("server error: {err}; truncating to ServerError::InternalServerError");
ServerError::InternalServerError
}
ServerFnErrorErr::MiddlewareError(err) => {
log::error!(
"middleware error: {err}; truncating to ServerError::InternalServerError"
);
ServerError::InternalServerError
}
ServerFnErrorErr::Request(err) => {
const CONN_ERR: &str = "TypeError: NetworkError when attempting to fetch resource.";
if err == CONN_ERR {
Self::ConnectionError
} else {
Self::InvalidRequest(err)
}
}
err => {
let t = match &err {
ServerFnErrorErr::Registration(_) => "Registration",
ServerFnErrorErr::UnsupportedRequestMethod(_) => "UnsupportedRequestMethod",
ServerFnErrorErr::Request(_) => "Request",
ServerFnErrorErr::ServerError(_) => "ServerError",
ServerFnErrorErr::MiddlewareError(_) => "MiddlewareError",
ServerFnErrorErr::Deserialization(_) => "Deserialization",
ServerFnErrorErr::Serialization(_) => "Serialization",
ServerFnErrorErr::Args(_) => "Args",
ServerFnErrorErr::MissingArg(_) => "MissingArg",
ServerFnErrorErr::Response(_) => "Response",
};
Self::InvalidRequest(format!("[{t}]: {err}"))
}
}
}
}
impl From<DatabaseError> for ServerError {
fn from(err: DatabaseError) -> Self {
match err {
DatabaseError::NotFound => ServerError::NotFound,
DatabaseError::UserAlreadyExists => ServerError::UserAlreadyExists,
#[allow(unreachable_patterns)]
_ => {
log::error!(
"converting database error into ServerError::InternalServerError: {err}"
);
ServerError::InternalServerError
}
}
}
}
#[derive(Debug, Clone, PartialEq, Error)]
pub enum DatabaseError {
#[error("user already exists")]
UserAlreadyExists,
#[error("password hashing error: {0}")]
PasswordHashError(String),
#[error("sqlx error: {0}")]
SqlxError(String),
#[error("not found")]
NotFound,
#[error("(de)serialization error: {0}")]
Serialization(String),
}
impl From<leptos::serde_json::Error> for DatabaseError {
fn from(value: leptos::serde_json::Error) -> Self {
Self::Serialization(value.to_string())
}
}
#[cfg(feature = "ssr")]
impl axum::response::IntoResponse for ServerError {
fn into_response(self) -> axum::response::Response {
use axum::{Json, http::StatusCode};
(
match self {
ServerError::NotYourGame
| ServerError::GameAlreadyOver
| ServerError::AlreadyInActiveGame(_)
| ServerError::GameError(_)
| ServerError::InvalidCredentials
| ServerError::InvalidRequest(_)
| ServerError::UserAlreadyExists => StatusCode::BAD_REQUEST,
ServerError::NotFound => StatusCode::NOT_FOUND,
ServerError::ConnectionError | ServerError::InternalServerError => {
StatusCode::INTERNAL_SERVER_ERROR
}
ServerError::ExpiredToken => StatusCode::UNAUTHORIZED,
},
Json(self),
)
.into_response()
}
}
#[cfg(feature = "ssr")]
impl From<sqlx::Error> for DatabaseError {
fn from(err: sqlx::Error) -> Self {
match err {
sqlx::Error::RowNotFound => Self::NotFound,
_ => Self::SqlxError(err.to_string()),
}
}
}
#[cfg(feature = "ssr")]
impl From<argon2::password_hash::Error> for DatabaseError {
fn from(err: argon2::password_hash::Error) -> Self {
Self::PasswordHashError(err.to_string())
}
}

View File

@ -27,10 +27,7 @@ use core::{
use chrono::{DateTime, Utc};
use rand::{Rng, seq::SliceRandom};
use serde::{
Deserialize, Serialize,
de::{Expected, Unexpected},
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
@ -40,7 +37,6 @@ use crate::{
night::{Night, ServerAction},
story::{DayDetail, GameActions, GameStory, NightDetails},
},
id_impl,
message::{
CharacterState, ClientDeadChat, Identification, ServerToClientMessage,
dead::{DeadChatContent, DeadChatMessage},
@ -57,8 +53,6 @@ pub use {
type Result<T> = core::result::Result<T, GameError>;
id_impl!(GameId);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Game {
started: DateTime<Utc>,
@ -67,19 +61,6 @@ pub struct Game {
}
impl Game {
pub fn new_with_assigned_character_ids(
players: &[(Identification, CharacterId)],
settings: GameSettings,
) -> Result<Self> {
let village = Village::new_with_assigned_character_ids(players, settings)?;
Ok(Self {
started: Utc::now(),
history: GameStory::new(village.clone()),
state: GameState::Night {
night: Night::new(village)?,
},
})
}
pub fn new(players: &[Identification], settings: GameSettings) -> Result<Self> {
let village = Village::new(players, settings)?;
Ok(Self {
@ -213,12 +194,9 @@ impl Game {
self.process(HostGameMessage::GetState)
}
(
GameState::Day { village, marked },
GameState::Day { village: _, marked },
HostGameMessage::Day(HostDayMessage::MarkForExecution(target)),
) => {
if village.character_by_id(target)?.died_to().is_some() {
return Err(GameError::CharacterAlreadyDead);
}
match marked
.iter()
.enumerate()
@ -488,70 +466,12 @@ pub enum Maybe {
Maybe,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Copy)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Copy, Serialize, Deserialize)]
pub enum GameTime {
Day { number: NonZeroU8 },
Night { number: u8 },
}
impl Serialize for GameTime {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
GameTime::Day { number } => format!("day_{number}"),
GameTime::Night { number } => format!("night_{number}"),
}
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for GameTime {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
enum ExpectedGameTime {
Format,
NumberU8,
NonZeroU8,
}
impl Expected for ExpectedGameTime {
fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str(match self {
Self::Format => "expected day_{num_nz} or night_{num}, where {num_nz} is a nonzero u8, and {num} is a u8",
Self::NumberU8 => "expected a u8 after day_ or night_",
Self::NonZeroU8 => "expected a non-zero day number",
})
}
}
let s = crate::limited::ClampedString::<5, 9>::deserialize(deserializer)?;
let (day_or_night, number) = s.split_once('_').ok_or(serde::de::Error::invalid_value(
Unexpected::Str(s.as_str()),
&ExpectedGameTime::Format,
))?;
let parsed_number = number.parse::<u8>().map_err(|_| {
serde::de::Error::invalid_value(Unexpected::Str(number), &ExpectedGameTime::NumberU8)
})?;
match day_or_night {
"day" => NonZeroU8::new(parsed_number)
.map(|number| GameTime::Day { number })
.ok_or(serde::de::Error::invalid_value(
Unexpected::Str(number),
&ExpectedGameTime::NonZeroU8,
)),
"night" => Ok(GameTime::Night {
number: parsed_number,
}),
_ => Err(serde::de::Error::invalid_value(
Unexpected::Str(day_or_night),
&ExpectedGameTime::Format,
)),
}
}
}
impl PartialOrd for GameTime {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))

View File

@ -29,12 +29,13 @@ use crate::{
error::GameError,
game::{
GameTime, Village,
kill::{self, KillOutcome},
night::changes::{ChangesLookup, NightChange},
},
message::{
dead::DeadChat,
dead::{DeadChat, DeadChatMessage},
night::{
ActionPrompt, ActionPromptTitle, ActionResponse, ActionResult, Visits,
ActionPrompt, ActionPromptTitle, ActionResponse, ActionResult, ActionType, Visits,
},
},
role::RoleTitle,
@ -828,7 +829,7 @@ impl Night {
NightChange::Protection {
target,
protection: _,
} => target == kill_target || target == *source,
} => target == kill_target,
_ => false,
}) {
// there is protection, so the kill doesn't happen -> no shapeshift

View File

@ -12,7 +12,7 @@
//
// 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::ops::Not;
use core::{num::NonZeroU8, ops::Not};
use super::Result;
use serde::{Deserialize, Serialize};

View File

@ -15,14 +15,17 @@
use core::num::NonZeroU8;
use crate::{
aura::Aura,
aura::{Aura, AuraTitle},
bag::DrunkRoll,
diedto::DiedTo,
error::GameError,
game::night::{
ActionComplete, CurrentResult, Night, NightState, ResponseOutcome, changes::NightChange,
},
message::night::{ActionPrompt, ActionResponse, ActionResult},
message::{
CharacterIdentity,
night::{ActionPrompt, ActionResponse, ActionResult, ActionType},
},
player::Protection,
role::{
Alignment, AlignmentEq, Killer, Powerful, PreviousGuardianAction, RoleBlock, RoleTitle,

View File

@ -23,12 +23,7 @@ use super::Result;
use serde::{Deserialize, Serialize};
use crate::{
character::{Character, CharacterId},
error::GameError,
message::Identification,
role::RoleTitle,
};
use crate::{character::Character, error::GameError, message::Identification, role::RoleTitle};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GameSettings {
@ -120,12 +115,8 @@ impl GameSettings {
}
}
pub fn assign_with_set_character_ids(
&self,
players: &[(Identification, CharacterId)],
) -> Result<Box<[Character]>> {
let idents_only = players.iter().map(|i| i.0.clone()).collect::<Box<_>>();
self.check_with_player_list(&idents_only)?;
pub fn assign(&self, players: &[Identification]) -> Result<Box<[Character]>> {
self.check_with_player_list(players)?;
let roles_in_game = self
.roles
@ -140,7 +131,7 @@ impl GameSettings {
s.assign_to.as_ref().map(|assign_to| {
players
.iter()
.find(|(pid, _)| pid.player_id == *assign_to)
.find(|pid| pid.player_id == *assign_to)
.ok_or(GameError::AssignedPlayerMissing(*assign_to))
.map(|id| (id, s))
})
@ -149,10 +140,10 @@ impl GameSettings {
let mut random_assign_players = players
.iter()
.filter(|(p, _)| {
.filter(|p| {
!with_assigned_roles
.iter()
.any(|((r, _), _)| r.player_id == p.player_id)
.any(|(r, _)| r.player_id == p.player_id)
})
.collect::<Box<[_]>>();
@ -165,29 +156,12 @@ impl GameSettings {
.into_iter()
.zip(self.roles.iter().filter(|s| s.assign_to.is_none())),
)
.map(|((ident, char_id), slot)| {
slot.clone()
.into_character_with_id(ident.clone(), &roles_in_game, *char_id)
})
.map(|(id, slot)| slot.clone().into_character(id.clone(), &roles_in_game))
.collect::<Result<Box<[_]>>>()
}
pub fn assign(&self, players: &[Identification]) -> Result<Box<[Character]>> {
let with_cids = players
.iter()
.map(|ident| (ident.clone(), CharacterId::new()))
.collect::<Box<_>>();
self.assign_with_set_character_ids(&with_cids)
}
pub fn check_with_player_list(&self, players: &[Identification]) -> Result<()> {
self.check()?;
if self.min_players_needed() > players.len() {
return Err(GameError::TooFewPlayers {
got: players.len() as _,
need: self.min_players_needed() as _,
});
}
let (p_len, r_len) = (players.len(), self.roles.len());
if p_len > r_len {
return Err(GameError::TooManyPlayers {

View File

@ -24,9 +24,8 @@ use werewolves_macros::{All, ChecksAs, Titles};
use crate::{
aura::AuraTitle,
character::{Character, CharacterId},
character::Character,
error::GameError,
id_impl,
message::Identification,
player::PlayerId,
role::{Role, RoleTitle},
@ -427,7 +426,14 @@ impl From<RoleTitle> for SetupRole {
}
}
id_impl!(SlotId);
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct SlotId(Uuid);
impl SlotId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SetupSlot {
@ -464,22 +470,17 @@ impl SetupSlot {
)
.ok_or(GameError::PlayerNotAssignedNumber(ident.to_string()))
}
}
pub fn into_character_with_id(
self,
ident: Identification,
roles_in_game: &[RoleTitle],
id: CharacterId,
) -> Result<Character, GameError> {
Character::new_with_character_id(
ident.clone(),
self.role.into_role(roles_in_game)?,
self.auras
.into_iter()
.map(|aura| aura.into_aura())
.collect(),
id,
)
.ok_or(GameError::PlayerNotAssignedNumber(ident.to_string()))
impl Category {
pub const fn class(&self) -> &'static str {
match self {
Category::Wolves => "wolves",
Category::Villager => "village",
Category::Intel => "intel",
Category::Defensive => "defensive",
Category::Offensive => "offensive",
Category::StartsAsVillager => "starts-as-villager",
}
}
}

View File

@ -43,27 +43,6 @@ pub struct Village {
}
impl Village {
pub fn new_with_assigned_character_ids(
players: &[(Identification, CharacterId)],
settings: GameSettings,
) -> Result<Self> {
if settings.min_players_needed() > players.len() {
return Err(GameError::TooManyRoles {
players: players.len() as u8,
roles: settings.min_players_needed() as u8,
});
}
let mut characters = settings.assign_with_set_character_ids(players)?;
assert_eq!(characters.len(), players.len());
characters.sort_by_key(|l| l.number());
Ok(Self {
settings,
characters,
time: GameTime::Night { number: 0 },
dead_chat: DeadChat::new(),
})
}
pub fn new(players: &[Identification], settings: GameSettings) -> Result<Self> {
if settings.min_players_needed() > players.len() {
return Err(GameError::TooManyRoles {

View File

@ -15,7 +15,7 @@
use core::{num::NonZeroU8, ops::Not};
use crate::{
aura::Aura,
aura::{Aura, AuraTitle},
diedto::DiedTo,
error::GameError,
game::{

View File

@ -1,25 +0,0 @@
use crate::{
game::{Game, GameSettings, story::GameStory},
player::PlayerId,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub use crate::game::GameId;
#[cfg_attr(feature = "ssr", derive(::sqlx::FromRow))]
#[derive(Debug)]
pub struct GameRecord {
pub id: GameId,
pub host: PlayerId,
pub created_at: DateTime<Utc>,
pub game_state: GameRecordState,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GameRecordState {
Lobby(GameSettings),
RoleReveal(Game),
Started(Game),
GameOver(GameStory),
}

View File

@ -24,7 +24,7 @@ mod time;
use crate::{
character::{Character, CharacterId},
diedto::DiedToTitle,
error::{GameError, ServerError},
error::GameError,
game::{Game, GameOver, GameSettings, OrRandom, SetupRole, SetupSlot},
message::{
CharacterState, Identification, PublicIdentity,
@ -803,12 +803,11 @@ fn wolfpack_kill_all_targets_valid() {
for (idx, target) in living_villagers.into_iter().enumerate() {
let mut attempt = game.clone();
if let ServerToHostMessage::Error(ServerError::GameError(GameError::InvalidTarget)) =
attempt
.process(HostGameMessage::Night(HostNightMessage::ActionResponse(
ActionResponse::MarkTarget(target.character_id),
)))
.unwrap()
if let ServerToHostMessage::Error(GameError::InvalidTarget) = attempt
.process(HostGameMessage::Night(HostNightMessage::ActionResponse(
ActionResponse::MarkTarget(target.character_id),
)))
.unwrap()
{
panic!("invalid target {target:?} at index [{idx}]");
}
@ -1120,7 +1119,7 @@ fn big_game_test_based_on_story_test() {
);
game.execute().title().vindicator();
game.mark(game.character_by_player_id(insomniac).character_id());
game.mark(game.character_by_player_id(shapeshifter).character_id());
game.r#continue().sleep();
game.next().title().wolf_pack_kill();
@ -1128,16 +1127,11 @@ fn big_game_test_based_on_story_test() {
game.r#continue().r#continue();
game.next().title().shapeshifter();
assert_eq!(
game.process(HostGameMessage::Night(HostNightMessage::ActionResponse(
ActionResponse::Shapeshift,
)))
.expect("shapeshift"),
ServerToHostMessage::ActionResult(
Some(game.character_by_player_id(shapeshifter).identity()),
ActionResult::Continue
)
);
game.process(HostGameMessage::Night(HostNightMessage::ActionResponse(
ActionResponse::Shapeshift,
)))
.expect("shapeshift");
// game.r#continue().r#continue();
assert_eq!(
game.next(),

View File

@ -444,52 +444,3 @@ fn shapeshift_removes_village_prompt_but_previous_can_bring_it_back() {
None
);
}
#[test]
fn shapeshifter_protected_when_shifting_prevents_shift() {
init_log();
let players = gen_players(1..21);
let mut player_ids = players.iter().map(|p| p.player_id);
let shapeshifter = player_ids.next().unwrap();
let wolf = player_ids.next().unwrap();
let protector = player_ids.next().unwrap();
let hunter = player_ids.next().unwrap();
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::Shapeshifter, shapeshifter);
settings.add_and_assign(SetupRole::Werewolf, wolf);
settings.add_and_assign(SetupRole::Protector, protector);
settings.add_and_assign(SetupRole::Hunter, hunter);
settings.fill_remaining_slots_with_villagers(players.len());
let mut game = Game::new(&players, settings).unwrap();
game.r#continue().r#continue();
assert_eq!(game.next().title(), ActionPromptTitle::WolvesIntro);
game.r#continue().sleep();
game.next_expect_day();
game.execute().title().protector();
game.mark(game.character_by_player_id(shapeshifter).character_id());
game.r#continue().sleep();
game.next().title().wolf_pack_kill();
game.mark(game.character_by_player_id(hunter).character_id());
game.r#continue().r#continue();
game.next().title().shapeshifter();
match game
.process(HostGameMessage::Night(HostNightMessage::ActionResponse(
ActionResponse::Shapeshift,
)))
.unwrap()
{
ServerToHostMessage::ActionResult(_, res) => assert_eq!(res, ActionResult::ShiftFailed),
other => panic!("expected action result, got {other:?}"),
}
game.r#continue().sleep();
game.next().title().hunter();
game.mark_villager();
game.r#continue().sleep();
game.next_expect_day();
}

View File

@ -15,121 +15,14 @@
#![allow(clippy::new_without_default)]
pub mod aura;
pub mod bag;
#[cfg(feature = "ssr")]
pub mod cbor;
pub mod cbor_leptos;
pub mod character;
pub mod diedto;
pub mod error;
pub mod game;
pub mod game_record;
#[cfg(test)]
mod game_test;
pub mod limited;
mod log;
pub mod message;
pub mod nonzero;
pub mod player;
pub mod role;
pub mod team;
pub mod token;
pub use log::*;
pub type ServerResult<T> = core::result::Result<T, error::ServerError>;
#[macro_export]
macro_rules! id_impl {
($name:ident) => {
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, ::serde::Serialize, ::serde::Deserialize,
)]
pub struct $name(uuid::Uuid);
#[cfg(feature = "ssr")]
impl sqlx::TypeInfo for $name {
fn is_null(&self) -> bool {
self.0 == uuid::Uuid::nil()
}
fn name(&self) -> &str {
"uuid"
}
}
#[cfg(feature = "ssr")]
impl sqlx::Type<sqlx::Postgres> for $name {
fn type_info() -> <sqlx::Postgres as sqlx::Database>::TypeInfo {
<uuid::Uuid as sqlx::Type<sqlx::Postgres>>::type_info()
}
}
#[cfg(feature = "ssr")]
impl<'q> sqlx::Encode<'q, sqlx::Postgres> for $name {
fn encode_by_ref(
&self,
buf: &mut <sqlx::Postgres as sqlx::Database>::ArgumentBuffer<'q>,
) -> core::result::Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
self.0.encode_by_ref(buf)
}
}
#[cfg(feature = "ssr")]
impl<'r> sqlx::Decode<'r, sqlx::Postgres> for $name {
fn decode(
value: <sqlx::Postgres as sqlx::Database>::ValueRef<'r>,
) -> core::result::Result<Self, sqlx::error::BoxDynError> {
Ok(Self(uuid::Uuid::decode(value)?))
}
}
impl From<uuid::Uuid> for $name {
fn from(value: uuid::Uuid) -> Self {
Self::from_uuid(value)
}
}
impl From<$name> for uuid::Uuid {
fn from(value: $name) -> Self {
value.into_uuid()
}
}
impl Default for $name {
fn default() -> Self {
Self::new()
}
}
impl $name {
pub fn new() -> Self {
Self(uuid::Uuid::new_v4())
}
pub const fn from_uuid(uuid: uuid::Uuid) -> Self {
Self(uuid)
}
pub const fn into_uuid(self) -> uuid::Uuid {
self.0
}
pub const fn from_u128(u: u128) -> Self {
Self(::uuid::Uuid::from_u128(u))
}
}
impl core::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> core::fmt::Result {
core::fmt::Display::fmt(&self.0, f)
}
}
impl core::str::FromStr for $name {
type Err = ::uuid::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
uuid::Uuid::parse_str(s).map(Self)
}
}
};
}

View File

@ -1,139 +0,0 @@
use core::{
fmt::Display,
ops::{Deref, RangeInclusive},
};
use serde::{Deserialize, Deserializer, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct FixedLenString<const LEN: usize>(String);
impl<const LEN: usize> Display for FixedLenString<LEN> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl<const LEN: usize> FixedLenString<LEN> {
pub fn new(s: String) -> Option<Self> {
(s.chars().take(LEN + 1).count() == LEN).then_some(Self(s))
}
pub unsafe fn new_unchecked(s: String) -> Self {
Self(s)
}
}
impl<const LEN: usize> Deref for FixedLenString<LEN> {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<'de, const LEN: usize> Deserialize<'de> for FixedLenString<LEN> {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct ExpectedLen(usize);
impl serde::de::Expected for ExpectedLen {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "a string exactly {} characters long", self.0)
}
}
<String as Deserialize>::deserialize(deserializer).and_then(|s| {
let char_count = s.chars().take(LEN.saturating_add(1)).count();
if char_count != LEN {
Err(serde::de::Error::invalid_length(
char_count,
&ExpectedLen(LEN),
))
} else {
Ok(Self(s))
}
})
}
}
impl<const LEN: usize> Serialize for FixedLenString<LEN> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.0.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ClampedString<const MIN: usize, const MAX: usize>(String);
impl<const MIN: usize, const MAX: usize> ClampedString<MIN, MAX> {
pub const MIN_LEN: usize = MIN;
pub const MAX_LEN: usize = MAX;
pub fn new(s: String) -> Result<Self, RangeInclusive<usize>> {
let str_len = s.chars().take(MAX.saturating_add(1)).count();
(str_len >= MIN && str_len <= MAX)
.then_some(Self(s))
.ok_or(MIN..=MAX)
}
pub unsafe fn new_unchecked(s: String) -> Self {
Self(s)
}
pub fn into_inner(self) -> String {
self.0
}
}
impl<const MIN: usize, const MAX: usize> Display for ClampedString<MIN, MAX> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl<const MIN: usize, const MAX: usize> Deref for ClampedString<MIN, MAX> {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<'de, const MIN: usize, const MAX: usize> Deserialize<'de> for ClampedString<MIN, MAX> {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct ExpectedLen(usize, usize);
impl serde::de::Expected for ExpectedLen {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
f,
"a string between {} and {} characters long",
self.0, self.1
)
}
}
<String as Deserialize>::deserialize(deserializer).and_then(|s| {
let char_count = s.chars().take(MAX.saturating_add(1)).count();
if char_count < MIN || char_count > MAX {
Err(serde::de::Error::invalid_length(
char_count,
&ExpectedLen(MIN, MAX),
))
} else {
Ok(Self(s))
}
})
}
}
impl<const MIN: usize, const MAX: usize> Serialize for ClampedString<MIN, MAX> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.0.as_str())
}
}

View File

@ -1,59 +0,0 @@
pub trait LogError {
fn log(self, loc: CodePath, level: log::Level);
fn log_warn(self, loc: CodePath);
fn log_err(self, loc: CodePath);
fn log_debug(self, loc: CodePath);
}
pub struct CodePath {
pub module_path: &'static str,
pub loc: &'static std::panic::Location<'static>,
}
#[macro_export]
macro_rules! loc {
() => {
(::werewolves_proto::CodePath {
module_path: log::__private_api::module_path!(),
loc: log::__private_api::loc(),
})
};
}
impl<T, E> LogError for Result<T, E>
where
E: core::fmt::Display,
{
fn log(self, loc: CodePath, lvl: log::Level) {
let Err(err) = self else {
return;
};
if lvl <= log::STATIC_MAX_LEVEL && lvl <= log::max_level() {
log::__private_api::log(
log::__log_logger!(__log_global_logger),
log::__private_api::format_args!("{err}"),
lvl,
&(loc.module_path, loc.module_path, loc.loc),
(),
);
}
}
fn log_warn(self, loc: CodePath) {
if self.is_err() {
self.log(loc, log::Level::Warn);
}
}
fn log_err(self, loc: CodePath) {
if self.is_err() {
self.log(loc, log::Level::Error);
}
}
fn log_debug(self, loc: CodePath) {
if self.is_err() {
self.log(loc, log::Level::Debug);
}
}
}

View File

@ -17,20 +17,18 @@ pub mod host;
mod ident;
pub mod night;
use crate::{
message::host::{HostMessage, ServerToHostMessage},
token::TokenString,
};
use core::num::NonZeroU8;
use chrono::{DateTime, Utc};
pub use ident::*;
use serde::{Deserialize, Serialize};
use werewolves_macros::Titles;
use crate::{
character::CharacterId, error::GameError, game::story::GameStory,
message::dead::DeadChatMessage, role::RoleTitle,
character::CharacterId,
error::GameError,
game::{GameOver, story::GameStory},
message::dead::DeadChatMessage,
role::RoleTitle,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@ -64,19 +62,20 @@ pub struct DayCharacter {
pub alive: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Titles)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ServerToClientMessage {
GameCancelled,
Disconnect,
LobbyInfo {
joined: bool,
players: Box<[PublicIdentity]>,
current_number: Option<NonZeroU8>,
},
GameInProgress,
GameStart {
role: RoleTitle,
},
InvalidMessageForGameState,
NoSuchTarget,
GameOver(GameOver),
Story(GameStory),
Update(PlayerUpdate),
DeadChat(Box<[DeadChatMessage]>),
@ -86,38 +85,7 @@ pub enum ServerToClientMessage {
Error(GameError),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PlayerUpdate {
Number(NonZeroU8),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WrappedServerMessage {
Authentication(TokenString),
HostMessage(HostMessage),
ClientMessage(ClientMessage),
}
impl From<TokenString> for WrappedServerMessage {
fn from(value: TokenString) -> Self {
Self::Authentication(value)
}
}
impl From<HostMessage> for WrappedServerMessage {
fn from(value: HostMessage) -> Self {
Self::HostMessage(value)
}
}
impl From<ClientMessage> for WrappedServerMessage {
fn from(value: ClientMessage) -> Self {
Self::ClientMessage(value)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum IntoClientResponse {
Player(ServerToClientMessage),
Host(ServerToHostMessage),
}

View File

@ -13,7 +13,6 @@
// 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::hash::Hash;
use std::collections::HashMap;
use chrono::{DateTime, TimeDelta, Utc};
@ -198,32 +197,14 @@ impl DeadChat {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DeadChatMessage {
pub id: Uuid,
pub timestamp: DateTime<Utc>,
pub message: DeadChatContent,
}
impl Hash for DeadChatMessage {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}
impl Ord for DeadChatMessage {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.id.cmp(&other.id)
}
}
impl PartialOrd for DeadChatMessage {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DeadChatContent {
PlayerMessage {
from: CharacterIdentity,

View File

@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize};
use crate::{
character::CharacterId,
error::{GameError, ServerError},
error::GameError,
game::{GameOver, GameSettings, story::GameStory},
message::{
CharacterIdentity,
@ -37,11 +37,8 @@ pub enum HostMessage {
Lobby(HostLobbyMessage),
InGame(HostGameMessage),
ForceRoleAckFor(CharacterId),
#[cfg(debug_assertions)]
ForceAllRoleAcks,
PostGame(PostGameMessage),
Echo(ServerToHostMessage),
CancelGame,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -95,7 +92,6 @@ pub enum HostLobbyMessage {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, werewolves_macros::Titles)]
pub enum ServerToHostMessage {
GameCancelled,
Disconnect,
Daytime {
characters: Box<[CharacterState]>,
@ -109,9 +105,9 @@ pub enum ServerToHostMessage {
Lobby {
players: Box<[PlayerState]>,
settings: GameSettings,
qr_mode: bool,
},
Error(ServerError),
QrMode(bool),
Error(GameError),
GameOver(GameOver),
WaitingForRoleRevealAcks {
ackd: Box<[CharacterIdentity]>,

View File

@ -31,7 +31,7 @@ pub struct PublicIdentity {
pub number: Option<NonZeroU8>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CharacterIdentity {
pub character_id: CharacterId,
pub name: String,

View File

@ -252,7 +252,7 @@ impl ActionPrompt {
| ActionPrompt::Insomniac { .. } => true,
}
}
pub const fn marked(&self) -> Option<(CharacterId, Option<CharacterId>)> {
pub(crate) const fn marked(&self) -> Option<(CharacterId, Option<CharacterId>)> {
match self {
ActionPrompt::Seer { marked, .. }
| ActionPrompt::Protector { marked, .. }
@ -581,46 +581,6 @@ impl ActionPrompt {
_ => false,
}
}
#[rustfmt::skip]
pub fn targets(&self) -> Option<&[CharacterIdentity]> {
match self {
ActionPrompt::Seer { living_players: targets,.. }
| ActionPrompt::Protector { targets,.. }
| ActionPrompt::Arcanist { living_players: targets,.. }
| ActionPrompt::Gravedigger { dead_players: targets,.. }
| ActionPrompt::Hunter { living_players: targets,.. }
| ActionPrompt::Militia { living_players: targets,.. }
| ActionPrompt::MapleWolf { living_players: targets,.. }
| ActionPrompt::Guardian { living_players: targets,.. }
| ActionPrompt::Adjudicator { living_players: targets,.. }
| ActionPrompt::PowerSeer { living_players: targets,.. }
| ActionPrompt::Mortician { dead_players: targets,.. }
| ActionPrompt::BeholderChooses { living_players: targets,.. }
| ActionPrompt::MasonLeaderRecruit { potential_recruits: targets,.. }
| ActionPrompt::Empath { living_players: targets,.. }
| ActionPrompt::Vindicator { living_players: targets,.. }
| ActionPrompt::PyreMaster { living_players: targets,.. }
| ActionPrompt::WolfPackKill { living_villagers: targets,.. }
| ActionPrompt::AlphaWolf { living_villagers: targets,.. }
| ActionPrompt::DireWolf { living_players: targets,.. }
| ActionPrompt::LoneWolfKill { living_players: targets, .. }
| ActionPrompt::Bloodletter {
living_players: targets,
..
} => Some(&**targets),
ActionPrompt::WolvesIntro { .. }
| ActionPrompt::RoleChange { .. }
| ActionPrompt::Shapeshifter { .. }
| ActionPrompt::ElderReveal { .. }
| ActionPrompt::Insomniac { .. }
| ActionPrompt::CoverOfDarkness
| ActionPrompt::MasonsWake { .. }
| ActionPrompt::BeholderWakes { .. }
| ActionPrompt::DamnedIntro { .. } => None,
}
}
}
impl PartialOrd for ActionPrompt {

View File

@ -12,20 +12,32 @@
//
// 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::fmt::Display;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{
character::CharacterId,
id_impl,
limited::ClampedString,
message::PublicIdentity,
role::{Role, RoleTitle},
token::TokenString,
};
id_impl!(PlayerId);
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct PlayerId(uuid::Uuid);
impl PlayerId {
pub fn new() -> Self {
Self(uuid::Uuid::new_v4())
}
pub const fn from_u128(v: u128) -> Self {
Self(uuid::Uuid::from_u128(v))
}
}
impl Display for PlayerId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Player {
@ -61,48 +73,3 @@ pub struct RoleChange {
pub new_role: RoleTitle,
pub changed_on_night: u8,
}
pub type Username = ClampedString<1, 0x40>;
pub type Password = ClampedString<6, 0x100>;
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct UserLogin {
pub username: Username,
pub password: Password,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct ChangePassword {
pub current: Password,
pub new: Password,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct DeleteUserRequest {
pub password: Password,
}
#[cfg_attr(feature = "ssr", derive(::sqlx::FromRow))]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub username: String,
pub display_name: Option<String>,
pub pronouns: Option<String>,
pub user_created_at: DateTime<Utc>,
pub user_updated_at: DateTime<Utc>,
pub token_created_at: DateTime<Utc>,
pub token_expires_at: DateTime<Utc>,
pub token: TokenString,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileUpdate {
pub display_name: Option<String>,
pub pronouns: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PlayerIdentity {
pub player_id: PlayerId,
pub character_id: CharacterId,
pub public: PublicIdentity,
}

View File

@ -20,7 +20,7 @@ use werewolves_macros::{All, ChecksAs, RefAndMut, Titles};
use crate::{
character::CharacterId,
diedto::DiedTo,
game::{Category, GameTime, Village},
game::{GameTime, Village},
message::CharacterIdentity,
};
@ -122,59 +122,50 @@ pub enum Role {
#[checks(Alignment::Village)]
#[checks(Killer::NotKiller)]
#[checks(Powerful::NotPowerful)]
#[checks(Category::Villager)]
Villager,
#[checks(Alignment::Wolves)]
#[checks(Killer::Killer)]
#[checks(Powerful::Powerful)]
#[checks(Category::Villager)]
Scapegoat { redeemed: bool },
#[checks(Alignment::Village)]
#[checks(Powerful::Powerful)]
#[checks(Killer::NotKiller)]
#[checks("is_mentor")]
#[checks("doesnt_wake_if_died_tonight")]
#[checks(Category::Intel)]
Seer,
#[checks(Alignment::Village)]
#[checks(Powerful::Powerful)]
#[checks(Killer::NotKiller)]
#[checks("is_mentor")]
#[checks("doesnt_wake_if_died_tonight")]
#[checks(Category::Intel)]
Arcanist,
#[checks(Alignment::Village)]
#[checks(Powerful::Powerful)]
#[checks(Killer::NotKiller)]
#[checks("is_mentor")]
#[checks("doesnt_wake_if_died_tonight")]
#[checks(Category::Intel)]
Adjudicator,
#[checks(Alignment::Village)]
#[checks(Powerful::Powerful)]
#[checks(Killer::NotKiller)]
#[checks("is_mentor")]
#[checks("doesnt_wake_if_died_tonight")]
#[checks(Category::Intel)]
PowerSeer,
#[checks(Alignment::Village)]
#[checks(Powerful::Powerful)]
#[checks(Killer::NotKiller)]
#[checks("is_mentor")]
#[checks("doesnt_wake_if_died_tonight")]
#[checks(Category::Intel)]
Mortician,
#[checks(Alignment::Village)]
#[checks(Powerful::Powerful)]
#[checks(Killer::NotKiller)]
#[checks("is_mentor")]
#[checks("doesnt_wake_if_died_tonight")]
#[checks(Category::Intel)]
Beholder,
#[checks(Alignment::Village)]
#[checks(Powerful::Powerful)]
#[checks(Killer::NotKiller)]
#[checks(Category::Intel)]
MasonLeader {
recruits_available: u8,
recruits: Box<[CharacterId]>,
@ -182,69 +173,58 @@ pub enum Role {
#[checks(Alignment::Village)]
#[checks(Powerful::Powerful)]
#[checks(Killer::NotKiller)]
#[checks(Category::Intel)]
Empath { cursed: bool },
#[checks(Alignment::Village)]
#[checks(Powerful::Powerful)]
#[checks(Killer::NotKiller)]
#[checks("is_mentor")]
#[checks(Category::Defensive)]
Vindicator,
#[checks(Alignment::Village)]
#[checks(Powerful::Powerful)]
#[checks(Killer::NotKiller)]
#[checks("is_mentor")]
#[checks(Category::Defensive)]
Diseased,
#[checks(Alignment::Village)]
#[checks(Powerful::Powerful)]
#[checks(Killer::NotKiller)]
#[checks("is_mentor")]
#[checks(Category::Defensive)]
BlackKnight { attacked: Option<DiedTo> },
#[checks(Alignment::Village)]
#[checks(Powerful::Powerful)]
#[checks(Killer::NotKiller)]
#[checks("is_mentor")]
#[checks(Category::Offensive)]
Weightlifter,
#[checks(Alignment::Village)]
#[checks(Powerful::Powerful)]
#[checks(Killer::Killer)]
#[checks("is_mentor")]
#[checks(Category::Offensive)]
PyreMaster { villagers_killed: u8 },
#[checks(Alignment::Village)]
#[checks(Powerful::Powerful)]
#[checks(Killer::NotKiller)]
#[checks("is_mentor")]
#[checks("doesnt_wake_if_died_tonight")]
#[checks(Category::Intel)]
Gravedigger,
#[checks(Alignment::Village)]
#[checks(Killer::Killer)]
#[checks(Powerful::Powerful)]
#[checks("is_mentor")]
#[checks]
#[checks(Category::Offensive)]
Hunter { target: Option<CharacterId> },
#[checks(Alignment::Village)]
#[checks(Killer::Killer)]
#[checks(Powerful::Powerful)]
#[checks("is_mentor")]
#[checks(Category::Offensive)]
Militia { targeted: Option<CharacterId> },
#[checks(Alignment::Wolves)]
#[checks(Killer::Killer)]
#[checks(Powerful::Powerful)]
#[checks("is_mentor")]
#[checks(Category::Offensive)]
MapleWolf { last_kill_on_night: u8 },
#[checks(Alignment::Village)]
#[checks(Powerful::Powerful)]
#[checks(Killer::Killer)]
#[checks("is_mentor")]
#[checks(Category::Defensive)]
Guardian {
last_protected: Option<PreviousGuardianAction>,
},
@ -252,17 +232,14 @@ pub enum Role {
#[checks(Powerful::Powerful)]
#[checks(Killer::NotKiller)]
#[checks("is_mentor")]
#[checks(Category::Defensive)]
Protector { last_protected: Option<CharacterId> },
#[checks(Alignment::Village)]
#[checks(Powerful::Powerful)]
#[checks(Killer::NotKiller)]
#[checks(Category::StartsAsVillager)]
Apprentice(RoleTitle),
#[checks(Alignment::Village)]
#[checks(Powerful::Powerful)]
#[checks(Killer::NotKiller)]
#[checks(Category::StartsAsVillager)]
Elder {
knows_on_night: NonZeroU8,
woken_for_reveal: bool,
@ -272,7 +249,6 @@ pub enum Role {
#[checks(Powerful::Powerful)]
#[checks(Killer::NotKiller)]
#[checks("doesnt_wake_if_died_tonight")]
#[checks(Category::Intel)]
Insomniac,
#[checks(Alignment::Wolves)]
@ -280,39 +256,33 @@ pub enum Role {
#[checks(Powerful::Powerful)]
#[checks("wolf")]
#[checks("killing_wolf")]
#[checks(Category::Wolves)]
Werewolf,
#[checks(Alignment::Wolves)]
#[checks(Killer::Killer)]
#[checks(Powerful::Powerful)]
#[checks("wolf")]
#[checks("killing_wolf")]
#[checks(Category::Wolves)]
AlphaWolf { killed: Option<CharacterId> },
#[checks(Alignment::Village)]
#[checks(Killer::Killer)]
#[checks(Powerful::Powerful)]
#[checks("wolf")]
#[checks(Category::Wolves)]
DireWolf { last_blocked: Option<CharacterId> },
#[checks(Alignment::Wolves)]
#[checks(Killer::Killer)]
#[checks(Powerful::Powerful)]
#[checks("wolf")]
#[checks("killing_wolf")]
#[checks(Category::Wolves)]
Shapeshifter { shifted_into: Option<CharacterId> },
#[checks(Alignment::Wolves)]
#[checks(Killer::Killer)]
#[checks(Powerful::Powerful)]
#[checks("wolf")]
#[checks(Category::Wolves)]
LoneWolf,
#[checks(Alignment::Wolves)]
#[checks(Killer::Killer)]
#[checks(Powerful::Powerful)]
#[checks("wolf")]
#[checks(Category::Wolves)]
Bloodletter,
}

View File

@ -1,58 +0,0 @@
// Copyright (C) 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 crate::{limited::FixedLenString, player::Username};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub const TOKEN_LEN: usize = 0x20;
pub type TokenString = FixedLenString<TOKEN_LEN>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Token {
pub token: TokenString,
pub username: Username,
pub display_name: Option<String>,
pub pronouns: Option<String>,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
impl Token {
pub fn login_token(&self) -> TokenLogin {
TokenLogin(self.token.clone())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TokenLogin(pub FixedLenString<TOKEN_LEN>);
#[cfg(feature = "ssr")]
impl axum_extra::headers::authorization::Credentials for TokenLogin {
const SCHEME: &'static str = "Bearer";
fn decode(value: &axum::http::HeaderValue) -> Option<Self> {
value
.to_str()
.ok()
.and_then(|v| FixedLenString::new(v.strip_prefix("Bearer ").unwrap_or(v).to_string()))
.map(Self)
}
fn encode(&self) -> axum::http::HeaderValue {
axum::http::HeaderValue::from_str(self.0.as_str()).expect("bearer token encode")
}
}

View File

@ -0,0 +1,2 @@
[target.wasm32-unknown-unknown]
rustflags = ['--cfg', 'getrandom_backend="wasm_js"']

1572
werewolves/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3,83 +3,45 @@ name = "werewolves"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "rlib"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
leptos = { workspace = true }
leptos_router = { workspace = true }
axum = { workspace = true, optional = true }
console_error_panic_hook = { version = "0.1", optional = true }
leptos_axum = { workspace = true, optional = true }
leptos_meta = { workspace = true }
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
wasm-bindgen = { workspace = true, optional = true }
getrandom = { version = "=0.3.4", optional = true }
colored = { workspace = true, optional = true }
pretty_env_logger = { workspace = true, optional = true }
sqlx = { workspace = true, optional = true }
tower-http = { workspace = true, optional = true }
mime-sniffer = { version = "0.1", optional = true }
futures = { version = "0.3", optional = true }
wasm-logger = { version = "0.2" }
gloo = { version = "0.11" }
leptos-use = { workspace = true }
serde = { workspace = true, features = ["derive"] }
rand = { workspace = true }
reactive_stores = { version = "0.3" }
axum-extra = { workspace = true, optional = true }
anyhow = { workspace = true, optional = true }
bytes = { workspace = true, optional = true }
fast_qr = { workspace = true, optional = true }
argon2 = { workspace = true, optional = true }
log.workspace = true
uuid.workspace = true
chrono.workspace = true
werewolves-macros.workspace = true
werewolves-proto.workspace = true
codee.workspace = true
convert_case.workspace = true
thiserror.workspace = true
sorted-vec.workspace = true
web-sys = { version = "0.3", features = [
"HtmlTableCellElement",
"Event",
"EventTarget",
"HtmlImageElement",
"HtmlDivElement",
"HtmlSelectElement",
"HtmlDialogElement",
"DomRect",
"WheelEvent",
] }
wasm-bindgen = { version = "=0.2.100" }
log = "0.4"
rand = { version = "0.9", features = ["small_rng"] }
getrandom = { version = "0.3", features = ["wasm_js"] }
uuid = { version = "*", features = ["js"] }
yew = { version = "0.22", features = ["csr"] }
yew-router = "0.19"
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", optional = true }
gloo = "0.11"
wasm-logger = "0.2"
instant = { version = "0.1", features = ["wasm-bindgen"] }
once_cell = "1"
chrono = { version = "0.4" }
werewolves-macros = { path = "../werewolves-macros" }
werewolves-proto = { path = "../werewolves-proto" }
futures = "0.3"
wasm-bindgen-futures = "0.4"
thiserror = { version = "2" }
convert_case = { version = "0.10" }
ciborium = { version = "0.2", optional = true }
chrono-humanize = { version = "0.2.3", features = ["wasmbind"] }
[features]
hydrate = [
"leptos/hydrate",
"dep:console_error_panic_hook",
"dep:wasm-bindgen",
"uuid/js",
"dep:getrandom",
"getrandom/wasm_js",
]
ssr = [
"dep:axum",
"dep:tokio",
"dep:argon2",
"dep:leptos_axum",
"dep:colored",
"dep:pretty_env_logger",
"dep:sqlx",
"dep:tower-http",
"dep:mime-sniffer",
"dep:futures",
"dep:axum-extra",
"dep:anyhow",
"dep:bytes",
"dep:fast_qr",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"leptos-use/ssr",
"leptos-use/axum",
"werewolves-proto/ssr",
]
# Defines a size-optimized profile for the WASM bundle in release mode
[profile.wasm-release]
inherits = "release"
opt-level = 'z'
lto = true
codegen-units = 1
panic = "abort"
default = ["cbor"]
# default = ["json"]
cbor = ["dep:ciborium"]
json = ["dep:serde_json"]

14
werewolves/Trunk.toml Normal file
View File

@ -0,0 +1,14 @@
[build]
target = "index.html" # The index HTML file to drive the bundling process.
html_output = "index.html" # The name of the output HTML file.
release = true # Build in release mode.
dist = "dist" # The output dir for all final assets.
public_url = "/" # The public URL from which assets are to be served.
filehash = true # Whether to include hash values in the output file names.
inject_scripts = true # Whether to inject scripts (and module preloads) into the finalized output.
offline = false # Run without network access
frozen = false # Require Cargo.lock and cache are up to date
locked = false # Require Cargo.lock is up to date
# minify = "on_release" # Control minification: can be one of: never, on_release, always
minify = "always" # Control minification: can be one of: never, on_release, always
no_sri = false # Allow disabling sub-resource integrity (SRI)

File diff suppressed because one or more lines are too long

View File

@ -1,78 +0,0 @@
use std::{fs::File, io::Write};
const STYLESHEET_PATH: &str = "../style/faction.scss";
fn main() {
println!("cargo::rerun-if-changed=../style/main.scss");
let mut sheet_file = File::create(STYLESHEET_PATH).unwrap();
let mut out = String::new();
for faction in [
"village",
"wolves",
"offensive",
"defensive",
"intel",
"starts-as-villager",
"damned",
"drunk",
] {
let name = faction.replace("-", "_");
out += format!(
r#"
.{faction} {{
--faction-color: ${name}_color;
--faction-border: ${name}_border;
--faction-color-faint: ${name}_color_faint;
--faction-border-faint: ${name}_border_faint;
&.box {{
background-color: ${name}_color;
border: 1px solid ${name}_border;
.selected:not(.faint) {{
color: white;
background-color: ${name}_border;
}}
.selected.faint {{
color: white;
background-color: ${name}_border_faint;
}}
&.hover:not(.selected):hover {{
color: white;
background-color: ${name}_border;
}}
&.faint:not(.selected) {{
border: 1px solid ${name}_border_faint;
background-color: ${name}_color_faint;
&.hover:hover {{
background-color: ${name}_border_faint;
}}
}}
}}
&.underline {{
text-decoration: ${name}_color underline;
&.faint {{
text-decoration: ${name}_color_faint underline;
}}
}}
&.text-color {{
color: ${name}_border;
&.faint {{
color: ${name}_border_faint;
}}
}}
}}
"#
)
.as_str();
}
sheet_file.write_all(out.as_bytes()).unwrap();
}

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

Before

Width:  |  Height:  |  Size: 979 B

After

Width:  |  Height:  |  Size: 979 B

View File

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 776 B

After

Width:  |  Height:  |  Size: 776 B

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

20
werewolves/index.html Normal file
View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>werewolves</title>
<link rel="icon" href="/img/wolf.svg" />
<link rel="stylesheet" href="/assets/fonts/liberation-serif.css" />
<link data-trunk rel="sass" href="index.scss" />
<link data-trunk rel="copy-dir" href="img">
<link data-trunk rel="copy-dir" href="assets">
</head>
<body>
<app></app>
<error></error>
</body>
</html>

3144
werewolves/index.scss Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,153 +0,0 @@
pub mod pages {
werewolves_macros::include_path!("werewolves/src/app/pages");
pub mod night_actions {
werewolves_macros::include_path!("werewolves/src/app/pages/night_actions");
pub mod role {
werewolves_macros::include_path!("werewolves/src/app/pages/night_actions/role");
}
}
}
pub mod components {
werewolves_macros::include_path!("werewolves/src/app/components");
pub mod input {
werewolves_macros::include_path!("werewolves/src/app/components/input");
}
}
pub mod class;
pub mod error;
pub mod storage;
use codee::string::JsonSerdeCodec;
use leptos::prelude::*;
use leptos_meta::{Link, MetaTags, Stylesheet, Title, provide_meta_context};
use leptos_router::{
components::{ProtectedRoute, Route, Router, Routes},
path,
};
use leptos_use::storage::use_local_storage;
use reactive_stores::Store;
use serde::{Deserialize, Serialize};
use crate::{
app::{
components::{ErrorBox, Nav},
error::WolfError,
pages::{GamePage, Main, NotFound, Signin, Signup, UserSettings, big::BigScreen},
storage::{
Stored,
user::{AuthContext, AuthContextStoreFields},
},
},
state::{InitOrUpdateStore, SessionState},
};
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Link rel="icon" href="/favicon.svg" type_="text/xml+svg" />
<AutoReload options=options.clone() />
<HydrationScripts options />
<MetaTags />
</head>
<body>
<App />
</body>
</html>
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq, Deserialize)]
pub struct Preferences {
pub tutorials_enabled: bool,
pub show_cancel_game: bool,
}
impl Default for Preferences {
fn default() -> Self {
Self {
tutorials_enabled: true,
show_cancel_game: true,
}
}
}
impl Stored for Preferences {
const STORAGE_KEY: &str = "preferences";
}
#[component]
pub fn App() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context();
let auth_store = Store::new(AuthContext::new());
provide_context(auth_store);
let session_store = Store::new(SessionState::new());
provide_context(session_store);
Effect::new(move || auth_store.init_or_update());
Effect::new(move || session_store.init_or_update());
let (pref_read, pref_write, _) =
use_local_storage::<Preferences, JsonSerdeCodec>(Preferences::STORAGE_KEY);
provide_context((pref_read, pref_write));
let is_logged_in = move || {
auth_store
.initialized()
.get()
.then_some(auth_store.session().get().is_some())
};
let not_logged_in = move || Some(auth_store.session().get().is_none());
let error: RwSignal<Option<WolfError>> = RwSignal::new(None);
view! {
<Stylesheet id="leptos" href="/pkg/werewolves.css" />
// sets the document title
<Title text="werewolves" />
// content for this welcome page
<Router>
<main>
<Nav />
<ErrorBox msg=error />
<Routes fallback=NotFound>
<Route
path=path!("/")
view=move || view! { <Main error=error.write_only() /> }
/>
<ProtectedRoute
path=path!("/signin")
view=move || view! { <Signin error=error.write_only() /> }
condition=not_logged_in
redirect_path=|| "/"
/>
<ProtectedRoute
path=path!("/signup")
view=move || view! { <Signup error=error.write_only() /> }
condition=not_logged_in
redirect_path=|| "/"
/>
<ProtectedRoute
path=path!("/user/settings")
view=move || view! { <UserSettings error=error.write_only() /> }
condition=is_logged_in
redirect_path=|| "/"
/>
<Route
path=path!("/games/:id")
view=move || view! { <GamePage error=error.write_only() /> }
/>
<Route path=path!("/games/:id/big") view=BigScreen />
</Routes>
</main>
</Router>
}
}

View File

@ -1,200 +0,0 @@
// Copyright (C) 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::ops::{Deref, DerefMut};
use werewolves_proto::{
aura::AuraTitle, character::Character, game::Category, role::RoleTitle, team::Team,
};
pub trait PartialClass {
fn partial_class(&self) -> Option<&'static str>;
}
impl PartialClass for AuraTitle {
fn partial_class(&self) -> Option<&'static str> {
match self {
AuraTitle::Damned => Some("damned"),
AuraTitle::Drunk => Some("drunk"),
AuraTitle::Insane
| AuraTitle::Bloodlet
| AuraTitle::Scapegoat
| AuraTitle::RedeemableScapegoat
| AuraTitle::VindictiveScapegoat
| AuraTitle::SpitefulScapegoat
| AuraTitle::InevitableScapegoat => None,
}
}
}
pub trait Class {
fn class(&self) -> &'static str;
}
impl Class for Character {
fn class(&self) -> &'static str {
if let Team::AnyEvil = self.team() {
return "damned";
}
self.role_title().category().class()
}
}
impl Class for RoleTitle {
fn class(&self) -> &'static str {
self.category().class()
}
}
impl Class for Category {
fn class(&self) -> &'static str {
match self {
Category::Wolves => "wolves",
Category::Villager => "village",
Category::Intel => "intel",
Category::Defensive => "defensive",
Category::Offensive => "offensive",
Category::StartsAsVillager => "starts-as-villager",
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Classes(Vec<String>);
impl Classes {
pub fn push_opt(&mut self, opt: Option<String>) {
if let Some(val) = opt {
self.0.push(val);
}
}
}
impl Deref for Classes {
type Target = Vec<String>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for Classes {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl<I> From<I> for Classes
where
I: Into<Vec<String>>,
{
fn from(value: I) -> Self {
Self(value.into())
}
}
impl core::fmt::Display for Classes {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0.join(" ").as_str())
}
}
pub trait AsClasses {
fn as_classes(&self) -> Classes;
}
impl AsClasses for [&str] {
fn as_classes(&self) -> Classes {
Classes(self.iter().map(|s| s.to_string()).collect())
}
}
impl<T> AsClasses for [Option<T>]
where
T: ToString,
{
fn as_classes(&self) -> Classes {
Classes(
self.iter()
.filter_map(|c| c.as_ref().map(|c| c.to_string()))
.collect(),
)
}
}
impl leptos::tachys::html::class::IntoClass for Classes {
type AsyncOutput = Self;
type State = (leptos::tachys::renderer::types::Element, Self);
type Cloneable = Self;
type CloneableOwned = Self;
fn html_len(&self) -> usize {
let len = self.0.len();
self.0.iter().map(|c| c.len()).sum::<usize>()
+ if len == 2 {
1
} else if len > 2 {
len - 1
} else {
0
}
}
fn to_html(self, class: &mut String) {
class.push_str(self.0.join(" ").as_str());
}
fn should_overwrite(&self) -> bool {
true
}
fn hydrate<const FROM_SERVER: bool>(
self,
el: &leptos::tachys::renderer::types::Element,
) -> Self::State {
if !FROM_SERVER {
leptos::tachys::renderer::Rndr::set_attribute(el, "class", self.to_string().as_str());
}
(el.clone(), self)
}
fn build(self, el: &leptos::tachys::renderer::types::Element) -> Self::State {
leptos::tachys::renderer::Rndr::set_attribute(el, "class", self.to_string().as_str());
(el.clone(), self)
}
fn rebuild(self, state: &mut Self::State) {
let (el, prev) = state;
if self != *prev {
leptos::tachys::renderer::Rndr::set_attribute(el, "class", self.to_string().as_str());
}
*prev = self;
}
fn into_cloneable(self) -> Self::Cloneable {
self
}
fn into_cloneable_owned(self) -> Self::CloneableOwned {
self.into()
}
fn dry_resolve(&mut self) {}
async fn resolve(self) -> Self::AsyncOutput {
self
}
fn reset(state: &mut Self::State) {
let (el, _prev) = state;
leptos::tachys::renderer::Rndr::remove_attribute(el, "class");
}
}

View File

@ -1,30 +0,0 @@
use leptos::prelude::*;
use werewolves_proto::message::{host::HostNightMessage, night::ActionResponse};
#[component]
pub fn Cover(
#[prop(optional)] message: &'static str,
#[prop(optional)] reply: Option<WriteSignal<Option<HostNightMessage>>>,
#[prop(default=HostNightMessage::ActionResponse(ActionResponse::Continue))]
reply_to_send: HostNightMessage,
) -> impl IntoView {
let message = if message.is_empty() {
"go to sleep"
} else {
message
};
let next = move || {
reply.map(|reply| {
let reply_to_send = reply_to_send.clone();
view! { <button on:click=move |_| { reply.set(Some(reply_to_send.clone())) }>"continue"</button> }
})
};
move || {
view! {
<div class="cover-of-darkness">
<p>{message}</p>
{next.clone()}
</div>
}
}
}

View File

@ -1,8 +0,0 @@
use leptos::prelude::*;
#[component]
pub fn DebugMarker() -> impl IntoView {
option_env!("LOCAL_DEBUG").map(|_| {
view! { <div class="debug-marker">"DEBUG"</div> }
})
}

View File

@ -1,34 +0,0 @@
use leptos::{html::Div, prelude::*};
use leptos_use::{
UseDraggableOptions, UseDraggableReturn, core::Position, use_draggable_with_options,
};
use crate::app::error::WolfError;
#[component]
pub fn ErrorBox(msg: RwSignal<Option<WolfError>>) -> impl IntoView {
let el = NodeRef::<Div>::new();
// `style` is a helper string "left: {x}px; top: {y}px;"
let UseDraggableReturn { style, .. } = use_draggable_with_options(
el,
UseDraggableOptions::default().initial_value(Position { x: 40.0, y: 40.0 }),
);
let content = move || {
msg.get().map(|err| {
view! {
<div class="error_container" hidden=move || msg.get().is_none()>
<div node_ref=el style=move || style.get() class="error">
<h5>"error"</h5>
<p>{err.to_string()}</p>
<button on:click=move |ev| {
ev.prevent_default();
msg.set(None);
}>"close"</button>
</div>
</div>
}
})
};
view! { {content} }
}

View File

@ -1,34 +0,0 @@
use leptos::prelude::*;
use werewolves_proto::message::{Identification, PublicIdentity};
#[component]
pub fn IdentityInline(ident: PublicIdentity) -> impl IntoView {
let number = ident
.number
.as_ref()
.map(|num| view! { <span class="number">{num.get()}</span> }.into_any())
.unwrap_or_else(|| view! { <span class="number red">"?"</span> }.into_any());
let pronouns = move || {
ident.pronouns.as_ref().map(|p| {
view! { <span class="pronouns">"("{p.clone()}")"</span> }
})
};
view! {
<span class="identity">
{number} <span class="name">{move || ident.name.clone()}</span> {pronouns}
</span>
}
}
#[component]
pub fn IdentificationInline(ident: Identification) -> impl IntoView {
if !ident.public.name.trim().is_empty() {
return view! { <IdentityInline ident=ident.public /> }.into_any();
}
view! {
<span class="identity">
<span class="player-id">{ident.player_id.to_string()}</span>
</span>
}
.into_any()
}

View File

@ -1,96 +0,0 @@
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

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

View File

@ -1,44 +0,0 @@
use core::fmt::Display;
use leptos::prelude::*;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InputType {
#[default]
Text,
Password,
}
impl Display for InputType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
InputType::Text => f.write_str("text"),
InputType::Password => f.write_str("password"),
}
}
}
#[component]
pub fn TextInput(
#[prop(optional)] label: Option<String>,
value: RwSignal<String>,
#[prop(optional)] autocomplete: bool,
#[prop(optional)] r#type: InputType,
) -> impl IntoView {
let id = Uuid::new_v4().to_string();
let label = label.map(|label| {
view! { <label for=id.clone()>{label}</label> }
});
let initial_value = move || value.read().to_string();
view! {
{label}
<input
type=r#type.to_string()
id=id
value=initial_value
on:input:target=move |ev| value.set(ev.target().value())
autocomplete=autocomplete
/>
}
}

View File

@ -1,29 +0,0 @@
use leptos::prelude::*;
use leptos_router::hooks::use_url;
#[component]
pub fn LinkButton(href: String, mut children: ChildrenFnMut) -> impl IntoView {
let url = use_url();
let link = move || {
let already_open = url.get().path() == href;
match already_open {
true => view! {
<button
class:current=already_open
disabled=already_open
class:no-hover=already_open
>
{children()}
</button>
}
.into_any(),
false => view! {
<a href=href.clone()>
<button>{children()}</button>
</a>
}
.into_any(),
}
};
view! { {link} }
}

Some files were not shown because too many files have changed in this diff Show More