├── server ├── migrations │ ├── .gitkeep │ ├── 2020-09-18-095050_game_owner │ │ ├── down.sql │ │ └── up.sql │ ├── 2020-08-18-124338_create_users │ │ ├── down.sql │ │ └── up.sql │ ├── 2021-03-30-114653_user_integration_access │ │ ├── up.sql │ │ └── down.sql │ └── 00000000000000_diesel_initial_setup │ │ ├── down.sql │ │ └── up.sql ├── .gitignore ├── README.md ├── run.sh ├── Dockerfile.dev ├── diesel.toml ├── src │ ├── schema.rs │ ├── db.rs │ ├── game_room.rs │ └── main.rs └── Cargo.toml ├── client ├── src │ ├── views.rs │ ├── config.rs │ ├── window.rs │ ├── palette.rs │ ├── networking.rs │ ├── state.rs │ ├── views │ │ └── create_game.rs │ └── board.rs ├── Dockerfile.dev ├── .cargo │ └── config.toml ├── dist.sh ├── Dockerfile ├── Dioxus.toml ├── serve.py └── Cargo.toml ├── sounds ├── stone1.wav ├── stone2.wav ├── stone3.wav ├── stone4.wav ├── stone5.wav ├── countdownbeep.wav └── README.md ├── .gitignore ├── shared ├── src │ ├── game │ │ ├── replays │ │ │ ├── antti-4+1-1.txt │ │ │ ├── 20-mirth-3color.txt │ │ │ └── 53-seequ-hiddenmove.txt │ │ ├── tests.rs │ │ ├── export.rs │ │ ├── board.rs │ │ ├── snapshots │ │ │ └── shared__game__tests__replay_snapshots@antti-4+1-1.txt.snap │ │ └── clock.rs │ ├── lib.rs │ ├── states │ │ ├── play │ │ │ ├── tetris.rs │ │ │ ├── traitor.rs │ │ │ └── n_plus_one.rs │ │ ├── mod.rs │ │ ├── scoring.rs │ │ ├── free_placement.rs │ │ └── play.rs │ ├── assume.rs │ └── message.rs └── Cargo.toml ├── Cargo.toml ├── store ├── Cargo.toml └── src │ ├── lib.rs │ └── store.rs ├── repl ├── Cargo.toml └── src │ └── main.rs ├── docker-compose.yml ├── README.md ├── LICENSE_MIT ├── privacy_policy.md └── LICENSE_APACHE /server/migrations/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | .env 4 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | 2 | Note on diesel: it requires libpq-dev 3 | 4 | -------------------------------------------------------------------------------- /server/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | diesel migration run 4 | cargo run --release 5 | -------------------------------------------------------------------------------- /client/src/views.rs: -------------------------------------------------------------------------------- 1 | pub mod create_game; 2 | 3 | pub use create_game::CreateGamePanel; 4 | -------------------------------------------------------------------------------- /sounds/stone1.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JaniM/variant-go-server/HEAD/sounds/stone1.wav -------------------------------------------------------------------------------- /sounds/stone2.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JaniM/variant-go-server/HEAD/sounds/stone2.wav -------------------------------------------------------------------------------- /sounds/stone3.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JaniM/variant-go-server/HEAD/sounds/stone3.wav -------------------------------------------------------------------------------- /sounds/stone4.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JaniM/variant-go-server/HEAD/sounds/stone4.wav -------------------------------------------------------------------------------- /sounds/stone5.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JaniM/variant-go-server/HEAD/sounds/stone5.wav -------------------------------------------------------------------------------- /server/migrations/2020-09-18-095050_game_owner/down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE games 2 | DROP COLUMN owner; 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | **/*.rs.bk 3 | Cargo.lock 4 | bin/ 5 | pkg/ 6 | wasm-pack.log 7 | /client/dist 8 | -------------------------------------------------------------------------------- /server/migrations/2020-08-18-124338_create_users/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE users; 2 | DROP TABLE games; 3 | 4 | -------------------------------------------------------------------------------- /sounds/countdownbeep.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JaniM/variant-go-server/HEAD/sounds/countdownbeep.wav -------------------------------------------------------------------------------- /sounds/README.md: -------------------------------------------------------------------------------- 1 | Sounds borrowed from [Katrain](https://github.com/sanderland/katrain) under the MIT license. 2 | -------------------------------------------------------------------------------- /server/migrations/2020-09-18-095050_game_owner/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE games 2 | ADD COLUMN owner BIGINT REFERENCES users(id); 3 | -------------------------------------------------------------------------------- /shared/src/game/replays/antti-4+1-1.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JaniM/variant-go-server/HEAD/shared/src/game/replays/antti-4+1-1.txt -------------------------------------------------------------------------------- /shared/src/game/replays/20-mirth-3color.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JaniM/variant-go-server/HEAD/shared/src/game/replays/20-mirth-3color.txt -------------------------------------------------------------------------------- /shared/src/game/replays/53-seequ-hiddenmove.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JaniM/variant-go-server/HEAD/shared/src/game/replays/53-seequ-hiddenmove.txt -------------------------------------------------------------------------------- /server/migrations/2021-03-30-114653_user_integration_access/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users 2 | ADD COLUMN has_integration_access BOOLEAN NOT NULL DEFAULT FALSE; 3 | -------------------------------------------------------------------------------- /server/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM rust:1.76 2 | 3 | WORKDIR /usr/src/app 4 | RUN cargo install diesel_cli --no-default-features --features postgres 5 | 6 | CMD ["sh", "run.sh"] -------------------------------------------------------------------------------- /server/diesel.toml: -------------------------------------------------------------------------------- 1 | # For documentation on how to configure this file, 2 | # see diesel.rs/guides/configuring-diesel-cli 3 | 4 | [print_schema] 5 | file = "src/schema.rs" 6 | -------------------------------------------------------------------------------- /server/migrations/2021-03-30-114653_user_integration_access/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | ALTER TABLE users 3 | DROP COLUMN has_integration_access; 4 | -------------------------------------------------------------------------------- /client/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM rust:1.76 2 | 3 | WORKDIR /usr/src/app 4 | RUN cargo install dioxus-cli 5 | RUN rustup target add wasm32-unknown-unknown 6 | 7 | CMD ["dx", "serve", "--release"] -------------------------------------------------------------------------------- /client/.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [profile.release] 2 | # opt-level = "z" 3 | opt-level = 3 4 | debug = false 5 | lto = true 6 | codegen-units = 1 7 | panic = "abort" 8 | strip = true 9 | incremental = false 10 | -------------------------------------------------------------------------------- /client/dist.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euxo pipefail 4 | 5 | WS_URL=wss://go.kahv.io/ws/ dx build --release 6 | docker build --tag seequo/vgs-client --platform linux/amd64 -f Dockerfile . 7 | docker push seequo/vgs-client 8 | -------------------------------------------------------------------------------- /shared/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | mod assume; 3 | pub mod game; 4 | pub mod message; 5 | pub mod states; 6 | 7 | #[cfg(test)] 8 | mod tests { 9 | #[test] 10 | fn it_works() { 11 | assert_eq!(2 + 2, 4); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = [ 4 | "client", 5 | "server", 6 | "shared", 7 | "store", 8 | "repl", 9 | ] 10 | 11 | 12 | [profile.release.package.client] 13 | # Tell `rustc` to optimize for small code size. 14 | opt-level = "s" 15 | -------------------------------------------------------------------------------- /server/migrations/2020-08-18-124338_create_users/up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users ( 2 | id BIGSERIAL PRIMARY KEY, 3 | auth_token TEXT NOT NULL UNIQUE, 4 | nick TEXT 5 | ); 6 | 7 | CREATE TABLE games ( 8 | id BIGSERIAL PRIMARY KEY, 9 | name TEXT NOT NULL, 10 | replay BYTEA 11 | ); 12 | -------------------------------------------------------------------------------- /store/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "store" 3 | version = "0.1.0" 4 | authors = ["Jani Mustonen "] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | yew = "0.17" 11 | -------------------------------------------------------------------------------- /server/migrations/00000000000000_diesel_initial_setup/down.sql: -------------------------------------------------------------------------------- 1 | -- This file was automatically created by Diesel to setup helper functions 2 | -- and other internal bookkeeping. This file is safe to edit, any future 3 | -- changes will be added to existing projects as new migrations. 4 | 5 | DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); 6 | DROP FUNCTION IF EXISTS diesel_set_updated_at(); 7 | -------------------------------------------------------------------------------- /client/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | 3 | EXPOSE 8000 4 | WORKDIR /serve 5 | 6 | RUN apk --no-cache -U add python3 && \ 7 | apk upgrade --no-cache -U -a 8 | # Patch OpenSSL vulnerability^ 9 | 10 | RUN addgroup -S servergrp && \ 11 | adduser -S server -G servergrp && \ 12 | chown -R server:servergrp /serve 13 | 14 | USER server 15 | 16 | COPY ./dist /serve 17 | COPY ./serve.py /bin/serve.py 18 | 19 | ENTRYPOINT [ "python3", "/bin/serve.py" ] 20 | -------------------------------------------------------------------------------- /client/src/config.rs: -------------------------------------------------------------------------------- 1 | macro_rules! config { 2 | ($name:ident, $default:expr) => { 3 | pub(crate) const $name: &str = match option_env!(stringify!($name)) { 4 | Some(s) => s, 5 | None => $default, 6 | }; 7 | }; 8 | } 9 | 10 | config!(WS_URL, "ws://localhost:8088/ws/"); 11 | 12 | // Give `konst` crate a try for parsing these 13 | pub(crate) const CONN_RETRY_DELAY: u32 = 1000; 14 | pub(crate) const SIDEBAR_SIZE: i32 = 300; 15 | -------------------------------------------------------------------------------- /client/Dioxus.toml: -------------------------------------------------------------------------------- 1 | 2 | [application] 3 | 4 | # App (Project) Name 5 | name = "client" 6 | default_platform = "web" 7 | out_dir = "dist" 8 | asset_dir = "public" 9 | 10 | [web.app] 11 | 12 | title = "Variant Go Server" 13 | 14 | [web.watcher] 15 | 16 | reload_html = true 17 | index_on_404 = true 18 | watch_path = ["src", "public"] 19 | 20 | [web.resource] 21 | 22 | # CSS style file 23 | style = [] 24 | 25 | # Javascript code file 26 | script = [] 27 | 28 | [web.resource.dev] 29 | 30 | # Javascript code file 31 | # serve: [dev-server] only 32 | script = [] 33 | -------------------------------------------------------------------------------- /server/src/schema.rs: -------------------------------------------------------------------------------- 1 | // @generated automatically by Diesel CLI. 2 | 3 | diesel::table! { 4 | games (id) { 5 | id -> Int8, 6 | name -> Text, 7 | replay -> Nullable, 8 | owner -> Nullable, 9 | } 10 | } 11 | 12 | diesel::table! { 13 | users (id) { 14 | id -> Int8, 15 | auth_token -> Text, 16 | nick -> Nullable, 17 | has_integration_access -> Bool, 18 | } 19 | } 20 | 21 | diesel::joinable!(games -> users (owner)); 22 | 23 | diesel::allow_tables_to_appear_in_same_query!( 24 | games, 25 | users, 26 | ); 27 | -------------------------------------------------------------------------------- /shared/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shared" 3 | version = "0.1.0" 4 | authors = ["Jani Mustonen "] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | bitmaps = "2.1" 11 | typenum = "1.12" 12 | itertools = "0.9" 13 | derive_more = "0.99" 14 | 15 | tinyvec = { version = "1.0", features = ["serde", "alloc"] } 16 | 17 | serde = { version = "1.0", features = ["derive"] } 18 | serde_cbor = "0.11.1" 19 | 20 | rand = "0.7.3" 21 | rand_pcg = "0.2.1" 22 | 23 | [dev-dependencies] 24 | insta = { version = "0.16.1", features = ["glob"] } 25 | -------------------------------------------------------------------------------- /repl/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "repl" 3 | version = "0.1.0" 4 | authors = ["Jani Mustonen "] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | tokio-tungstenite = "0.21.0" 11 | futures-util = { version = "0.3", default-features = false, features = ["async-await", "sink", "std"] } 12 | futures-channel = "0.3" 13 | tokio = { version = "1.24.2", default-features = false, features = ["io-std", "macros", "time", "rt"] } 14 | url = "2.1.1" 15 | 16 | serde = { version = "1.0", features = ["derive"] } 17 | serde_cbor = "0.11.1" 18 | 19 | dotenv = "0.15.0" 20 | 21 | shared = { path = "../shared" } 22 | -------------------------------------------------------------------------------- /server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "server" 3 | version = "0.1.0" 4 | authors = ["Jani Mustonen "] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | actix = "0.13.3" 11 | actix-web = "4.5.1" 12 | actix-web-actors = "4.3.0" 13 | actix-rt = "2.9" 14 | futures-util = "0.3.30" 15 | env_logger = "0.7" 16 | 17 | chrono = { version = "= 0.4.29" } 18 | 19 | serde = { version = "1.0", features = ["derive"] } 20 | serde_cbor = "0.11.1" 21 | 22 | rand = "0.7.3" 23 | uuid = { version = "0.8", features = ["serde", "v4"] } 24 | 25 | diesel = { version = "1.4.4", features = ["postgres"] } 26 | dotenv = "0.15.0" 27 | 28 | shared = { path = "../shared" } 29 | -------------------------------------------------------------------------------- /client/serve.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import urllib.parse 4 | import http.server 5 | import socketserver 6 | import re 7 | from pathlib import Path 8 | 9 | HOST = ("0.0.0.0", 8080) 10 | pattern = re.compile(".png|.jpg|.jpeg|.js|.css|.ico|.gif|.svg", re.IGNORECASE) 11 | 12 | 13 | class Handler(http.server.SimpleHTTPRequestHandler): 14 | def do_GET(self): 15 | url_parts = urllib.parse.urlparse(self.path) 16 | request_file_path = Path(url_parts.path.strip("/")) 17 | 18 | ext = request_file_path.suffix 19 | if not request_file_path.is_file() and not pattern.match(ext): 20 | self.path = "index.html" 21 | 22 | return http.server.SimpleHTTPRequestHandler.do_GET(self) 23 | 24 | 25 | httpd = socketserver.TCPServer(HOST, Handler) 26 | httpd.serve_forever() 27 | -------------------------------------------------------------------------------- /client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "client" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | dioxus = "0.4.3" 10 | dioxus-router = "0.4.3" 11 | dioxus-signals = "0.4.3" 12 | dioxus-web = "0.4.3" 13 | futures = "0.3.29" 14 | futures-channel = "0.3.29" 15 | gloo-events = "0.2.0" 16 | gloo-net = "0.5.0" 17 | gloo-storage = "0.3.0" 18 | gloo-timers = { version = "0.3.0", features = ["futures"] } 19 | gloo-utils = "0.2.0" 20 | log = "0.4.20" 21 | serde_cbor = "0.11.2" 22 | 23 | shared = { path = "../shared" } 24 | sir = { version = "0.4.0", features = ["dioxus"] } 25 | wasm-logger = "0.2.0" 26 | web-sys = { version = "0.3.66", features = ["CanvasRenderingContext2d", "HtmlCanvasElement", "CssStyleDeclaration"] } 27 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | client: 3 | build: 4 | context: ./client 5 | dockerfile: Dockerfile.dev 6 | ports: 7 | - "8080:8080" 8 | volumes: 9 | - ./client:/usr/src/app 10 | - ./shared:/usr/src/shared 11 | environment: 12 | WS_URL: ws://localhost:8088/ws/ 13 | server: 14 | build: 15 | context: ./server 16 | dockerfile: Dockerfile.dev 17 | ports: 18 | - "8088:8088" 19 | volumes: 20 | - ./server:/usr/src/app 21 | - ./shared:/usr/src/shared 22 | environment: 23 | - DATABASE_URL=postgres://postgres:localpw@go-postgres/postgres 24 | depends_on: 25 | - go-postgres 26 | go-postgres: 27 | image: postgres 28 | environment: 29 | POSTGRES_PASSWORD: localpw 30 | ports: 31 | - 5432 32 | volumes: 33 | - /var/lib/postgresql/data 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Variant Go Server 2 | 3 | A highly unfinished server for Go variants implemented in full-stack Rust. 4 | 5 | ## Development 6 | 7 | The whole app can be spun up with `docker compose up --build`. 8 | After that, the client should be accessible at http://localhost:8080/ 9 | 10 | ### Client 11 | 12 | While you can just use the docker image, builds on Mac OS can be very slow. 13 | If that ends up being an issue, try this. 14 | 15 | The client uses Dioxus CLI, install it with `cargo install dioxus-cli`. 16 | 17 | Build & run the client with 18 | 19 | ``` sh 20 | cd client 21 | dx serve --hot-reload 22 | ``` 23 | 24 | ## Testing 25 | 26 | Game rules use snapshot tests powered by [insta](https://docs.rs/insta/0.16.1/insta/). 27 | 28 | # Licensing 29 | 30 | The main project is dual licensed under MIT and Apache 2.0 licenses - the user is free to pick either one. 31 | Sounds are borrowed from [Katrain](https://github.com/sanderland/katrain) and fall under the MIT license. 32 | -------------------------------------------------------------------------------- /shared/src/states/play/tetris.rs: -------------------------------------------------------------------------------- 1 | use crate::game::find_groups; 2 | use crate::game::Color; 3 | use crate::game::{Board, GroupVec, Point, TetrisGo}; 4 | 5 | pub enum TetrisResult { 6 | Nothing, 7 | Illegal, 8 | } 9 | 10 | pub fn check( 11 | points_played: &mut GroupVec, 12 | board: &mut Board, 13 | _rule: &TetrisGo, 14 | ) -> TetrisResult { 15 | let groups = find_groups(board); 16 | 17 | for point_played in points_played.clone() { 18 | let color = board.get_point(point_played); 19 | 20 | for group in &groups { 21 | if group.team != color || group.points.len() != 4 { 22 | continue; 23 | } 24 | 25 | let contains = group.points.contains(&point_played); 26 | 27 | if !contains { 28 | continue; 29 | } 30 | 31 | points_played.retain(|x| *x != point_played); 32 | *board.point_mut(point_played) = Color::empty(); 33 | } 34 | } 35 | 36 | if points_played.is_empty() { 37 | return TetrisResult::Illegal; 38 | } 39 | 40 | TetrisResult::Nothing 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE_MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Jani Mustonen 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /shared/src/assume.rs: -------------------------------------------------------------------------------- 1 | pub trait AssumeFrom { 2 | fn assume(x: &T) -> &Self; 3 | fn assume_mut(x: &mut T) -> &mut Self; 4 | } 5 | 6 | #[macro_export] 7 | macro_rules! assume { 8 | ($owner:ident, $var:pat => $out:expr, $ty:ty) => { 9 | impl AssumeFrom<$owner> for $ty { 10 | fn assume(x: &$owner) -> &$ty { 11 | use $owner::*; 12 | match x { 13 | $var => $out, 14 | _ => panic!(concat!("Assumed ", stringify!($var), " but was in {:?}"), x), 15 | } 16 | } 17 | 18 | fn assume_mut(x: &mut $owner) -> &mut $ty { 19 | use $owner::*; 20 | match x { 21 | $var => $out, 22 | _ => panic!(concat!("Assumed ", stringify!($var), " but was in {:?}"), x), 23 | } 24 | } 25 | } 26 | }; 27 | ($owner:ident) => { 28 | impl $owner { 29 | pub fn assume>(&self) -> &T { 30 | T::assume(self) 31 | } 32 | 33 | pub fn assume_mut>(&mut self) -> &mut T { 34 | T::assume_mut(self) 35 | } 36 | } 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /server/migrations/00000000000000_diesel_initial_setup/up.sql: -------------------------------------------------------------------------------- 1 | -- This file was automatically created by Diesel to setup helper functions 2 | -- and other internal bookkeeping. This file is safe to edit, any future 3 | -- changes will be added to existing projects as new migrations. 4 | 5 | 6 | 7 | 8 | -- Sets up a trigger for the given table to automatically set a column called 9 | -- `updated_at` whenever the row is modified (unless `updated_at` was included 10 | -- in the modified columns) 11 | -- 12 | -- # Example 13 | -- 14 | -- ```sql 15 | -- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); 16 | -- 17 | -- SELECT diesel_manage_updated_at('users'); 18 | -- ``` 19 | CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ 20 | BEGIN 21 | EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s 22 | FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); 23 | END; 24 | $$ LANGUAGE plpgsql; 25 | 26 | CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ 27 | BEGIN 28 | IF ( 29 | NEW IS DISTINCT FROM OLD AND 30 | NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at 31 | ) THEN 32 | NEW.updated_at := current_timestamp; 33 | END IF; 34 | RETURN NEW; 35 | END; 36 | $$ LANGUAGE plpgsql; 37 | -------------------------------------------------------------------------------- /shared/src/states/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod free_placement; 2 | pub mod play; 3 | pub mod scoring; 4 | 5 | pub use self::free_placement::FreePlacement; 6 | pub use self::play::PlayState; 7 | pub use self::scoring::ScoringState; 8 | 9 | use crate::assume::AssumeFrom; 10 | use crate::game::{Board, Seat}; 11 | use serde::{Deserialize, Serialize}; 12 | 13 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 14 | pub enum GameState { 15 | FreePlacement(FreePlacement), 16 | Play(PlayState), 17 | Scoring(ScoringState), 18 | Done(ScoringState), 19 | } 20 | 21 | impl GameState { 22 | pub fn free_placement( 23 | seat_count: usize, 24 | team_count: usize, 25 | board: Board, 26 | teams_share_stones: bool, 27 | ) -> Self { 28 | GameState::FreePlacement(FreePlacement::new( 29 | seat_count, 30 | team_count, 31 | board, 32 | teams_share_stones, 33 | )) 34 | } 35 | 36 | pub fn play(seat_count: usize) -> Self { 37 | GameState::Play(PlayState::new(seat_count)) 38 | } 39 | 40 | pub fn scoring(board: &Board, seats: &[Seat], scores: &[i32]) -> Self { 41 | GameState::Scoring(ScoringState::new(board, seats, scores)) 42 | } 43 | } 44 | 45 | assume!(GameState); 46 | assume!(GameState, Play(x) => x, PlayState); 47 | assume!(GameState, Scoring(x) => x, ScoringState); 48 | assume!(GameState, FreePlacement(x) => x, FreePlacement); 49 | -------------------------------------------------------------------------------- /store/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod store; 2 | 3 | pub use crate::store::*; 4 | 5 | /// Generates boilerplate for using a store. 6 | /// 7 | /// Usage: 8 | /// 9 | /// ```ignore 10 | /// store! { 11 | /// store StoreName, 12 | /// state StoreState, 13 | /// request RequestEnum { 14 | /// method => Variant(a: T, b: U) 15 | /// } 16 | /// } 17 | /// ``` 18 | #[macro_export] 19 | macro_rules! store { 20 | ( 21 | store $store:ident, 22 | state $state:ident, 23 | request $name:ident { $( 24 | $fn:ident => $var:ident $( ( $( 25 | $arg:ident : $argty:ty 26 | ),+ ) )? 27 | ),* $(,)? } 28 | ) => { 29 | pub enum $name { 30 | $( 31 | $var $( ( $($argty),+ ) )? 32 | ),* 33 | } 34 | 35 | pub struct $store { 36 | bridge: StoreBridge<$state>, 37 | } 38 | 39 | impl $store { 40 | pub fn bridge(cb: Callback< as Agent>::Output>) -> Self { 41 | Self { 42 | bridge: <$state as Bridgeable>::bridge(cb), 43 | } 44 | } 45 | } 46 | 47 | impl $store { 48 | $( 49 | pub fn $fn ( &mut self, $( $($arg : $argty ),+ )? ) { 50 | self.bridge.send($name::$var $( ( 51 | $($arg),+ 52 | ) )? ); 53 | } 54 | )* 55 | } 56 | }; 57 | } 58 | 59 | #[cfg(test)] 60 | mod tests { 61 | #[test] 62 | fn it_works() { 63 | assert_eq!(2 + 2, 4); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /shared/src/states/play/traitor.rs: -------------------------------------------------------------------------------- 1 | use rand::prelude::*; 2 | use rand_pcg::Lcg64Xsh32; 3 | 4 | use crate::game::Color; 5 | use crate::game::GroupVec; 6 | use crate::game::TraitorGo; 7 | 8 | #[derive(Clone, Default)] 9 | struct TeamState { 10 | traitor_count: u32, 11 | stone_count: u32, 12 | } 13 | 14 | #[derive(Clone)] 15 | pub struct TraitorState { 16 | /// Remaining traitors for each team 17 | team_states: GroupVec, 18 | rng_state: Lcg64Xsh32, 19 | } 20 | 21 | impl TraitorState { 22 | pub fn new(team_count: usize, stone_count: u32, seed: u64, rule: &TraitorGo) -> Self { 23 | Self { 24 | team_states: vec![ 25 | TeamState { 26 | stone_count, 27 | traitor_count: rule.traitor_count, 28 | }; 29 | team_count 30 | ] 31 | .as_slice() 32 | .into(), 33 | rng_state: Lcg64Xsh32::seed_from_u64(seed), 34 | } 35 | } 36 | 37 | pub fn next_color(&mut self, team_color: Color) -> Color { 38 | let team = &mut self.team_states[team_color.as_usize() - 1]; 39 | let stone_count = team.stone_count; 40 | team.stone_count = team.stone_count.saturating_sub(1); 41 | 42 | if stone_count > 0 && self.rng_state.next_u32() % stone_count < team.traitor_count { 43 | team.traitor_count -= 1; 44 | 45 | let color = (1u8..=self.team_states.len() as u8) 46 | .filter(|&x| x != team_color.0) 47 | .choose(&mut self.rng_state) 48 | .expect("Empty color choices in TraitorState::next_color"); 49 | 50 | Color(color as u8) 51 | } else { 52 | team_color 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /shared/src/game/tests.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn seats() { 5 | let mut game = Game::standard( 6 | &vec![1, 2], 7 | GroupVec::from(&[0, 15][..]), 8 | (9, 9), 9 | GameModifier::default(), 10 | 0 11 | ) 12 | .unwrap(); 13 | 14 | assert_eq!( 15 | &game.shared.seats, 16 | &GroupVec::from( 17 | &[ 18 | Seat { 19 | player: None, 20 | team: Color(1), 21 | resigned: false, 22 | }, 23 | Seat { 24 | player: None, 25 | team: Color(2), 26 | resigned: false, 27 | }, 28 | ][..] 29 | ) 30 | ); 31 | 32 | game.take_seat(100, 0).expect("Take seat"); 33 | game.take_seat(200, 1).expect("Take seat"); 34 | 35 | assert_eq!( 36 | &game.shared.seats, 37 | &GroupVec::from( 38 | &[ 39 | Seat { 40 | player: Some(100), 41 | team: Color(1), 42 | resigned: false, 43 | }, 44 | Seat { 45 | player: Some(200), 46 | team: Color(2), 47 | resigned: false, 48 | }, 49 | ][..] 50 | ) 51 | ); 52 | 53 | assert_eq!(game.take_seat(300, 2), Err(TakeSeatError::DoesNotExist)); 54 | assert_eq!(game.take_seat(300, 1), Err(TakeSeatError::NotOpen)); 55 | assert_eq!(game.leave_seat(300, 1), Err(TakeSeatError::NotOpen)); 56 | } 57 | 58 | use insta::{assert_debug_snapshot, glob}; 59 | use std::fs; 60 | 61 | #[test] 62 | fn replay_snapshots() { 63 | glob!("replays/*.txt", |path| { 64 | let input = fs::read(path).unwrap(); 65 | let game = Game::load(&input).unwrap(); 66 | let view = game.get_view(0); 67 | assert_debug_snapshot!(view); 68 | }); 69 | } 70 | -------------------------------------------------------------------------------- /client/src/window.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use dioxus_signals::*; 3 | 4 | use crate::config; 5 | 6 | #[derive(Clone, Default)] 7 | struct WindowSize((i32, i32)); 8 | 9 | fn window_size() -> (i32, i32) { 10 | let window = gloo_utils::window(); 11 | let document = window.document().unwrap(); 12 | let element = document.document_element().unwrap(); 13 | let width = element.client_width(); 14 | let height = element.client_height(); 15 | (width, height) 16 | } 17 | 18 | pub(crate) fn use_window_size_provider(cx: &ScopeState) { 19 | let size = *use_context_provider(cx, || Signal::new(WindowSize(window_size()))); 20 | cx.use_hook(move || { 21 | let window = gloo_utils::window(); 22 | gloo_events::EventListener::new(&window, "resize", move |_| { 23 | size.write().0 = window_size(); 24 | }) 25 | }); 26 | } 27 | 28 | pub(crate) fn use_window_size(cx: &ScopeState) -> (i32, i32) { 29 | let size = use_context::>(cx).expect("WindowSize not set up"); 30 | size.read().0 31 | } 32 | 33 | #[derive(Clone, Copy, PartialEq, Eq, Debug)] 34 | pub(crate) enum DisplayMode { 35 | Desktop(bool), 36 | Mobile, 37 | } 38 | 39 | impl DisplayMode { 40 | pub(crate) fn class(self) -> &'static str { 41 | match self { 42 | DisplayMode::Desktop(true) => "desktop small", 43 | DisplayMode::Desktop(false) => "desktop large", 44 | DisplayMode::Mobile => "mobile", 45 | } 46 | } 47 | 48 | pub(crate) fn is_mobile(self) -> bool { 49 | matches!(self, Self::Mobile) 50 | } 51 | 52 | pub(crate) fn is_desktop(self) -> bool { 53 | matches!(self, Self::Desktop(_)) 54 | } 55 | 56 | pub(crate) fn is_large_desktop(self) -> bool { 57 | matches!(self, Self::Desktop(false)) 58 | } 59 | 60 | pub(crate) fn is_small_desktop(self) -> bool { 61 | matches!(self, Self::Desktop(true)) 62 | } 63 | } 64 | 65 | pub(crate) fn use_display_mode(cx: &ScopeState) -> DisplayMode { 66 | let (width, height) = use_window_size(cx); 67 | let sidebar_size = config::SIDEBAR_SIZE; 68 | if width < height + sidebar_size { 69 | DisplayMode::Mobile 70 | } else if width < height + sidebar_size * 2 - sidebar_size / 2 { 71 | DisplayMode::Desktop(true) 72 | } else { 73 | DisplayMode::Desktop(false) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /client/src/palette.rs: -------------------------------------------------------------------------------- 1 | use gloo_storage::Storage; 2 | 3 | #[derive(Clone, PartialEq)] 4 | pub(crate) struct Palette { 5 | pub shadow_stone_colors: [&'static str; 4], 6 | pub shadow_border_colors: [&'static str; 4], 7 | pub stone_colors: [&'static str; 4], 8 | pub stone_colors_hidden: [&'static str; 4], 9 | pub border_colors: [&'static str; 4], 10 | pub dead_mark_color: [&'static str; 4], 11 | pub background: &'static str, 12 | } 13 | 14 | #[derive(Copy, Clone, Debug, PartialEq)] 15 | pub(crate) enum PaletteOption { 16 | Normal, 17 | Colorblind, 18 | } 19 | 20 | impl std::fmt::Display for PaletteOption { 21 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 22 | write!(f, "{:?}", self) 23 | } 24 | } 25 | 26 | impl PaletteOption { 27 | pub(crate) fn get() -> PaletteOption { 28 | let val = gloo_storage::LocalStorage::get::("palette").ok(); 29 | match val.as_deref() { 30 | Some("Normal") => PaletteOption::Normal, 31 | Some("Colorblind") => PaletteOption::Colorblind, 32 | _ => PaletteOption::Normal, 33 | } 34 | } 35 | 36 | pub(crate) fn save(&self) { 37 | gloo_storage::LocalStorage::set("palette", &format!("{:?}", self)).unwrap(); 38 | } 39 | 40 | pub(crate) fn to_palette(&self) -> Palette { 41 | match self { 42 | PaletteOption::Normal => Palette { 43 | shadow_stone_colors: ["#000000a0", "#eeeeeea0", "#5074bca0", "#e0658fa0"], 44 | shadow_border_colors: ["#bbbbbb", "#555555", "#555555", "#555555"], 45 | stone_colors: ["#000000", "#eeeeee", "#5074bc", "#e0658f"], 46 | stone_colors_hidden: ["#00000080", "#eeeeee80", "#5074bc80", "#e0658f80"], 47 | border_colors: ["#555555", "#000000", "#000000", "#000000"], 48 | dead_mark_color: ["#eeeeee", "#000000", "#000000", "#000000"], 49 | background: "#e0bb6c", 50 | }, 51 | PaletteOption::Colorblind => Palette { 52 | shadow_stone_colors: ["#000000a0", "#eeeeeea0", "#56b3e9a0", "#d52e00a0"], 53 | shadow_border_colors: ["#bbbbbb", "#555555", "#555555", "#555555"], 54 | stone_colors: ["#000000", "#eeeeee", "#56b3e9", "#d52e00"], 55 | stone_colors_hidden: ["#00000080", "#eeeeee80", "#56b3e980", "#d52e0080"], 56 | border_colors: ["#555555", "#000000", "#000000", "#000000"], 57 | dead_mark_color: ["#eeeeee", "#000000", "#000000", "#000000"], 58 | background: "#e0bb6c", 59 | }, 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /shared/src/game/export.rs: -------------------------------------------------------------------------------- 1 | use super::Board; 2 | use super::Game; 3 | use std::fmt::Write; 4 | 5 | struct SGFWriter { 6 | buffer: String, 7 | } 8 | 9 | impl SGFWriter { 10 | fn new() -> SGFWriter { 11 | SGFWriter { 12 | buffer: "(;FF[4]GM[1]".to_string(), 13 | } 14 | } 15 | 16 | fn size(&mut self, size: (u32, u32)) { 17 | if size.0 == size.1 { 18 | let _ = write!(&mut self.buffer, "SZ[{}]", size.0); 19 | } else { 20 | let _ = write!(&mut self.buffer, "SZ[{}:{}]", size.0, size.1); 21 | } 22 | } 23 | 24 | fn set_point(&mut self, point: (u32, u32), color: u8) { 25 | let name = match color { 26 | 0 => "AE", 27 | 1 => "AB", 28 | 2 => "AW", 29 | _ => unreachable!(), 30 | }; 31 | 32 | let (x, y) = self.point(point); 33 | 34 | let _ = write!(&mut self.buffer, "{}[{}{}]", name, x, y); 35 | } 36 | 37 | fn point(&self, point: (u32, u32)) -> (char, char) { 38 | let mut letters = 'a'..='z'; 39 | let x = letters.clone().nth(point.0 as usize).unwrap_or('a'); 40 | let y = letters.nth(point.1 as usize).unwrap_or('a'); 41 | (x, y) 42 | } 43 | 44 | fn label(&mut self, point: (u32, u32), text: &str) { 45 | let (x, y) = self.point(point); 46 | 47 | let _ = write!(&mut self.buffer, "LB[{}{}:{}]", x, y, text); 48 | } 49 | 50 | fn end_turn(&mut self) { 51 | let _ = write!(&mut self.buffer, ";"); 52 | } 53 | 54 | fn finish(mut self) -> String { 55 | let _ = write!(&mut self.buffer, ")"); 56 | self.buffer 57 | } 58 | } 59 | 60 | /// Write a simple single-variation representation of the game. 61 | /// Limited to two colors so has to use markers for the other colors and hidden stones. 62 | pub fn sgf_export(game: &Game) -> String { 63 | let mut writer = SGFWriter::new(); 64 | let (width, height) = (game.shared.board.width, game.shared.board.height); 65 | writer.size((width, height)); 66 | 67 | let mut last = Board::empty(width, height, game.shared.board.toroidal); 68 | 69 | for history in &game.shared.board_history { 70 | let board = &history.board; 71 | 72 | for (idx, (old, new)) in last.points.iter_mut().zip(&board.points).enumerate() { 73 | if *old != *new { 74 | // Map colored stones to black and white. 75 | let color = if new.0 == 0 { 0 } else { (new.0 - 1) % 2 + 1 }; 76 | writer.set_point(board.idx_to_coord(idx).unwrap(), color); 77 | *old = *new; 78 | } 79 | } 80 | 81 | for (idx, new) in board.points.iter().enumerate() { 82 | let coord = board.idx_to_coord(idx).unwrap(); 83 | match new.0 { 84 | 3 => writer.label(coord, "U"), 85 | 4 => writer.label(coord, "R"), 86 | _ => {} 87 | } 88 | } 89 | 90 | writer.end_turn(); 91 | 92 | // TODO: PUZZLE markers for hidden stones 93 | } 94 | 95 | writer.finish() 96 | } 97 | -------------------------------------------------------------------------------- /shared/src/game/board.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use std::collections::hash_map::DefaultHasher; 4 | use std::hash::{Hash, Hasher}; 5 | 6 | use super::Color; 7 | 8 | #[derive(Debug, Clone, PartialEq, Hash, Serialize, Deserialize)] 9 | pub struct Board { 10 | pub width: u32, 11 | pub height: u32, 12 | pub toroidal: bool, 13 | pub points: Vec, 14 | } 15 | 16 | pub type Point = (u32, u32); 17 | 18 | impl Board { 19 | pub fn empty(width: u32, height: u32, toroidal: bool) -> Self { 20 | Board { 21 | width, 22 | height, 23 | toroidal, 24 | points: vec![T::default(); (width * height) as usize], 25 | } 26 | } 27 | 28 | pub fn point_within(&self, (x, y): Point) -> bool { 29 | (0..self.width).contains(&x) && (0..self.height).contains(&y) 30 | } 31 | 32 | pub fn get_point(&self, (x, y): Point) -> T { 33 | self.points[(y * self.width + x) as usize] 34 | } 35 | 36 | pub fn point_mut(&mut self, (x, y): Point) -> &mut T { 37 | &mut self.points[(y * self.width + x) as usize] 38 | } 39 | 40 | pub fn idx_to_coord(&self, idx: usize) -> Option { 41 | if idx < self.points.len() { 42 | Some((idx as u32 % self.width, idx as u32 / self.width)) 43 | } else { 44 | None 45 | } 46 | } 47 | 48 | pub fn wrap_point(&self, x: i32, y: i32) -> Option { 49 | wrap_point(x, y, self.width as i32, self.height as i32, self.toroidal) 50 | } 51 | 52 | pub fn surrounding_points(&self, p: Point) -> impl Iterator { 53 | let x = p.0 as i32; 54 | let y = p.1 as i32; 55 | let width = self.width as i32; 56 | let height = self.height as i32; 57 | let toroidal = self.toroidal; 58 | [(-1, 0), (1, 0), (0, -1), (0, 1)] 59 | .iter() 60 | .filter_map(move |&(dx, dy)| wrap_point(x + dx, y + dy, width, height, toroidal)) 61 | } 62 | 63 | pub fn surrounding_diagonal_points(&self, p: Point) -> impl Iterator { 64 | let x = p.0 as i32; 65 | let y = p.1 as i32; 66 | let width = self.width as i32; 67 | let height = self.height as i32; 68 | let toroidal = self.toroidal; 69 | [(-1, -1), (1, -1), (1, 1), (-1, 1)] 70 | .iter() 71 | .filter_map(move |&(dx, dy)| wrap_point(x + dx, y + dy, width, height, toroidal)) 72 | } 73 | } 74 | 75 | impl Board { 76 | pub fn hash(&self) -> u64 { 77 | let mut hasher = DefaultHasher::new(); 78 | Hash::hash(&self, &mut hasher); 79 | hasher.finish() 80 | } 81 | } 82 | 83 | fn wrap_point(x: i32, y: i32, width: i32, height: i32, toroidal: bool) -> Option { 84 | if x >= 0 && x < width && y >= 0 && y < height { 85 | Some((x as u32, y as u32)) 86 | } else if toroidal { 87 | let x = if x < 0 { 88 | x + width 89 | } else if x >= width { 90 | x - width 91 | } else { 92 | x 93 | }; 94 | let y = if y < 0 { 95 | y + height 96 | } else if y >= height { 97 | y - height 98 | } else { 99 | y 100 | }; 101 | Some((x as u32, y as u32)) 102 | } else { 103 | None 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /client/src/networking.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use futures::{select, stream::FusedStream, SinkExt, StreamExt}; 3 | use gloo_net::websocket::futures::WebSocket; 4 | use gloo_timers::future::TimeoutFuture; 5 | use shared::message::{ClientMessage, ServerMessage}; 6 | 7 | use crate::config; 8 | 9 | enum LoopFlow { 10 | Reconnect, 11 | Exit, 12 | } 13 | 14 | pub(crate) fn use_websocket_provider<'a>( 15 | cx: &'a ScopeState, 16 | mut on_connect: impl FnMut() -> Vec + 'static, 17 | mut receive: impl FnMut(ServerMessage) + 'static, 18 | ) -> impl Fn(ClientMessage) + 'a { 19 | use_coroutine(cx, move |rx: UnboundedReceiver| async move { 20 | log::info!("Connecting to WebSocket"); 21 | let mut rx = rx.fuse(); 22 | loop { 23 | let mut ws = match WebSocket::open(config::WS_URL) { 24 | Ok(ws) => ws, 25 | Err(e) => { 26 | log::error!("Failed to connect to WebSocket: {}", e); 27 | TimeoutFuture::new(config::CONN_RETRY_DELAY).await; 28 | continue; 29 | } 30 | }; 31 | for message in on_connect() { 32 | log::debug!("Sending initial message: {:?}", message); 33 | ws.send(pack(message)).await.unwrap(); 34 | } 35 | match connection_loop(ws, &mut rx, &mut receive).await { 36 | LoopFlow::Reconnect => { 37 | log::info!("Reconnecting to WebSocket"); 38 | TimeoutFuture::new(config::CONN_RETRY_DELAY).await; 39 | continue; 40 | } 41 | LoopFlow::Exit => { 42 | log::info!("Exiting WebSocket connection loop"); 43 | break; 44 | } 45 | } 46 | } 47 | }); 48 | 49 | use_websocket(cx) 50 | } 51 | 52 | async fn connection_loop( 53 | ws: WebSocket, 54 | rx: &mut (impl FusedStream + Unpin), 55 | receive: &mut impl FnMut(ServerMessage), 56 | ) -> LoopFlow { 57 | let (mut ws_sender, ws_recv) = ws.split(); 58 | use gloo_net::websocket::Message; 59 | let mut ws_recv = ws_recv.fuse(); 60 | loop { 61 | select! { 62 | msg = ws_recv.next() => { 63 | let Some(Ok(msg)) = msg else { 64 | log::info!("{:?}", msg); 65 | log::warn!("WebSocket closed"); 66 | return LoopFlow::Reconnect; 67 | }; 68 | let msg: ServerMessage = match msg { 69 | Message::Text(text) => { 70 | log::warn!("Received text message: {}", text); 71 | serde_cbor::from_slice(text.as_bytes()).unwrap() 72 | } 73 | Message::Bytes(bytes) => { 74 | serde_cbor::from_slice(&bytes).unwrap() 75 | } 76 | }; 77 | receive(msg); 78 | } 79 | msg = rx.next() => { 80 | let Some(msg) = msg else { 81 | log::error!("Client message channel closed"); 82 | return LoopFlow::Exit; 83 | }; 84 | log::debug!("Sending: {:?}", msg); 85 | ws_sender.send(pack(msg)).await.unwrap(); 86 | } 87 | } 88 | } 89 | } 90 | 91 | fn pack(msg: ClientMessage) -> gloo_net::websocket::Message { 92 | let bytes = serde_cbor::to_vec(&msg).expect("cbor fail"); 93 | gloo_net::websocket::Message::Bytes(bytes) 94 | } 95 | 96 | pub(crate) fn use_websocket<'a>(cx: &'a ScopeState) -> impl Fn(ClientMessage) + Copy + 'a { 97 | let handle = 98 | use_coroutine_handle(cx).expect("use_websocket called outside of websocket provider"); 99 | move |msg| handle.send(msg) 100 | } 101 | -------------------------------------------------------------------------------- /shared/src/game/snapshots/shared__game__tests__replay_snapshots@antti-4+1-1.txt.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: shared/src/game/tests.rs 3 | expression: view 4 | input_file: shared/src/game/replays/antti-4+1-1.txt 5 | --- 6 | GameView { 7 | state: Play( 8 | PlayState { 9 | players_passed: [ 10 | false, 11 | false, 12 | ], 13 | last_stone: Some( 14 | [( 15 | 7, 16 | 7, 17 | )], 18 | ), 19 | capture_count: 14, 20 | }, 21 | ), 22 | seats: [Seat { 23 | player: Some( 24 | 49, 25 | ), 26 | team: 1, 27 | resigned: false, 28 | }, Seat { 29 | player: Some( 30 | 47, 31 | ), 32 | team: 2, 33 | resigned: false, 34 | }], 35 | turn: 1, 36 | board: [ 37 | 0, 38 | 0, 39 | 0, 40 | 0, 41 | 0, 42 | 0, 43 | 0, 44 | 0, 45 | 0, 46 | 0, 47 | 0, 48 | 0, 49 | 0, 50 | 0, 51 | 0, 52 | 0, 53 | 0, 54 | 0, 55 | 0, 56 | 0, 57 | 0, 58 | 0, 59 | 0, 60 | 0, 61 | 0, 62 | 0, 63 | 0, 64 | 2, 65 | 0, 66 | 0, 67 | 0, 68 | 2, 69 | 0, 70 | 0, 71 | 0, 72 | 1, 73 | 0, 74 | 0, 75 | 0, 76 | 0, 77 | 1, 78 | 2, 79 | 2, 80 | 2, 81 | 2, 82 | 0, 83 | 0, 84 | 0, 85 | 0, 86 | 1, 87 | 0, 88 | 0, 89 | 0, 90 | 1, 91 | 1, 92 | 1, 93 | 1, 94 | 2, 95 | 0, 96 | 2, 97 | 0, 98 | 2, 99 | 1, 100 | 0, 101 | 0, 102 | 0, 103 | 0, 104 | 0, 105 | 1, 106 | 0, 107 | 2, 108 | 2, 109 | 0, 110 | 2, 111 | 1, 112 | 1, 113 | 0, 114 | 0, 115 | 0, 116 | 1, 117 | 2, 118 | 1, 119 | 1, 120 | 1, 121 | 2, 122 | 2, 123 | 0, 124 | 2, 125 | 2, 126 | 2, 127 | 1, 128 | 0, 129 | 1, 130 | 0, 131 | 1, 132 | 0, 133 | 0, 134 | 2, 135 | 1, 136 | 0, 137 | 0, 138 | 2, 139 | 1, 140 | 0, 141 | 0, 142 | 2, 143 | 1, 144 | 0, 145 | 0, 146 | 0, 147 | 2, 148 | 0, 149 | 2, 150 | 0, 151 | 0, 152 | 2, 153 | 1, 154 | 2, 155 | 2, 156 | 2, 157 | 2, 158 | 0, 159 | 1, 160 | 2, 161 | 0, 162 | 2, 163 | 0, 164 | 2, 165 | 1, 166 | 0, 167 | 0, 168 | 2, 169 | 0, 170 | 0, 171 | 2, 172 | 1, 173 | 1, 174 | 2, 175 | 2, 176 | 0, 177 | 2, 178 | 0, 179 | 0, 180 | 0, 181 | 2, 182 | 1, 183 | 1, 184 | 1, 185 | 1, 186 | 2, 187 | 2, 188 | 2, 189 | 2, 190 | 1, 191 | 0, 192 | 0, 193 | 0, 194 | 0, 195 | 0, 196 | 0, 197 | 0, 198 | 2, 199 | 0, 200 | 0, 201 | 0, 202 | 0, 203 | 0, 204 | 0, 205 | 0, 206 | ], 207 | board_visibility: None, 208 | hidden_stones_left: 0, 209 | size: ( 210 | 13, 211 | 13, 212 | ), 213 | mods: GameModifier { 214 | pixel: false, 215 | ponnuki_is_points: None, 216 | zen_go: None, 217 | hidden_move: None, 218 | visibility_mode: None, 219 | no_history: false, 220 | n_plus_one: Some( 221 | NPlusOne { 222 | length: 4, 223 | }, 224 | ), 225 | captures_give_points: None, 226 | tetris: None, 227 | toroidal: None, 228 | }, 229 | points: [0, 15], 230 | move_number: 87, 231 | } 232 | -------------------------------------------------------------------------------- /shared/src/message.rs: -------------------------------------------------------------------------------- 1 | use derive_more::From; 2 | use serde::{Deserialize, Serialize}; 3 | use std::borrow::Cow; 4 | 5 | use crate::game; 6 | 7 | /////////////////////////////////////////////////////////////////////////////// 8 | // Client messages // 9 | /////////////////////////////////////////////////////////////////////////////// 10 | 11 | #[derive(Serialize, Deserialize, Debug, Clone)] 12 | pub enum GameAction { 13 | Place(u32, u32), 14 | Pass, 15 | Cancel, 16 | Resign, 17 | BoardAt(u32, u32), 18 | TakeSeat(u32), 19 | LeaveSeat(u32), 20 | KickPlayer(u64), 21 | RequestSGF, 22 | } 23 | 24 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] 25 | pub struct StartGame { 26 | pub name: String, 27 | pub seats: Vec, 28 | pub komis: Vec, 29 | pub size: (u8, u8), 30 | pub mods: game::GameModifier, 31 | } 32 | 33 | #[derive(Serialize, Deserialize, Debug, Clone)] 34 | pub enum AdminAction { 35 | UnloadRoom(u32), 36 | } 37 | 38 | #[derive(Serialize, Deserialize, Debug, Clone)] 39 | pub enum ClientMode { 40 | Client, 41 | Integration, 42 | } 43 | 44 | #[derive(Serialize, Deserialize, Debug, Clone, From)] 45 | pub enum ClientMessage { 46 | #[from(ignore)] 47 | Identify { 48 | token: Option, 49 | nick: Option, 50 | }, 51 | #[from(ignore)] 52 | GetGameList, 53 | #[from(ignore)] 54 | JoinGame(u32), 55 | /// `None` leaves all rooms 56 | #[from(ignore)] 57 | LeaveGame(Option), 58 | #[from(ignore)] 59 | GameAction { 60 | room_id: Option, 61 | action: GameAction, 62 | }, 63 | StartGame(StartGame), 64 | Admin(AdminAction), 65 | Mode(ClientMode), 66 | } 67 | 68 | impl std::convert::From for ClientMessage { 69 | fn from(action: GameAction) -> Self { 70 | ClientMessage::GameAction { 71 | room_id: None, 72 | action, 73 | } 74 | } 75 | } 76 | 77 | /////////////////////////////////////////////////////////////////////////////// 78 | // Server messages // 79 | /////////////////////////////////////////////////////////////////////////////// 80 | 81 | #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] 82 | pub struct Profile { 83 | pub user_id: u64, 84 | pub nick: Option, 85 | } 86 | 87 | #[derive(Serialize, Deserialize, Debug, Clone, From)] 88 | pub enum GameError { 89 | TakeSeat(game::TakeSeatError), 90 | Action(game::MakeActionError), 91 | } 92 | 93 | #[derive(Serialize, Deserialize, Debug, Clone)] 94 | pub enum Error { 95 | /// This error means the client has to wait for x seconds before it can create a game 96 | GameStartTimer(u64), 97 | Game { 98 | room_id: u32, 99 | error: GameError, 100 | }, 101 | RateLimit, 102 | Other(Cow<'static, str>), 103 | } 104 | 105 | impl Error { 106 | pub fn other(v: &'static str) -> Self { 107 | Error::Other(Cow::from(v)) 108 | } 109 | } 110 | 111 | #[derive(Serialize, Deserialize, Debug, Clone)] 112 | #[allow(clippy::large_enum_variant)] 113 | pub enum ServerMessage { 114 | Identify { 115 | token: String, 116 | nick: Option, 117 | user_id: u64, 118 | }, 119 | AnnounceGame { 120 | room_id: u32, 121 | name: String, 122 | }, 123 | CloseGame { 124 | room_id: u32, 125 | }, 126 | GameStatus { 127 | room_id: u32, 128 | owner: u64, 129 | members: Vec, 130 | seats: Vec<(Option, u8, bool)>, 131 | turn: u32, 132 | // 19x19 vec, 0 = empty, 1 = black, 2 = white 133 | board: Vec, 134 | board_visibility: Option>, 135 | hidden_stones_left: u32, 136 | size: (u8, u8), 137 | state: game::GameStateView, 138 | mods: game::GameModifier, 139 | points: Vec, 140 | move_number: u32, 141 | clock: Option, 142 | }, 143 | BoardAt { 144 | room_id: u32, 145 | view: game::GameHistory, 146 | }, 147 | SGF { 148 | room_id: u32, 149 | sgf: String, 150 | }, 151 | Profile(Profile), 152 | ServerTime(game::clock::Millisecond), 153 | MsgError(String), 154 | Error(Error), 155 | } 156 | 157 | impl ServerMessage { 158 | pub fn pack(&self) -> Vec { 159 | serde_cbor::to_vec(self).expect("cbor fail") 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /shared/src/game/clock.rs: -------------------------------------------------------------------------------- 1 | #[derive( 2 | Copy, Clone, Debug, Ord, PartialOrd, Eq, PartialEq, serde::Serialize, serde::Deserialize, 3 | )] 4 | #[repr(transparent)] 5 | #[serde(transparent)] 6 | pub struct Millisecond(pub i128); 7 | 8 | impl Millisecond { 9 | pub fn as_secs(self) -> f32 { 10 | self.0 as f32 / 1000. 11 | } 12 | 13 | pub fn as_minutes(self) -> f32 { 14 | self.0 as f32 / 1000. / 60. 15 | } 16 | 17 | pub fn now() -> Self { 18 | Millisecond( 19 | std::time::SystemTime::now() 20 | .duration_since(std::time::UNIX_EPOCH) 21 | .unwrap() 22 | .as_millis() as i128, 23 | ) 24 | } 25 | } 26 | 27 | impl std::ops::Sub for Millisecond { 28 | type Output = Self; 29 | 30 | fn sub(self, rhs: Self) -> Self::Output { 31 | Millisecond(self.0 - rhs.0) 32 | } 33 | } 34 | 35 | impl std::ops::Add for Millisecond { 36 | type Output = Self; 37 | 38 | fn add(self, rhs: Self) -> Self::Output { 39 | Millisecond(self.0 + rhs.0) 40 | } 41 | } 42 | 43 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] 44 | pub struct SimpleClock { 45 | turn_time: Millisecond, 46 | } 47 | 48 | impl SimpleClock { 49 | fn clock(&self) -> PlayerClock { 50 | PlayerClock::Plain { 51 | last_time: Millisecond(0), 52 | time_left: self.turn_time, 53 | } 54 | } 55 | } 56 | 57 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] 58 | pub struct FischerClock { 59 | pub main_time: Millisecond, 60 | pub increment: Millisecond, 61 | } 62 | 63 | impl FischerClock { 64 | fn clock(&self) -> PlayerClock { 65 | PlayerClock::Plain { 66 | last_time: Millisecond(0), 67 | time_left: self.main_time, 68 | } 69 | } 70 | } 71 | 72 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] 73 | pub enum ClockRule { 74 | /// Simple time gives the player exactly `turn_time` milliseconds per turn. 75 | Simple(SimpleClock), 76 | /// Fischer time adds `increment` milliseconds to the player's clock after making an action. 77 | Fischer(FischerClock), 78 | } 79 | 80 | impl ClockRule { 81 | fn clock(&self) -> PlayerClock { 82 | match self { 83 | ClockRule::Simple(rule) => rule.clock(), 84 | ClockRule::Fischer(rule) => rule.clock(), 85 | } 86 | } 87 | } 88 | 89 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] 90 | pub enum PlayerClock { 91 | /// Only main time 92 | Plain { 93 | last_time: Millisecond, 94 | time_left: Millisecond, 95 | }, 96 | } 97 | 98 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] 99 | pub struct GameClock { 100 | /// One clock per team or player. This is decided by the game controller, this module doesn't care which is used. 101 | pub clocks: Vec, 102 | pub rule: ClockRule, 103 | pub paused: bool, 104 | pub server_time: Millisecond, 105 | } 106 | 107 | impl GameClock { 108 | pub fn new(rule: ClockRule, clock_count: usize) -> Self { 109 | GameClock { 110 | clocks: vec![rule.clock(); clock_count], 111 | rule, 112 | paused: true, 113 | server_time: Millisecond(0), 114 | } 115 | } 116 | 117 | pub fn initialize_clocks(&mut self, initial_time: Millisecond) { 118 | for clock in &mut self.clocks { 119 | match clock { 120 | PlayerClock::Plain { last_time, .. } => { 121 | *last_time = initial_time; 122 | } 123 | } 124 | } 125 | } 126 | 127 | /// Returns the time left for the given clock at current timestamp `time`. 128 | pub fn advance_clock(&mut self, clock_idx: usize, time: Millisecond) -> Millisecond { 129 | if self.paused { 130 | return Millisecond(0); 131 | } 132 | 133 | let clock = &mut self.clocks[clock_idx]; 134 | 135 | match clock { 136 | PlayerClock::Plain { 137 | last_time, 138 | time_left, 139 | } => { 140 | let duration = time - *last_time; 141 | *time_left = *time_left - duration; 142 | *time_left 143 | } 144 | } 145 | } 146 | 147 | pub fn end_turn(&mut self, clock_idx: usize, time: Millisecond) { 148 | if self.paused { 149 | return; 150 | } 151 | 152 | let clock = &mut self.clocks[clock_idx]; 153 | 154 | match &mut self.rule { 155 | ClockRule::Simple(rule) => match clock { 156 | PlayerClock::Plain { time_left, .. } => { 157 | *time_left = rule.turn_time; 158 | } 159 | }, 160 | ClockRule::Fischer(rule) => match clock { 161 | PlayerClock::Plain { time_left, .. } => { 162 | *time_left = *time_left + rule.increment; 163 | } 164 | }, 165 | } 166 | 167 | for clock in &mut self.clocks { 168 | match clock { 169 | PlayerClock::Plain { last_time, .. } => { 170 | *last_time = time; 171 | } 172 | } 173 | } 174 | } 175 | 176 | pub fn pause(&mut self, paused: bool) { 177 | self.paused = paused; 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /store/src/store.rs: -------------------------------------------------------------------------------- 1 | /// This is stolen quite directly from `yewtil`. It currently (as of 0.17.3) 2 | /// doesn't provide quite the API I want here and adding it all is a major 3 | /// breaking change, so copy and fix it is. 4 | use std::cell::RefCell; 5 | use std::collections::HashSet; 6 | use std::ops::Deref; 7 | use std::rc::Rc; 8 | use yew::agent::{Agent, AgentLink, Context, Discoverer, Dispatcher, HandlerId}; 9 | use yew::prelude::*; 10 | 11 | pub type StoreBridge = Box>>; 12 | 13 | /// A functional state wrapper, enforcing a unidirectional 14 | /// data flow and consistent state to the observers. 15 | /// 16 | /// `handle_input` receives incoming messages from components, 17 | /// `reduce` applies changes to the state 18 | /// 19 | /// Once created with a first bridge, a Store will never be destroyed 20 | /// for the lifetime of the application. 21 | pub trait Store: Sized + 'static { 22 | /// Messages instructing the store to do somethin 23 | type Input; 24 | /// State updates to be consumed by `reduce` 25 | type Action; 26 | 27 | /// Create a new Store 28 | fn new() -> Self; 29 | 30 | /// Receives messages from components and other agents. Use the `link` 31 | /// to send actions to itself in order to notify `reduce` once your 32 | /// operation completes. This is the place to do side effects, like 33 | /// talking to the server, or asking the user for input. 34 | /// 35 | /// Note that you can look at the state of your Store, but you 36 | /// cannot modify it here. If you want to modify it, send a Message 37 | /// to the reducer 38 | fn handle_input(&self, link: AgentLink>, msg: Self::Input); 39 | 40 | /// A pure function, with no side effects. Receives a message, 41 | /// and applies it to the state as it sees fit. 42 | fn reduce(&mut self, msg: Self::Action); 43 | } 44 | 45 | /// Hides the full context Agent from a Store and does 46 | /// the boring data wrangling logic 47 | #[derive(Debug)] 48 | pub struct StoreWrapper { 49 | /// Currently subscribed components and agents 50 | pub handlers: HashSet, 51 | /// Link to itself so Store::handle_input can send actions to reducer 52 | pub link: AgentLink, 53 | 54 | /// The actual Store 55 | pub state: Shared, 56 | 57 | /// A circular dispatcher to itself so the store is not removed 58 | pub self_dispatcher: Dispatcher, 59 | } 60 | 61 | type Shared = Rc>; 62 | 63 | /// A wrapper ensuring state observers can only 64 | /// borrow the state immutably 65 | #[derive(Debug)] 66 | pub struct ReadOnly { 67 | state: Shared, 68 | } 69 | 70 | impl ReadOnly { 71 | /// Allow only immutable borrows to the underlying data 72 | pub fn borrow<'a>(&'a self) -> impl Deref + 'a { 73 | self.state.borrow() 74 | } 75 | } 76 | 77 | /// This is a wrapper, intended to be used as an opaque 78 | /// machinery allowing the Store to do it's things. 79 | impl Agent for StoreWrapper { 80 | type Reach = Context; 81 | type Message = S::Action; 82 | type Input = S::Input; 83 | type Output = ReadOnly; 84 | 85 | fn create(link: AgentLink) -> Self { 86 | let state = Rc::new(RefCell::new(S::new())); 87 | let handlers = HashSet::new(); 88 | 89 | // Link to self to never go out of scope 90 | let self_dispatcher = Self::dispatcher(); 91 | 92 | StoreWrapper { 93 | handlers, 94 | state, 95 | link, 96 | self_dispatcher, 97 | } 98 | } 99 | 100 | fn update(&mut self, msg: Self::Message) { 101 | { 102 | self.state.borrow_mut().reduce(msg); 103 | } 104 | 105 | for handler in self.handlers.iter() { 106 | self.link.respond( 107 | *handler, 108 | ReadOnly { 109 | state: self.state.clone(), 110 | }, 111 | ); 112 | } 113 | } 114 | 115 | fn connected(&mut self, id: HandlerId) { 116 | self.handlers.insert(id); 117 | self.link.respond( 118 | id, 119 | ReadOnly { 120 | state: self.state.clone(), 121 | }, 122 | ); 123 | } 124 | 125 | fn handle_input(&mut self, msg: Self::Input, _id: HandlerId) { 126 | self.state.borrow().handle_input(self.link.clone(), msg); 127 | } 128 | 129 | fn disconnected(&mut self, id: HandlerId) { 130 | self.handlers.remove(&id); 131 | } 132 | } 133 | 134 | // This instance is quite unfortunate, as the Rust compiler 135 | // does not support mutually exclusive trait bounds (https://github.com/rust-lang/rust/issues/51774), 136 | // we have to create a new trait with the same function as in the original one. 137 | 138 | /// Allows us to communicate with a store 139 | pub trait Bridgeable: Sized + 'static { 140 | /// A wrapper for the store we want to bridge to, 141 | /// which serves as a communication intermediary 142 | type Wrapper: Agent; 143 | 144 | /// Creates a messaging bridge between a worker and the component. 145 | fn bridge( 146 | callback: Callback<::Output>, 147 | ) -> Box>; 148 | } 149 | 150 | /// Implementation of bridge creation 151 | impl Bridgeable for T 152 | where 153 | T: Store, 154 | { 155 | /// The hiding wrapper 156 | type Wrapper = StoreWrapper; 157 | 158 | fn bridge( 159 | callback: Callback<::Output>, 160 | ) -> Box> { 161 | ::Reach::spawn_or_join(Some(callback)) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /shared/src/states/play/n_plus_one.rs: -------------------------------------------------------------------------------- 1 | use crate::game::{Board, GroupVec, NPlusOne, Point, Visibility, VisibilityBoard}; 2 | 3 | pub enum NPlusOneResult { 4 | ExtraTurn, 5 | Nothing, 6 | } 7 | 8 | pub fn check( 9 | points_played: &GroupVec, 10 | board: &Board, 11 | mut visibility: Option<&mut VisibilityBoard>, 12 | rule: &NPlusOne, 13 | ) -> NPlusOneResult { 14 | let mut line_points = Vec::new(); 15 | 16 | let mut matched = false; 17 | 18 | for &point_played in points_played { 19 | let color = board.get_point(point_played); 20 | 21 | if color.is_empty() { 22 | // The point can be empty if the group was killed by traitor-suicide. 23 | // Skip it in that case. 24 | continue; 25 | } 26 | 27 | let add_point = |line_points: &mut Vec, p: Point| { 28 | if board.get_point(p) == color && !line_points.contains(&p) { 29 | line_points.push(p); 30 | false 31 | } else { 32 | true 33 | } 34 | }; 35 | 36 | // Vertical /////////////////////////////////////////////////////////// 37 | 38 | let mut y = point_played.1 as i32 - 1; 39 | while let Some(p) = board.wrap_point(point_played.0 as i32, y) { 40 | if add_point(&mut line_points, p) { 41 | break; 42 | } 43 | y -= 1; 44 | } 45 | 46 | let mut y = point_played.1 as i32; 47 | while let Some(p) = board.wrap_point(point_played.0 as i32, y) { 48 | if add_point(&mut line_points, p) { 49 | break; 50 | } 51 | y += 1; 52 | } 53 | 54 | let vertical_match = line_points.len() == rule.length as usize; 55 | 56 | if vertical_match { 57 | if let Some(visibility) = visibility.as_mut() { 58 | for &p in &line_points { 59 | *visibility.point_mut(p) = Visibility::new(); 60 | } 61 | } 62 | } 63 | 64 | line_points.clear(); 65 | 66 | // Horizontal ///////////////////////////////////////////////////////// 67 | 68 | let mut x = point_played.0 as i32 - 1; 69 | while let Some(p) = board.wrap_point(x, point_played.1 as i32) { 70 | if add_point(&mut line_points, p) { 71 | break; 72 | } 73 | x -= 1; 74 | } 75 | 76 | let mut x = point_played.0 as i32; 77 | while let Some(p) = board.wrap_point(x, point_played.1 as i32) { 78 | if add_point(&mut line_points, p) { 79 | break; 80 | } 81 | x += 1; 82 | } 83 | 84 | let horizontal_match = line_points.len() == rule.length as usize; 85 | 86 | if horizontal_match { 87 | if let Some(visibility) = visibility.as_mut() { 88 | for &p in &line_points { 89 | *visibility.point_mut(p) = Visibility::new(); 90 | } 91 | } 92 | } 93 | 94 | line_points.clear(); 95 | 96 | // Diagonal top left - bottom right /////////////////////////////////// 97 | 98 | let mut point = (point_played.0 as i32 - 1, point_played.1 as i32 - 1); 99 | while let Some(p) = board.wrap_point(point.0, point.1) { 100 | if add_point(&mut line_points, p) { 101 | break; 102 | } 103 | 104 | point.0 -= 1; 105 | point.1 -= 1; 106 | } 107 | 108 | let mut point = (point_played.0 as i32, point_played.1 as i32); 109 | while let Some(p) = board.wrap_point(point.0, point.1) { 110 | if add_point(&mut line_points, p) { 111 | break; 112 | } 113 | 114 | point.0 += 1; 115 | point.1 += 1; 116 | } 117 | 118 | let diagonal_tlbr_match = line_points.len() == rule.length as usize; 119 | 120 | if diagonal_tlbr_match { 121 | if let Some(visibility) = visibility.as_mut() { 122 | for &p in &line_points { 123 | *visibility.point_mut(p) = Visibility::new(); 124 | } 125 | } 126 | } 127 | 128 | line_points.clear(); 129 | 130 | // Diagonal bottom left - top right /////////////////////////////////// 131 | 132 | let mut point = (point_played.0 as i32 - 1, point_played.1 as i32 + 1); 133 | while let Some(p) = board.wrap_point(point.0, point.1) { 134 | if add_point(&mut line_points, p) { 135 | break; 136 | } 137 | 138 | point.0 -= 1; 139 | point.1 += 1; 140 | } 141 | 142 | let mut point = (point_played.0 as i32, point_played.1 as i32); 143 | while let Some(p) = board.wrap_point(point.0, point.1) { 144 | if add_point(&mut line_points, p) { 145 | break; 146 | } 147 | 148 | point.0 += 1; 149 | point.1 -= 1; 150 | } 151 | 152 | let diagonal_bltr_match = line_points.len() == rule.length as usize; 153 | 154 | if diagonal_bltr_match { 155 | if let Some(visibility) = visibility.as_mut() { 156 | for &p in &line_points { 157 | *visibility.point_mut(p) = Visibility::new(); 158 | } 159 | } 160 | } 161 | 162 | line_points.clear(); 163 | 164 | matched = matched 165 | || vertical_match 166 | || horizontal_match 167 | || diagonal_tlbr_match 168 | || diagonal_bltr_match; 169 | } 170 | 171 | if matched { 172 | return NPlusOneResult::ExtraTurn; 173 | } 174 | 175 | NPlusOneResult::Nothing 176 | } 177 | -------------------------------------------------------------------------------- /privacy_policy.md: -------------------------------------------------------------------------------- 1 | 2 | # Privacy Policy for Variant Go Server 3 | 4 | At Variant Go Server, accessible from http://go.kahv.io/, one of our main priorities is the privacy of our visitors. This Privacy Policy document contains types of information that is collected and recorded by Variant Go Server and how we use it. 5 | 6 | If you have additional questions or require more information about our Privacy Policy, do not hesitate to contact us. 7 | 8 | This Privacy Policy applies only to our online activities and is valid for visitors to our website with regards to the information that they shared and/or collect in Variant Go Server. This policy is not applicable to any information collected offline or via channels other than this website. 9 | 10 | ## Consent 11 | 12 | By using our website, you hereby consent to our Privacy Policy and agree to its terms. 13 | 14 | ## Information we collect 15 | 16 | The personal information that you are asked to provide, and the reasons why you are asked to provide it, will be made clear to you at the point we ask you to provide your personal information. 17 | 18 | If you contact us directly, we may receive additional information about you such as your name, email address, phone number, the contents of the message and/or attachments you may send us, and any other information you may choose to provide. 19 | 20 | When you register for an Account, we may ask for your contact information, including items such as email address. 21 | 22 | *Non-Identifying Information*. When you visit the website, our third-party analytics tool SimpleAnalytics collects some non-identifying information. However, this information is not tied to you in any way. You can read more about it from the [SimpleAnalytics website](https://docs.simpleanalytics.com/what-we-collect). These analytics are publically available and can be viewed at https://simpleanalytics.com/go.kahv.io. The cost of this tool is covered by [BadukClub](https://baduk.club) and if you also have a website and are looking for an ethical and beautiful analytics solution, please consider using this [referral link](https://referral.simpleanalytics.com/devin-fraze) to sign up and we’ll both receive one month free! 23 | 24 | ## How we use your information 25 | 26 | We use the information we collect in various ways, including to: 27 | 28 | - Provide, operate, and maintain our website 29 | - Improve, personalize, and expand our website 30 | - Understand and analyze how you use our website 31 | - Develop new products, services, features, and functionality 32 | - Communicate with you, either directly or through one of our partners, to provide you with updates and other information relating to the website 33 | - Send you emails 34 | 35 | ## Log Files 36 | 37 | Variant Go Server follows a standard procedure of using log files. These files log visitors when they visit websites. All hosting companies do this and a part of hosting services' analytics. The information collected by log files include internet protocol (IP) addresses, browser type, Internet Service Provider (ISP), date and time stamp and referring/exit pages. These are not linked to any information that is personally identifiable. The purpose of the information is solely for administering the site. 38 | 39 | ## Cookies and Web Beacons 40 | 41 | Like any other website, Variant Go Server uses 'cookies'. These cookies are used to store information including visitors' preferences, and the pages on the website that the visitor accessed or visited. The information is used to optimize the users' experience by customizing our web page content based on visitors' browser type and/or other information. 42 | 43 | For more general information on cookies, please read "[What Are Cookies](https://www.cookieconsent.com/what-are-cookies/ "_link")". 44 | 45 | ## Advertising Partners Privacy Policies 46 | 47 | Variant Go Server does no business with third-party ad services. None of the collected information will be shared with other services. 48 | 49 | ## Third Party Privacy Policies 50 | 51 | Variant Go Server's Privacy Policy does not apply to other advertisers or websites. Thus, we are advising you to consult the respective Privacy Policies of these third-party ad servers for more detailed information. It may include their practices and instructions about how to opt-out of certain options. 52 | 53 | You can choose to disable cookies through your individual browser options. To know more detailed information about cookie management with specific web browsers, it can be found at the browsers' respective websites. 54 | 55 | ## CCPA Privacy Rights (Do Not Sell My Personal Information) 56 | 57 | Under the CCPA, among other rights, California consumers have the right to: 58 | 59 | Request that a business that collects a consumer's personal data disclose the categories and specific pieces of personal data that a business has collected about consumers. 60 | 61 | Request that a business delete any personal data about the consumer that a business has collected. 62 | 63 | Request that a business that sells a consumer's personal data, not sell the consumer's personal data. 64 | 65 | If you make a request, we have one month to respond to you. If you would like to exercise any of these rights, please contact us. 66 | 67 | ## GDPR Data Protection Rights 68 | 69 | We would like to make sure you are fully aware of all of your data protection rights. Every user is entitled to the following: 70 | 71 | The right to access – You have the right to request copies of your personal data. 72 | 73 | The right to rectification – You have the right to request that we correct any information you believe is inaccurate. You also have the right to request that we complete the information you believe is incomplete. 74 | 75 | The right to erasure – You have the right to request that we erase your personal data, under certain conditions. 76 | 77 | The right to restrict processing – You have the right to request that we restrict the processing of your personal data, under certain conditions. 78 | 79 | The right to object to processing – You have the right to object to our processing of your personal data, under certain conditions. 80 | 81 | The right to data portability – You have the right to request that we transfer the data that we have collected to another organization, or directly to you, under certain conditions. 82 | 83 | If you make a request, we have one month to respond to you. If you would like to exercise any of these rights, please contact us. 84 | 85 | ## Children's Information 86 | 87 | Another part of our priority is adding protection for children while using the internet. We encourage parents and guardians to observe, participate in, and/or monitor and guide their online activity. 88 | 89 | Variant Go Server does not knowingly collect any Personal Identifiable Information from children under the age of 13. If you think that your child provided this kind of information on our website, we strongly encourage you to contact us immediately and we will do our best efforts to promptly remove such information from our records. 90 | -------------------------------------------------------------------------------- /shared/src/states/scoring.rs: -------------------------------------------------------------------------------- 1 | use crate::game::{ 2 | find_groups, ActionChange, ActionKind, Board, Color, GameState, Group, GroupVec, 3 | MakeActionResult, Point, Seat, SharedState, 4 | }; 5 | use serde::{Deserialize, Serialize}; 6 | use std::collections::{HashSet, VecDeque}; 7 | 8 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 9 | pub struct ScoringState { 10 | pub groups: Vec, 11 | /// Vector of the board, marking who owns a point 12 | pub points: Board, 13 | pub scores: GroupVec, 14 | // TODO: use smallvec? 15 | pub players_accepted: Vec, 16 | } 17 | 18 | impl ScoringState { 19 | pub fn new(board: &Board, seats: &[Seat], scores: &[i32]) -> Self { 20 | let groups = find_groups(board); 21 | let points = score_board(board, &groups); 22 | let mut scores: GroupVec = scores.into(); 23 | for color in &points.points { 24 | if !color.is_empty() { 25 | scores[color.0 as usize - 1] += 2; 26 | } 27 | } 28 | ScoringState { 29 | groups, 30 | points, 31 | scores, 32 | players_accepted: seats.iter().map(|s| s.resigned).collect(), 33 | } 34 | } 35 | 36 | pub fn make_action_place( 37 | &mut self, 38 | shared: &mut SharedState, 39 | point: Point, 40 | ) -> MakeActionResult { 41 | let group = self.groups.iter_mut().find(|g| g.points.contains(&point)); 42 | 43 | let group = match group { 44 | Some(g) => g, 45 | None => return Ok(ActionChange::None), 46 | }; 47 | 48 | group.alive = !group.alive; 49 | 50 | self.points = score_board(&shared.board, &self.groups); 51 | self.scores = shared.points.clone(); 52 | for color in &self.points.points { 53 | if !color.is_empty() { 54 | self.scores[color.0 as usize - 1] += 2; 55 | } 56 | } 57 | 58 | for (idx, accept) in self.players_accepted.iter_mut().enumerate() { 59 | *accept = shared.seats[idx].resigned; 60 | } 61 | 62 | Ok(ActionChange::None) 63 | } 64 | 65 | pub fn make_action_pass( 66 | &mut self, 67 | shared: &mut SharedState, 68 | player_id: u64, 69 | ) -> MakeActionResult { 70 | // A single player can hold multiple seats so we have to mark every seat they hold 71 | let seats = shared 72 | .seats 73 | .iter() 74 | .enumerate() 75 | .filter(|x| x.1.player == Some(player_id)); 76 | 77 | for (seat_idx, _) in seats { 78 | self.players_accepted[seat_idx] = true; 79 | } 80 | if self.players_accepted.iter().all(|x| *x) { 81 | Ok(ActionChange::SwapState(GameState::Done(self.clone()))) 82 | } else { 83 | Ok(ActionChange::None) 84 | } 85 | } 86 | 87 | fn make_action_resign(&mut self, shared: &mut SharedState, player_id: u64) -> MakeActionResult { 88 | // A single player can hold multiple seats so we have to mark every seat they hold 89 | let seats = shared 90 | .seats 91 | .iter_mut() 92 | .enumerate() 93 | .filter(|x| x.1.player == Some(player_id)); 94 | 95 | for (seat_idx, seat) in seats { 96 | seat.resigned = true; 97 | self.players_accepted[seat_idx] = true; 98 | } 99 | 100 | if self.players_accepted.iter().all(|x| *x) { 101 | Ok(ActionChange::SwapState(GameState::Done(self.clone()))) 102 | } else { 103 | Ok(ActionChange::None) 104 | } 105 | } 106 | 107 | pub fn make_action( 108 | &mut self, 109 | shared: &mut SharedState, 110 | player_id: u64, 111 | action: ActionKind, 112 | ) -> MakeActionResult { 113 | match action { 114 | ActionKind::Place(x, y) => self.make_action_place(shared, (x, y)), 115 | ActionKind::Pass => self.make_action_pass(shared, player_id), 116 | ActionKind::Cancel => Ok(ActionChange::PopState), 117 | ActionKind::Resign => self.make_action_resign(shared, player_id), 118 | } 119 | } 120 | } 121 | 122 | /// Scores a board by filling in fully surrounded empty spaces based on chinese rules 123 | fn score_board(board: &Board, groups: &[Group]) -> Board { 124 | let &Board { 125 | width, 126 | height, 127 | toroidal, 128 | .. 129 | } = board; 130 | let mut board = Board::empty(width, height, toroidal); 131 | 132 | // Fill living groups to the board 133 | for group in groups { 134 | if !group.alive { 135 | continue; 136 | } 137 | for point in &group.points { 138 | *board.point_mut(*point) = group.team; 139 | } 140 | } 141 | 142 | // Find empty points 143 | let mut legal_points = board 144 | .points 145 | .iter() 146 | .enumerate() 147 | .filter_map(|(idx, c)| { 148 | if c.is_empty() { 149 | board.idx_to_coord(idx) 150 | } else { 151 | None 152 | } 153 | }) 154 | .collect::>(); 155 | 156 | #[derive(Copy, Clone)] 157 | enum SeenTeams { 158 | Zero, 159 | One(Color), 160 | Many, 161 | } 162 | use SeenTeams::*; 163 | 164 | let mut seen = HashSet::new(); 165 | let mut stack = VecDeque::new(); 166 | let mut marked = Vec::new(); 167 | 168 | while let Some(point) = legal_points.pop() { 169 | stack.push_back(point); 170 | 171 | let mut collisions = SeenTeams::Zero; 172 | 173 | while let Some(point) = stack.pop_front() { 174 | marked.push(point); 175 | for point in board.surrounding_points(point) { 176 | if !seen.insert(point) { 177 | continue; 178 | } 179 | 180 | match board.get_point(point) { 181 | Color(0) => { 182 | stack.push_back(point); 183 | legal_points.retain(|x| *x != point); 184 | } 185 | c => { 186 | collisions = match collisions { 187 | Zero => One(c), 188 | One(x) if x == c => One(x), 189 | One(_) => Many, 190 | Many => Many, 191 | } 192 | } 193 | } 194 | } 195 | } 196 | 197 | // The floodfill touched only a single color -> this must be their territory 198 | if let One(color) = collisions { 199 | for point in marked.drain(..) { 200 | *board.point_mut(point) = color; 201 | } 202 | } 203 | 204 | seen.clear(); 205 | marked.clear(); 206 | } 207 | 208 | board 209 | } 210 | -------------------------------------------------------------------------------- /server/src/db.rs: -------------------------------------------------------------------------------- 1 | use actix::prelude::*; 2 | 3 | use diesel::pg::PgConnection; 4 | use diesel::prelude::*; 5 | use diesel::result::Error as DError; 6 | use dotenv::dotenv; 7 | use std::env; 8 | 9 | use crate::schema::games; 10 | use crate::schema::users; 11 | 12 | fn establish_connection() -> PgConnection { 13 | dotenv().ok(); 14 | 15 | let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); 16 | PgConnection::establish(&database_url) 17 | .unwrap_or_else(|_| panic!("Error connecting to {}", database_url)) 18 | } 19 | 20 | /////////////////////////////////////////////////////////////////////////////// 21 | // Database models // 22 | /////////////////////////////////////////////////////////////////////////////// 23 | 24 | // User /////////////////////////////////////////////////////////////////////// 25 | 26 | #[derive(Queryable)] 27 | pub struct User { 28 | pub id: i64, 29 | pub auth_token: String, 30 | pub nick: Option, 31 | pub has_integration_access: bool, 32 | } 33 | 34 | #[derive(Insertable, AsChangeset)] 35 | #[table_name = "users"] 36 | pub struct NewUser<'a> { 37 | pub auth_token: &'a str, 38 | pub nick: Option<&'a str>, 39 | } 40 | 41 | // Game /////////////////////////////////////////////////////////////////////// 42 | 43 | #[derive(Queryable, Debug)] 44 | pub struct Game { 45 | pub id: i64, 46 | pub name: String, 47 | pub replay: Option>, 48 | pub owner: Option, 49 | } 50 | 51 | #[derive(Insertable, AsChangeset)] 52 | #[table_name = "games"] 53 | pub struct NewGame<'a> { 54 | pub id: Option, 55 | pub name: &'a str, 56 | pub replay: Option<&'a [u8]>, 57 | pub owner: Option, 58 | } 59 | 60 | /////////////////////////////////////////////////////////////////////////////// 61 | // Actor messages // 62 | /////////////////////////////////////////////////////////////////////////////// 63 | 64 | // User /////////////////////////////////////////////////////////////////////// 65 | 66 | pub struct IdentifyUser { 67 | pub auth_token: String, 68 | pub nick: Option, 69 | } 70 | 71 | impl Message for IdentifyUser { 72 | type Result = Result; 73 | } 74 | 75 | pub struct GetUser(pub u64); 76 | impl Message for GetUser { 77 | type Result = Result; 78 | } 79 | 80 | pub struct GetUserByToken(pub String); 81 | impl Message for GetUserByToken { 82 | type Result = Result; 83 | } 84 | 85 | // Game /////////////////////////////////////////////////////////////////////// 86 | 87 | pub struct StoreGame { 88 | pub id: Option, 89 | pub owner: Option, 90 | pub name: String, 91 | pub replay: Option>, 92 | } 93 | 94 | impl Message for StoreGame { 95 | type Result = Result; 96 | } 97 | 98 | pub struct GetGame(pub u64); 99 | 100 | impl Message for GetGame { 101 | type Result = Result; 102 | } 103 | 104 | /////////////////////////////////////////////////////////////////////////////// 105 | // Actor // 106 | /////////////////////////////////////////////////////////////////////////////// 107 | 108 | pub struct DbActor { 109 | connection: PgConnection, 110 | } 111 | 112 | impl Default for DbActor { 113 | fn default() -> Self { 114 | DbActor { 115 | connection: establish_connection(), 116 | } 117 | } 118 | } 119 | 120 | impl Actor for DbActor { 121 | type Context = SyncContext; 122 | 123 | fn stopping(&mut self, _ctx: &mut Self::Context) -> Running { 124 | println!("Database actor stopping!"); 125 | 126 | Running::Stop 127 | } 128 | } 129 | 130 | impl Handler for DbActor { 131 | type Result = Result; 132 | 133 | fn handle(&mut self, msg: IdentifyUser, _ctx: &mut Self::Context) -> Self::Result { 134 | use crate::schema::users::dsl::*; 135 | 136 | let new_user = NewUser { 137 | auth_token: &msg.auth_token, 138 | nick: msg.nick.as_deref(), 139 | }; 140 | 141 | let existing = users 142 | .filter(auth_token.eq(&msg.auth_token)) 143 | .first::(&self.connection); 144 | 145 | let result = match existing { 146 | Ok(u) => diesel::update(users.filter(id.eq(u.id))) 147 | .set(new_user) 148 | .get_result(&self.connection), 149 | Err(DError::NotFound) => diesel::insert_into(users) 150 | .values(new_user) 151 | .get_result(&self.connection), 152 | Err(e) => { 153 | println!("{:?}", e); 154 | return Err(()); 155 | } 156 | }; 157 | 158 | result.map_err(|_| ()) 159 | } 160 | } 161 | 162 | impl Handler for DbActor { 163 | type Result = Result; 164 | 165 | fn handle(&mut self, msg: GetUser, _ctx: &mut Self::Context) -> Self::Result { 166 | use crate::schema::users::dsl::*; 167 | 168 | let existing = users.find(msg.0 as i64).first::(&self.connection); 169 | 170 | match existing { 171 | Ok(u) => Ok(u), 172 | Err(e) => { 173 | println!("{:?}", e); 174 | Err(()) 175 | } 176 | } 177 | } 178 | } 179 | 180 | impl Handler for DbActor { 181 | type Result = Result; 182 | 183 | fn handle(&mut self, msg: GetUserByToken, _ctx: &mut Self::Context) -> Self::Result { 184 | use crate::schema::users::dsl::*; 185 | 186 | let existing = users 187 | .filter(auth_token.eq(msg.0)) 188 | .first::(&self.connection); 189 | 190 | match existing { 191 | Ok(u) => Ok(u), 192 | Err(e) => { 193 | println!("{:?}", e); 194 | Err(()) 195 | } 196 | } 197 | } 198 | } 199 | 200 | impl Handler for DbActor { 201 | type Result = Result; 202 | 203 | fn handle(&mut self, msg: StoreGame, _ctx: &mut Self::Context) -> Self::Result { 204 | use crate::schema::games::dsl::*; 205 | 206 | let new_game = NewGame { 207 | id: msg.id.map(|x| x as _), 208 | owner: msg.owner.map(|x| x as _), 209 | name: &msg.name, 210 | replay: msg.replay.as_deref(), 211 | }; 212 | 213 | let result = match msg.id { 214 | Some(m_id) => diesel::update(games.filter(id.eq(m_id as i64))) 215 | .set(new_game) 216 | .get_result(&self.connection), 217 | None => diesel::insert_into(games) 218 | .values(new_game) 219 | .get_result(&self.connection), 220 | }; 221 | 222 | result.map_err(|e| { 223 | println!("{:?}", e); 224 | }) 225 | } 226 | } 227 | 228 | impl Handler for DbActor { 229 | type Result = Result; 230 | 231 | fn handle(&mut self, msg: GetGame, _ctx: &mut Self::Context) -> Self::Result { 232 | use crate::schema::games::dsl::*; 233 | 234 | let result = games.find(msg.0 as i64).first(&self.connection); 235 | 236 | result.map_err(|e| { 237 | println!("{:?}", e); 238 | }) 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /shared/src/states/free_placement.rs: -------------------------------------------------------------------------------- 1 | use crate::game::{ 2 | ActionChange, ActionKind, Board, BoardHistory, Color, GameState, MakeActionError, 3 | MakeActionResult, Seat, SharedState, VisibilityBoard, 4 | }; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | use itertools::izip; 8 | 9 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 10 | pub struct FreePlacement { 11 | // One board per visibility group (= team or player) 12 | pub boards: Vec, 13 | pub stones_placed: Vec, 14 | pub players_ready: Vec, 15 | pub teams_share_stones: bool, 16 | } 17 | 18 | impl FreePlacement { 19 | pub fn new( 20 | seat_count: usize, 21 | team_count: usize, 22 | board: Board, 23 | teams_share_stones: bool, 24 | ) -> Self { 25 | let count = if teams_share_stones { 26 | team_count 27 | } else { 28 | seat_count 29 | }; 30 | FreePlacement { 31 | boards: vec![board; count], 32 | stones_placed: vec![0; count], 33 | players_ready: vec![false; seat_count], 34 | teams_share_stones, 35 | } 36 | } 37 | 38 | fn make_action_place( 39 | &mut self, 40 | shared: &mut SharedState, 41 | player_id: u64, 42 | (x, y): (u32, u32), 43 | ) -> MakeActionResult { 44 | let (seat_idx, active_seat) = get_seat(&shared.seats, player_id); 45 | let team = active_seat.team; 46 | 47 | let board = if self.teams_share_stones { 48 | &mut self.boards[team.0 as usize - 1] 49 | } else { 50 | &mut self.boards[seat_idx] 51 | }; 52 | let stones_placed = if self.teams_share_stones { 53 | &mut self.stones_placed[team.0 as usize - 1] 54 | } else { 55 | &mut self.stones_placed[seat_idx] 56 | }; 57 | 58 | if *stones_placed >= shared.mods.hidden_move.as_ref().unwrap().placement_count { 59 | return Err(MakeActionError::PointOccupied); 60 | } 61 | 62 | if shared.mods.pixel { 63 | // In pixel mode coordinate 0,0 is outside the board. 64 | // This is to adjust for it. 65 | 66 | if x > board.width || y > board.height { 67 | return Err(MakeActionError::OutOfBounds); 68 | } 69 | let x = x as i32 - 1; 70 | let y = y as i32 - 1; 71 | 72 | let mut any_placed = false; 73 | for &(x, y) in &[(x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1)] { 74 | let coord = match shared.board.wrap_point(x, y) { 75 | Some(x) => x, 76 | None => continue, 77 | }; 78 | 79 | let point = board.point_mut(coord); 80 | if !point.is_empty() { 81 | continue; 82 | } 83 | *point = active_seat.team; 84 | any_placed = true; 85 | } 86 | if !any_placed { 87 | return Err(MakeActionError::PointOccupied); 88 | } 89 | } else { 90 | if !board.point_within((x, y)) { 91 | return Err(MakeActionError::OutOfBounds); 92 | } 93 | 94 | // TODO: don't repeat yourself 95 | let point = board.point_mut((x, y)); 96 | if !point.is_empty() { 97 | return Err(MakeActionError::PointOccupied); 98 | } 99 | 100 | *point = active_seat.team; 101 | } 102 | 103 | *stones_placed += 1; 104 | 105 | Ok(ActionChange::None) 106 | } 107 | 108 | pub fn make_action_pass( 109 | &mut self, 110 | shared: &mut SharedState, 111 | player_id: u64, 112 | ) -> MakeActionResult { 113 | let (seat_idx, _active_seat) = get_seat(&shared.seats, player_id); 114 | self.players_ready[seat_idx] = true; 115 | 116 | if self.players_ready.iter().all(|x| *x) { 117 | let (board, visibility) = self.build_board(shared.board.clone()); 118 | 119 | shared.board = board; 120 | shared.board_visibility = Some(visibility); 121 | 122 | let state = GameState::play(shared.seats.len()); 123 | 124 | shared.board_history = vec![BoardHistory { 125 | hash: shared.board.hash(), 126 | board: shared.board.clone(), 127 | board_visibility: shared.board_visibility.clone(), 128 | state: state.clone(), 129 | points: shared.points.clone(), 130 | turn: 0, 131 | traitor: shared.traitor.clone(), 132 | }]; 133 | 134 | return Ok(ActionChange::SwapState(state)); 135 | } 136 | 137 | Ok(ActionChange::None) 138 | } 139 | 140 | fn build_board(&self, mut board: Board) -> (Board, VisibilityBoard) { 141 | let mut visibility = VisibilityBoard::empty(board.width, board.height, board.toroidal); 142 | 143 | for view_board in &self.boards { 144 | for (a, b, v) in izip!( 145 | &mut board.points, 146 | &view_board.points, 147 | &mut visibility.points 148 | ) { 149 | if *b == Color::empty() { 150 | continue; 151 | } 152 | 153 | v.set(b.as_usize(), true); 154 | 155 | // Double-committed points become empty! 156 | if v.len() == 1 { 157 | *a = *b; 158 | } else { 159 | *a = Color::empty(); 160 | } 161 | } 162 | } 163 | 164 | (board, visibility) 165 | } 166 | 167 | pub fn make_action_cancel( 168 | &mut self, 169 | shared: &mut SharedState, 170 | player_id: u64, 171 | ) -> MakeActionResult { 172 | let (seat_idx, active_seat) = get_seat(&shared.seats, player_id); 173 | let team = active_seat.team; 174 | 175 | let board = if self.teams_share_stones { 176 | &mut self.boards[team.0 as usize - 1] 177 | } else { 178 | &mut self.boards[seat_idx] 179 | }; 180 | let stones_placed = if self.teams_share_stones { 181 | &mut self.stones_placed[team.0 as usize - 1] 182 | } else { 183 | &mut self.stones_placed[seat_idx] 184 | }; 185 | 186 | self.players_ready[seat_idx] = false; 187 | *board = shared.board.clone(); 188 | *stones_placed = 0; 189 | 190 | Ok(ActionChange::None) 191 | } 192 | 193 | pub fn make_action( 194 | &mut self, 195 | shared: &mut SharedState, 196 | player_id: u64, 197 | action: ActionKind, 198 | ) -> MakeActionResult { 199 | match action { 200 | ActionKind::Place(x, y) => self.make_action_place(shared, player_id, (x, y)), 201 | ActionKind::Pass => self.make_action_pass(shared, player_id), 202 | ActionKind::Cancel => self.make_action_cancel(shared, player_id), 203 | ActionKind::Resign => { 204 | // We don't allow resigning in free placement 205 | Ok(ActionChange::None) 206 | } 207 | } 208 | } 209 | } 210 | 211 | fn get_seat(seats: &[Seat], player_id: u64) -> (usize, &Seat) { 212 | // In free placement it is assumed a player can only hold a single seat. 213 | seats 214 | .iter() 215 | .enumerate() 216 | .find(|(_, x)| x.player == Some(player_id)) 217 | .expect("User has no seat") 218 | } 219 | -------------------------------------------------------------------------------- /LICENSE_APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /client/src/state.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, rc::Rc}; 2 | 3 | use crate::networking::use_websocket_provider; 4 | use dioxus::prelude::*; 5 | use dioxus_signals::{ReadOnlySignal, Signal}; 6 | use futures::StreamExt; 7 | use gloo_storage::Storage as _; 8 | use gloo_timers::future::TimeoutFuture; 9 | use shared::{ 10 | game, 11 | message::{self, ClientMessage, Profile, ServerMessage}, 12 | }; 13 | 14 | #[derive(Clone, Copy)] 15 | pub(crate) struct ClientState { 16 | pub(crate) user: Signal, 17 | pub(crate) profiles: Signal>, 18 | pub(crate) rooms: Signal>, 19 | active_room: Signal>, 20 | } 21 | 22 | impl ClientState { 23 | fn new() -> Self { 24 | Self { 25 | user: Signal::new(Profile::default()), 26 | profiles: Signal::new(HashMap::new()), 27 | rooms: Signal::new(Vec::new()), 28 | active_room: Signal::new(None), 29 | } 30 | } 31 | 32 | pub(crate) fn active_room(&self) -> ReadOnlySignal> { 33 | ReadOnlySignal::new(self.active_room) 34 | } 35 | } 36 | 37 | #[derive(Clone, Debug)] 38 | pub(crate) struct GameRoom { 39 | pub(crate) id: u32, 40 | pub(crate) name: Rc, 41 | } 42 | 43 | #[derive(Clone, Debug)] 44 | pub(crate) struct ActiveRoom { 45 | pub(crate) id: u32, 46 | pub(crate) owner: u64, 47 | pub(crate) members: Vec, 48 | pub(crate) view: Rc, 49 | } 50 | 51 | #[derive(Debug, Clone, PartialEq)] 52 | pub(crate) struct GameView { 53 | pub(crate) state: game::GameStateView, 54 | pub(crate) seats: Vec, 55 | pub(crate) turn: u32, 56 | pub(crate) board: Vec, 57 | pub(crate) board_visibility: Option>, 58 | pub(crate) hidden_stones_left: u32, 59 | pub(crate) size: (u8, u8), 60 | pub(crate) mods: game::GameModifier, 61 | pub(crate) points: Vec, 62 | pub(crate) move_number: u32, 63 | pub(crate) clock: Option, 64 | } 65 | 66 | #[derive(Debug, Clone, PartialEq)] 67 | pub(crate) struct GameHistory { 68 | pub(crate) board: Vec, 69 | pub(crate) board_visibility: Option>, 70 | pub(crate) last_stone: Option>, 71 | pub(crate) move_number: u32, 72 | } 73 | 74 | fn on_connect() -> Vec { 75 | vec![ 76 | ClientMessage::Identify { 77 | token: get_token(), 78 | nick: None, 79 | }, 80 | ClientMessage::GetGameList, 81 | ] 82 | } 83 | 84 | enum RoomEvent { 85 | Announce(GameRoom), 86 | Close(u32), 87 | } 88 | 89 | fn use_debouncer( 90 | cx: &ScopeState, 91 | debounce_time_ms: u32, 92 | mut callback: impl FnMut(Vec) + 'static, 93 | ) -> impl Fn(T) { 94 | let (sender, rx) = cx.use_hook(|| { 95 | let (sender, rx) = futures::channel::mpsc::unbounded(); 96 | (sender, Some(rx)) 97 | }); 98 | use_on_create(cx, move || { 99 | let mut rx = rx.take().unwrap(); 100 | async move { 101 | loop { 102 | let mut queue = vec![]; 103 | queue.push(rx.next().await.unwrap()); 104 | let mut timed = (&mut rx).take_until(TimeoutFuture::new(debounce_time_ms)); 105 | while let Some(value) = timed.next().await { 106 | queue.push(value); 107 | } 108 | callback(queue); 109 | } 110 | } 111 | }); 112 | let sender = sender.clone(); 113 | move |value| sender.unbounded_send(value).expect("channel broken") 114 | } 115 | 116 | pub(crate) fn use_state_provider(cx: &ScopeState) -> Signal { 117 | let state = *use_context_provider(cx, || Signal::new(ClientState::new())); 118 | 119 | let room_debouncer = use_debouncer(cx, 100, move |events| { 120 | let rooms = state.read().rooms; 121 | let mut rooms = rooms.write(); 122 | for event in events { 123 | apply_room_event(event, &mut *rooms); 124 | } 125 | }); 126 | 127 | let on_message = move |msg| { 128 | if !matches!(msg, ServerMessage::ServerTime(_)) { 129 | log::debug!("Received: {:?}", msg); 130 | } 131 | let state = state.read(); 132 | match msg { 133 | ServerMessage::Identify { 134 | token, 135 | nick, 136 | user_id, 137 | } => { 138 | set_token(&token); 139 | state.user.set(Profile { user_id, nick }); 140 | } 141 | ServerMessage::Profile(profile) => { 142 | state.profiles.write().insert(profile.user_id, profile); 143 | } 144 | ServerMessage::AnnounceGame { room_id, name } => { 145 | let new_room = GameRoom { 146 | id: room_id, 147 | name: name.into(), 148 | }; 149 | room_debouncer(RoomEvent::Announce(new_room)); 150 | } 151 | ServerMessage::CloseGame { room_id } => { 152 | room_debouncer(RoomEvent::Close(room_id)); 153 | } 154 | ServerMessage::GameStatus { 155 | room_id, 156 | owner, 157 | members, 158 | seats, 159 | turn, 160 | board, 161 | board_visibility, 162 | hidden_stones_left, 163 | size, 164 | state: game_state, 165 | mods, 166 | points, 167 | move_number, 168 | clock, 169 | } => { 170 | let view = GameView { 171 | state: game_state, 172 | seats: seats 173 | .into_iter() 174 | .map(|(player, team, resigned)| game::Seat { 175 | player, 176 | team: game::Color(team), 177 | resigned, 178 | }) 179 | .collect(), 180 | turn, 181 | board: board.into_iter().map(game::Color).collect(), 182 | board_visibility, 183 | hidden_stones_left, 184 | size, 185 | mods, 186 | points, 187 | move_number, 188 | clock, 189 | }; 190 | let room = ActiveRoom { 191 | id: room_id, 192 | view: Rc::new(view), 193 | owner, 194 | members, 195 | }; 196 | *state.active_room.write() = Some(room); 197 | log::debug!("{:?}", &*state.active_room.read()); 198 | } 199 | _ => {} 200 | } 201 | }; 202 | let _ = use_websocket_provider(cx, on_connect, on_message); 203 | state 204 | } 205 | 206 | pub(crate) fn use_state(cx: &ScopeState) -> Signal { 207 | *use_context(cx).expect("state not provided") 208 | } 209 | 210 | fn apply_room_event(event: RoomEvent, rooms: &mut Vec) { 211 | match event { 212 | RoomEvent::Announce(room) => match rooms.binary_search_by(|r| room.id.cmp(&r.id)) { 213 | Ok(idx) => { 214 | log::warn!( 215 | "Received a game we already knew about ({}, {:?})", 216 | room.id, 217 | room.name 218 | ); 219 | rooms[idx] = room; 220 | } 221 | Err(idx) => rooms.insert(idx, room), 222 | }, 223 | RoomEvent::Close(id) => match rooms.binary_search_by(|r| id.cmp(&r.id)) { 224 | Ok(idx) => { 225 | rooms.remove(idx); 226 | } 227 | Err(_) => { 228 | log::warn!("CloseGame on an unknown room ({})", id); 229 | } 230 | }, 231 | } 232 | } 233 | 234 | fn get_token() -> Option { 235 | gloo_storage::LocalStorage::get("token").ok() 236 | } 237 | 238 | fn set_token(token: &str) { 239 | gloo_storage::LocalStorage::set("token", token).unwrap(); 240 | } 241 | 242 | #[derive(Clone, Copy)] 243 | pub(crate) struct ActionSender<'a> { 244 | state: Signal, 245 | handle: &'a Coroutine, 246 | } 247 | 248 | impl<'a> ActionSender<'a> { 249 | pub(crate) fn new(cx: &'a ScopeState) -> Self { 250 | let state = use_state(cx); 251 | let handle = 252 | use_coroutine_handle(cx).expect("use_websocket called outside of websocket provider"); 253 | Self { state, handle } 254 | } 255 | 256 | fn send(&self, msg: ClientMessage) { 257 | self.handle.send(msg); 258 | } 259 | 260 | pub(crate) fn set_nick(&self, nick: &str) { 261 | self.send(ClientMessage::Identify { 262 | token: get_token(), 263 | nick: Some(nick.to_owned()), 264 | }); 265 | } 266 | 267 | pub(crate) fn join_room(&self, id: u32) { 268 | self.send(ClientMessage::JoinGame(id)); 269 | } 270 | 271 | pub(crate) fn leave_all_rooms(&self) { 272 | let active_room = self.state.read().active_room; 273 | *active_room.write() = None; 274 | self.send(ClientMessage::LeaveGame(None)) 275 | } 276 | 277 | pub(crate) fn take_seat(&self, id: u32) { 278 | self.send(ClientMessage::GameAction { 279 | room_id: None, 280 | action: shared::message::GameAction::TakeSeat(id), 281 | }) 282 | } 283 | 284 | pub(crate) fn leave_seat(&self, id: u32) { 285 | self.send(ClientMessage::GameAction { 286 | room_id: None, 287 | action: shared::message::GameAction::LeaveSeat(id), 288 | }) 289 | } 290 | 291 | pub(crate) fn start_game(&self, start: message::StartGame) { 292 | let msg = ClientMessage::StartGame(start); 293 | self.send(msg); 294 | } 295 | 296 | pub(crate) fn place_stone(&self, x: u32, y: u32) { 297 | self.send(ClientMessage::GameAction { 298 | room_id: None, 299 | action: shared::message::GameAction::Place(x, y), 300 | }) 301 | } 302 | 303 | pub(crate) fn undo(&self) { 304 | self.send(ClientMessage::GameAction { 305 | room_id: None, 306 | action: shared::message::GameAction::Cancel, 307 | }) 308 | } 309 | 310 | pub(crate) fn pass(&self) { 311 | self.send(ClientMessage::GameAction { 312 | room_id: None, 313 | action: shared::message::GameAction::Pass, 314 | }) 315 | } 316 | 317 | pub(crate) fn resign(&self) { 318 | self.send(ClientMessage::GameAction { 319 | room_id: None, 320 | action: shared::message::GameAction::Resign, 321 | }) 322 | } 323 | } 324 | 325 | pub(crate) fn username(profile: &Profile) -> String { 326 | profile 327 | .nick 328 | .as_ref() 329 | .map_or_else(|| "Unknown".to_string(), |n| n.clone()) 330 | } 331 | -------------------------------------------------------------------------------- /repl/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::sync::Arc; 3 | use std::sync::Mutex; 4 | 5 | use futures_util::{future, pin_mut, StreamExt}; 6 | use tokio_tungstenite::{connect_async, tungstenite::protocol::Message}; 7 | 8 | use shared::message::{AdminAction, ClientMessage, ServerMessage}; 9 | 10 | #[derive(Default)] 11 | struct RoomInfo { 12 | room_id: u32, 13 | name: String, 14 | players: Vec, 15 | member_count: usize, 16 | move_count: usize, 17 | } 18 | 19 | #[derive(Default)] 20 | struct State { 21 | rooms: HashMap, 22 | profiles: HashMap>, 23 | } 24 | 25 | fn pack(msg: ClientMessage) -> Vec { 26 | serde_cbor::to_vec(&msg).unwrap() 27 | } 28 | 29 | #[tokio::main(flavor = "current_thread")] 30 | async fn main() { 31 | dotenv::dotenv().ok(); 32 | 33 | let url = url::Url::parse("ws://localhost:8088/ws/").unwrap(); 34 | 35 | let state = Arc::new(Mutex::new(State::default())); 36 | 37 | let (ws_tx, ws_rx) = futures_channel::mpsc::unbounded(); 38 | 39 | tokio::spawn(read_stdin(state.clone(), ws_tx)); 40 | 41 | let (ws_stream, _) = connect_async(url).await.expect("Failed to connect"); 42 | println!("WebSocket handshake has been successfully completed"); 43 | 44 | let (write, read) = ws_stream.split(); 45 | 46 | let stdin_to_ws = ws_rx.map(Ok).forward(write); 47 | let ws_to_stdout = { 48 | let state = state.clone(); 49 | read.for_each(move |message| { 50 | if let Ok(Message::Binary(data)) = message { 51 | let msg = serde_cbor::from_slice::(&data).unwrap(); 52 | #[allow(clippy::single_match)] 53 | match msg { 54 | ServerMessage::GameStatus { 55 | room_id, 56 | move_number, 57 | members, 58 | seats, 59 | .. 60 | } => { 61 | let mut state = state.lock().unwrap(); 62 | let room = state.rooms.entry(room_id).or_insert_with(RoomInfo::default); 63 | room.room_id = room_id; 64 | room.move_count = move_number as usize; 65 | room.member_count = members.len() - 1; // Subtract self 66 | 67 | room.players = seats.into_iter().filter_map(|x| x.0).collect(); 68 | room.players.sort_unstable(); 69 | room.players.dedup(); 70 | 71 | println!( 72 | "Visited room {}: {} players, {} moves", 73 | room_id, room.member_count, room.move_count 74 | ); 75 | } 76 | ServerMessage::AnnounceGame { room_id, name } => { 77 | let mut state = state.lock().unwrap(); 78 | let room = state.rooms.entry(room_id).or_insert_with(RoomInfo::default); 79 | room.room_id = room_id; 80 | room.name = name.clone(); 81 | println!("New room {}: {:?}", room_id, name); 82 | } 83 | ServerMessage::CloseGame { room_id } => { 84 | let mut state = state.lock().unwrap(); 85 | if state.rooms.remove(&room_id).is_some() { 86 | println!("Closed room {}", room_id); 87 | } 88 | } 89 | ServerMessage::Profile(profile) => { 90 | let mut state = state.lock().unwrap(); 91 | state.profiles.insert(profile.user_id, profile.nick); 92 | } 93 | ServerMessage::Error(e) => { 94 | println!("{:?}", e); 95 | } 96 | _ => {} 97 | } 98 | } 99 | async {} 100 | }) 101 | }; 102 | 103 | pin_mut!(stdin_to_ws, ws_to_stdout); 104 | future::select(stdin_to_ws, ws_to_stdout).await; 105 | } 106 | 107 | // Our helper method which will read data from stdin and send it along the 108 | // sender provided. 109 | async fn read_stdin(state: Arc>, tx: futures_channel::mpsc::UnboundedSender) { 110 | use tokio::io::{self, AsyncBufReadExt, BufReader}; 111 | 112 | let token = std::env::var("ADMIN_TOKEN").unwrap(); 113 | tx.unbounded_send(Message::binary(pack(ClientMessage::Identify { 114 | token: Some(token.clone()), 115 | nick: None, 116 | }))) 117 | .unwrap(); 118 | 119 | let mut selection = Vec::::new(); 120 | 121 | let mut reader = BufReader::new(io::stdin()); 122 | loop { 123 | let mut text = String::new(); 124 | match reader.read_line(&mut text).await { 125 | Err(_) | Ok(0) => break, 126 | Ok(n) => n, 127 | }; 128 | 129 | let text = text.trim(); 130 | 131 | let mut words = text.split(' '); 132 | let command = words.next().unwrap(); 133 | 134 | let state = state.lock().unwrap(); 135 | 136 | let msgs = match command { 137 | "login" => vec![ClientMessage::Identify { 138 | token: Some(token.clone()), 139 | nick: None, 140 | }], 141 | "unload" | "ul" => match words.next() { 142 | Some("between") | Some("b") => { 143 | let start: u32 = words.next().and_then(|x| x.parse().ok()).unwrap_or(0); 144 | let end: u32 = words.next().and_then(|x| x.parse().ok()).unwrap_or(0); 145 | (start..=end) 146 | .map(|id| ClientMessage::Admin(AdminAction::UnloadRoom(id))) 147 | .collect() 148 | } 149 | Some(x) if x.parse::().is_ok() => text 150 | .split(' ') 151 | .skip(1) 152 | .filter_map(|x| x.parse::().ok()) 153 | .map(|id| ClientMessage::Admin(AdminAction::UnloadRoom(id))) 154 | .collect(), 155 | _ => vec![], 156 | }, 157 | "load" | "l" => match words.next() { 158 | Some("between") | Some("b") => { 159 | let start: u32 = words.next().and_then(|x| x.parse().ok()).unwrap_or(0); 160 | let end: u32 = words.next().and_then(|x| x.parse().ok()).unwrap_or(0); 161 | (start..=end).map(ClientMessage::JoinGame).collect() 162 | } 163 | Some(x) if x.parse::().is_ok() => text 164 | .split(' ') 165 | .skip(1) 166 | .filter_map(|x| x.parse::().ok()) 167 | .map(ClientMessage::JoinGame) 168 | .collect(), 169 | _ => vec![], 170 | }, 171 | "list" | "li" => vec![ClientMessage::GetGameList], 172 | "visit" | "v" => state 173 | .rooms 174 | .values() 175 | .map(|x| ClientMessage::JoinGame(x.room_id)) 176 | .collect(), 177 | "prune" | "p" => selection 178 | .drain(..) 179 | .map(|id| ClientMessage::Admin(AdminAction::UnloadRoom(id))) 180 | .collect(), 181 | "select" | "s" => { 182 | let changed = match words.next() { 183 | Some("all") | Some("a") => { 184 | selection = state.rooms.keys().copied().collect(); 185 | true 186 | } 187 | Some("move") | Some("m") => match words.next() { 188 | Some("below") | Some("b") => { 189 | let limit: usize = 190 | words.next().and_then(|x| x.parse().ok()).unwrap_or(0); 191 | selection.retain(|id| state.rooms.get(id).unwrap().move_count < limit); 192 | true 193 | } 194 | Some("above") | Some("a") => { 195 | let limit: usize = 196 | words.next().and_then(|x| x.parse().ok()).unwrap_or(10000); 197 | selection.retain(|id| state.rooms.get(id).unwrap().move_count > limit); 198 | true 199 | } 200 | _ => false, 201 | }, 202 | Some("name") | Some("n") => { 203 | let needle = words.collect::>().join(" ").to_lowercase(); 204 | selection.retain(|id| { 205 | state 206 | .rooms 207 | .get(id) 208 | .unwrap() 209 | .name 210 | .to_lowercase() 211 | .contains(&needle) 212 | }); 213 | true 214 | } 215 | Some("player") | Some("p") => match words.next() { 216 | Some("name") | Some("n") => { 217 | let needle = words.collect::>().join(" ").to_lowercase(); 218 | selection.retain(|id| { 219 | state 220 | .rooms 221 | .get(id) 222 | .unwrap() 223 | .players 224 | .iter() 225 | .flat_map(|id| state.profiles.get(id)) 226 | .flatten() 227 | .any(|n| n.to_lowercase().contains(&needle)) 228 | }); 229 | true 230 | } 231 | Some("count") | Some("c") => { 232 | let limit: usize = 233 | words.next().and_then(|x| x.parse().ok()).unwrap_or(0); 234 | selection 235 | .retain(|id| state.rooms.get(id).unwrap().players.len() <= limit); 236 | true 237 | } 238 | _ => false, 239 | }, 240 | Some("empty") | Some("e") => { 241 | selection.retain(|id| state.rooms.get(id).unwrap().member_count == 0); 242 | true 243 | } 244 | Some("list") | Some("l") => true, 245 | _ => false, 246 | }; 247 | if changed { 248 | list_selection(&selection, &state); 249 | } 250 | vec![] 251 | } 252 | "quit" | "q" => std::process::exit(0), 253 | _ => vec![], 254 | }; 255 | 256 | for msg in msgs { 257 | tx.unbounded_send(Message::binary(pack(msg))).unwrap(); 258 | } 259 | } 260 | } 261 | 262 | fn list_selection(selection: &[u32], state: &State) { 263 | for id in selection { 264 | let room = state.rooms.get(id).unwrap(); 265 | let names = room 266 | .players 267 | .iter() 268 | .filter_map(|id| state.profiles.get(id)) 269 | .flatten() 270 | .collect::>(); 271 | println!("{:>4}: {:?} {:?}", id, room.name, names); 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /server/src/game_room.rs: -------------------------------------------------------------------------------- 1 | use actix::prelude::*; 2 | use std::collections::{HashMap, HashSet}; 3 | use std::time::Instant; 4 | 5 | use crate::{db, server}; 6 | use shared::game; 7 | use shared::game::clock::Millisecond; 8 | use shared::message; 9 | 10 | // TODO: add room timeout 11 | 12 | /////////////////////////////////////////////////////////////////////////////// 13 | // Actor messages // 14 | /////////////////////////////////////////////////////////////////////////////// 15 | 16 | // Output ///////////////////////////////////////////////////////////////////// 17 | 18 | #[derive(Message, Clone)] 19 | #[rtype(result = "()")] 20 | pub enum Message { 21 | // TODO: Use a proper struct, not magic tuples 22 | GameStatus { 23 | room_id: u32, 24 | owner: u64, 25 | members: Vec, 26 | view: game::GameView, 27 | }, 28 | BoardAt { 29 | room_id: u32, 30 | view: game::GameHistory, 31 | }, 32 | SGF { 33 | room_id: u32, 34 | sgf: String, 35 | }, 36 | } 37 | 38 | // Actions //////////////////////////////////////////////////////////////////// 39 | 40 | pub struct GameAction { 41 | pub id: usize, 42 | pub action: message::GameAction, 43 | } 44 | 45 | impl actix::Message for GameAction { 46 | type Result = Result<(), message::Error>; 47 | } 48 | 49 | pub struct GameActionAsUser { 50 | pub user_id: u64, 51 | pub action: message::GameAction, 52 | } 53 | 54 | impl actix::Message for GameActionAsUser { 55 | type Result = Result<(), message::Error>; 56 | } 57 | 58 | // User lifecycle ///////////////////////////////////////////////////////////// 59 | 60 | #[derive(Message)] 61 | #[rtype(result = "()")] 62 | pub struct Leave { 63 | pub session_id: usize, 64 | } 65 | 66 | #[derive(Message)] 67 | #[rtype(result = "()")] 68 | pub struct Join { 69 | pub session_id: usize, 70 | pub user_id: u64, 71 | pub addr: Recipient, 72 | } 73 | 74 | // Control //////////////////////////////////////////////////////////////////// 75 | 76 | #[derive(Message)] 77 | #[rtype(result = "()")] 78 | pub struct Unload; 79 | 80 | pub struct GetAdminView; 81 | 82 | impl actix::Message for GetAdminView { 83 | type Result = Result; 84 | } 85 | 86 | /////////////////////////////////////////////////////////////////////////////// 87 | // Actor // 88 | /////////////////////////////////////////////////////////////////////////////// 89 | 90 | pub struct GameRoom { 91 | pub room_id: u32, 92 | pub owner: Option, 93 | pub sessions: HashMap)>, 94 | pub users: HashSet, 95 | pub name: String, 96 | pub last_action: Instant, 97 | pub game: game::Game, 98 | pub db: Addr, 99 | pub server: Addr, 100 | 101 | /// Kicked players are not visible to other users in the game and can not 102 | /// hold seats. They can still follow the game. 103 | pub kicked_players: HashSet, 104 | } 105 | 106 | impl GameRoom { 107 | fn send_room_messages(&self, mut create_msg: impl FnMut(u64) -> Message) { 108 | for (user_id, addr) in self.sessions.values() { 109 | let _ = addr.do_send(create_msg(*user_id)); 110 | } 111 | } 112 | 113 | fn view_for_user(&self, user_id: u64) -> Message { 114 | Message::GameStatus { 115 | room_id: self.room_id, 116 | owner: self.owner.unwrap_or(0), 117 | members: self 118 | .users 119 | .iter() 120 | .copied() 121 | .filter(|id| !self.kicked_players.contains(id)) 122 | .collect(), 123 | view: self.game.get_view(user_id), 124 | } 125 | } 126 | 127 | fn make_action( 128 | &mut self, 129 | user_id: u64, 130 | action: message::GameAction, 131 | addr: Option>, 132 | ) -> Result<(), message::Error> { 133 | use message::Error; 134 | 135 | let current_time = Millisecond( 136 | std::time::SystemTime::now() 137 | .duration_since(std::time::UNIX_EPOCH) 138 | .unwrap() 139 | .as_millis() as i128, 140 | ); 141 | 142 | self.last_action = Instant::now(); 143 | let res = match action { 144 | message::GameAction::Place(x, y) => self 145 | .game 146 | .make_action(user_id, game::ActionKind::Place(x, y), current_time) 147 | .map_err(Into::into), 148 | message::GameAction::Pass => self 149 | .game 150 | .make_action(user_id, game::ActionKind::Pass, current_time) 151 | .map_err(Into::into), 152 | message::GameAction::Cancel => self 153 | .game 154 | .make_action(user_id, game::ActionKind::Cancel, current_time) 155 | .map_err(Into::into), 156 | message::GameAction::Resign => self 157 | .game 158 | .make_action(user_id, game::ActionKind::Resign, current_time) 159 | .map_err(Into::into), 160 | message::GameAction::TakeSeat(seat_id) => { 161 | if self.kicked_players.contains(&user_id) { 162 | return Err(Error::other("Kicked from game")); 163 | } 164 | self.game 165 | .take_seat(user_id, seat_id as _) 166 | .map_err(Into::into) 167 | } 168 | message::GameAction::LeaveSeat(seat_id) => self 169 | .game 170 | .leave_seat(user_id, seat_id as _) 171 | .map_err(Into::into), 172 | message::GameAction::BoardAt(start, end) => { 173 | let addr = addr.expect("Address needed to get board position"); 174 | if start > end { 175 | return Ok(()); 176 | } 177 | // Prevent asking for a ridiculous amount. 178 | if end as usize > self.game.shared.board_history.len() + 20 { 179 | return Ok(()); 180 | } 181 | for turn in (start..=end).rev() { 182 | let view = self.game.get_view_at(user_id, turn); 183 | if let Some(view) = view { 184 | let _ = addr.do_send(Message::BoardAt { 185 | room_id: self.room_id, 186 | view, 187 | }); 188 | } 189 | } 190 | return Ok(()); 191 | } 192 | message::GameAction::RequestSGF => { 193 | let addr = addr.expect("Address needed to get SGF"); 194 | let game_done = matches!(self.game.state, game::GameState::Done(_)); 195 | if !game_done { 196 | return Err(Error::other("Game not finished")); 197 | } 198 | let sgf = game::export::sgf_export(&self.game); 199 | let _ = addr.do_send(Message::SGF { 200 | room_id: self.room_id, 201 | sgf, 202 | }); 203 | return Ok(()); 204 | } 205 | message::GameAction::KickPlayer(kick_player_id) => { 206 | if self.owner != Some(user_id) { 207 | return Err(Error::other("Not room owner")); 208 | } 209 | 210 | for (idx, seat) in self.game.shared.seats.clone().into_iter().enumerate() { 211 | if seat.player == Some(kick_player_id) { 212 | let _ = self.game.leave_seat(kick_player_id, idx); 213 | } 214 | } 215 | if self.users.contains(&kick_player_id) { 216 | self.kicked_players.insert(kick_player_id); 217 | } 218 | Ok(()) 219 | } 220 | }; 221 | 222 | if let Err(err) = res { 223 | return Err(Error::Game { 224 | room_id: self.room_id, 225 | error: err, 226 | }); 227 | } 228 | 229 | self.db.do_send(db::StoreGame { 230 | id: Some(self.room_id as _), 231 | name: self.name.clone(), 232 | replay: Some(self.game.dump()), 233 | owner: self.owner, 234 | }); 235 | 236 | self.send_room_messages(|user_id| self.view_for_user(user_id)); 237 | 238 | Ok(()) 239 | } 240 | } 241 | 242 | impl Actor for GameRoom { 243 | type Context = Context; 244 | 245 | fn stopping(&mut self, _ctx: &mut Self::Context) -> Running { 246 | println!("Room {} stopping!", self.room_id); 247 | 248 | Running::Stop 249 | } 250 | } 251 | 252 | impl Handler for GameRoom { 253 | type Result = (); 254 | 255 | fn handle(&mut self, msg: Leave, _ctx: &mut Self::Context) -> Self::Result { 256 | let Leave { session_id } = msg; 257 | 258 | if let Some((user_id, _addr)) = self.sessions.remove(&session_id) { 259 | let sessions = &self.sessions; 260 | if !sessions.values().any(|(uid, _addr)| *uid == user_id) { 261 | self.users.remove(&user_id); 262 | self.send_room_messages(|user_id| self.view_for_user(user_id)); 263 | } 264 | } 265 | } 266 | } 267 | 268 | impl Handler for GameRoom { 269 | type Result = (); 270 | 271 | fn handle(&mut self, msg: Join, _ctx: &mut Self::Context) -> Self::Result { 272 | let Join { 273 | session_id, 274 | user_id, 275 | addr, 276 | } = msg; 277 | 278 | self.sessions.insert(session_id, (user_id, addr)); 279 | self.users.insert(user_id); 280 | self.send_room_messages(|user_id| self.view_for_user(user_id)); 281 | 282 | // TODO: Announce profile to room members 283 | 284 | // Broadcast the profile of each seatholder 285 | // .. this is not great 286 | for seat in &self.game.shared.seats { 287 | if let Some(user_id) = seat.player { 288 | self.server.do_send(server::QueryProfile { user_id }); 289 | } 290 | } 291 | } 292 | } 293 | 294 | impl Handler for GameRoom { 295 | type Result = MessageResult; 296 | 297 | fn handle(&mut self, msg: GameAction, _: &mut Context) -> MessageResult { 298 | use message::Error; 299 | 300 | let GameAction { id, action } = msg; 301 | 302 | // TODO: PUZZLE Add background timer for clock 303 | 304 | let &(user_id, ref addr) = match self.sessions.get(&id) { 305 | Some(x) => x, 306 | None => return MessageResult(Err(Error::other("No session"))), 307 | }; 308 | let addr = addr.clone(); 309 | 310 | MessageResult(self.make_action(user_id, action, Some(addr))) 311 | } 312 | } 313 | 314 | impl Handler for GameRoom { 315 | type Result = MessageResult; 316 | 317 | fn handle( 318 | &mut self, 319 | msg: GameActionAsUser, 320 | _: &mut Context, 321 | ) -> MessageResult { 322 | let GameActionAsUser { user_id, action } = msg; 323 | 324 | MessageResult(self.make_action(user_id, action, None)) 325 | } 326 | } 327 | 328 | impl Handler for GameRoom { 329 | type Result = (); 330 | 331 | fn handle(&mut self, _: Unload, ctx: &mut Self::Context) -> Self::Result { 332 | ctx.stop(); 333 | } 334 | } 335 | 336 | impl Handler for GameRoom { 337 | type Result = ::Result; 338 | 339 | fn handle(&mut self, _: GetAdminView, _ctx: &mut Self::Context) -> Self::Result { 340 | Ok(self.game.get_view(0)) 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /client/src/views/create_game.rs: -------------------------------------------------------------------------------- 1 | use dioxus::prelude::*; 2 | use dioxus_signals::*; 3 | 4 | use crate::{ 5 | state::{self, ActionSender}, 6 | window, 7 | }; 8 | use shared::{game::GameModifier, message}; 9 | 10 | macro_rules! simple_modifier { 11 | ($name:ident, $modifiers:ident => $select:expr, $flip:expr, $text:expr, $tooltip:expr) => { 12 | #[component] 13 | fn $name(cx: Scope, modifiers: Signal) -> Element { 14 | let flip = move || { 15 | let mut $modifiers = modifiers.write(); 16 | $flip; 17 | }; 18 | 19 | cx.render(rsx! { 20 | li { 21 | input { 22 | r#type: "checkbox", 23 | checked: { 24 | let $modifiers = modifiers.read(); 25 | $select 26 | }, 27 | onclick: move |_| flip(), 28 | } 29 | label { 30 | class: "tooltip", 31 | onclick: move |_| flip(), 32 | $text 33 | span { 34 | class: "tooltip-text", 35 | $tooltip 36 | } 37 | } 38 | } 39 | }) 40 | } 41 | }; 42 | } 43 | 44 | #[derive(Clone, Copy, PartialEq, Eq)] 45 | enum Preset { 46 | Standard, 47 | Rengo, 48 | ThreeColor, 49 | FourColor, 50 | ThreeColorRengo, 51 | } 52 | 53 | impl Preset { 54 | fn name(self) -> &'static str { 55 | match self { 56 | Preset::Standard => "Standard", 57 | Preset::Rengo => "Rengo", 58 | Preset::ThreeColor => "Three Color Go", 59 | Preset::FourColor => "Four Color Go", 60 | Preset::ThreeColorRengo => "Three Color Rengo", 61 | } 62 | } 63 | } 64 | 65 | #[component] 66 | pub fn CreateGamePanel(cx: Scope) -> Element { 67 | let mode = window::use_display_mode(cx); 68 | let state = state::use_state(cx); 69 | let game_name = use_signal(cx, || { 70 | format!("{}'s game", state::username(&state.read().user.read())) 71 | }); 72 | let chosen_preset = use_signal(cx, || Preset::Standard); 73 | let modifiers = use_signal(cx, GameModifier::default); 74 | 75 | let start = dioxus_signals::use_selector(cx, move || { 76 | let preset = chosen_preset.read().clone(); 77 | let seats = match preset { 78 | Preset::Standard => vec![1, 2], 79 | Preset::Rengo => vec![1, 2, 1, 2], 80 | Preset::ThreeColor => vec![1, 2, 3], 81 | Preset::FourColor => vec![1, 2, 3, 4], 82 | Preset::ThreeColorRengo => vec![1, 2, 3, 1, 2, 3], 83 | }; 84 | 85 | let komis = match preset { 86 | Preset::Standard => vec![0, 15], 87 | Preset::Rengo => vec![0, 15], 88 | Preset::ThreeColor => vec![0, 0, 0], 89 | Preset::FourColor => vec![0, 0, 0, 0], 90 | Preset::ThreeColorRengo => vec![0, 0, 0], 91 | }; 92 | 93 | message::StartGame { 94 | name: game_name.read().clone(), 95 | seats, 96 | komis, 97 | size: (19, 19), 98 | mods: modifiers.read().clone(), 99 | } 100 | }); 101 | 102 | #[rustfmt::skip] 103 | let class = sir::css!(" 104 | overflow: scroll; 105 | 106 | .sections { 107 | display: grid; 108 | grid-template-columns: 1fr 1fr; 109 | padding: 20px; 110 | } 111 | 112 | &.mobile .sections { 113 | grid-template-columns: 1fr; 114 | } 115 | 116 | input[type='number'] { 117 | width: 50px; 118 | } 119 | "); 120 | cx.render(rsx! { 121 | div { 122 | class: "{class} {mode.class()}", 123 | div { 124 | class: "sections", 125 | div { 126 | NameInput { name: game_name } 127 | PresetSelectors { chosen_preset: chosen_preset } 128 | ModifierSelectors { modifiers: modifiers } 129 | CreateGameButton { start: start } 130 | 131 | // Hack to get mobile usable for now 132 | div { 133 | style: "height: 50px;" 134 | } 135 | } 136 | } 137 | } 138 | }) 139 | } 140 | 141 | #[component] 142 | fn CreateGameButton(cx: Scope, start: ReadOnlySignal) -> Element { 143 | let start = *start; 144 | 145 | let action = ActionSender::new(cx); 146 | 147 | cx.render(rsx! { 148 | div { 149 | button { 150 | onclick: move |_| action.start_game(start.read().clone()), 151 | "Start Game" 152 | } 153 | } 154 | }) 155 | } 156 | 157 | #[component] 158 | fn ModifierSelectors(cx: Scope, modifiers: Signal) -> Element { 159 | let modifiers = *modifiers; 160 | 161 | dioxus_signals::use_effect(cx, move || { 162 | log::info!("{:?}", &*modifiers.read()); 163 | }); 164 | 165 | #[rustfmt::skip] 166 | let class = sir::css!(" 167 | padding: 10px; 168 | li { 169 | padding: 5px; 170 | label { 171 | cursor: pointer; 172 | margin-left: 5px; 173 | } 174 | .adjust { 175 | margin-left: 5px; 176 | } 177 | } 178 | "); 179 | 180 | cx.render(rsx! { 181 | ul { 182 | class: class, 183 | HiddenMoveGo { modifiers: modifiers } 184 | PixelGo { modifiers: modifiers } 185 | ZenGo { modifiers: modifiers } 186 | OneColorGo { modifiers: modifiers } 187 | NoHistory { modifiers: modifiers } 188 | NPlusOne { modifiers: modifiers } 189 | TetrisGo { modifiers: modifiers } 190 | ToroidalGo { modifiers: modifiers } 191 | PhantomGo { modifiers: modifiers } 192 | TraitorGo { modifiers: modifiers } 193 | CapturesGivePoints { modifiers: modifiers } 194 | PonnukiIsPoints { modifiers: modifiers } 195 | Observable { modifiers: modifiers } 196 | NoUndo { modifiers: modifiers } 197 | } 198 | }) 199 | } 200 | 201 | // TODO: Traitor go Traitor stones: 202 | // TODO: Ponnuki is: points (can be negative) 203 | 204 | simple_modifier!( 205 | OneColorGo, 206 | modifiers => modifiers.visibility_mode.is_some(), 207 | modifiers.visibility_mode = match modifiers.visibility_mode { 208 | Some(_) => None, 209 | None => Some(shared::game::VisibilityMode::OneColor), 210 | }, 211 | "One color go", 212 | "Everyone sees the stones as same color. Confusion ensues." 213 | ); 214 | 215 | simple_modifier!( 216 | PixelGo, 217 | modifiers => modifiers.pixel, 218 | modifiers.pixel = !modifiers.pixel, 219 | "Pixel go", 220 | "You place 2x2 blobs. Overlapping stones are ignored." 221 | ); 222 | 223 | // TODO: Ensure zen go receives the correct color count from the preset 224 | simple_modifier!( 225 | ZenGo, 226 | modifiers => modifiers.zen_go.is_some(), 227 | modifiers.zen_go = match modifiers.zen_go { 228 | Some(_) => None, 229 | None => Some(shared::game::ZenGo::default()), 230 | }, 231 | "Zen go", 232 | "One extra player. You get a different color on every turn. There are no winners." 233 | ); 234 | 235 | simple_modifier!( 236 | NoHistory, 237 | modifiers => modifiers.no_history, 238 | modifiers.no_history = !modifiers.no_history, 239 | "No history (good for one color)", 240 | "No one can browse the past moves during the game." 241 | ); 242 | 243 | simple_modifier!( 244 | TetrisGo, 245 | modifiers => modifiers.tetris.is_some(), 246 | modifiers.tetris = match modifiers.tetris { 247 | Some(_) => None, 248 | None => Some(shared::game::TetrisGo {}), 249 | }, 250 | "Tetris go", 251 | "You can't play a group of exactly 4 stones. Diagonals don't form a group." 252 | ); 253 | 254 | simple_modifier!( 255 | ToroidalGo, 256 | modifiers => modifiers.toroidal.is_some(), 257 | modifiers.toroidal = match modifiers.toroidal { 258 | Some(_) => None, 259 | None => Some(shared::game::ToroidalGo {}), 260 | }, 261 | "Toroidal go", 262 | "Opposing edges are connected. First line doesn't exist. Click on the borders, shift click on a point or use WASD or 8462 to move the view. Use < and > or + and - to adjust the extended view." 263 | ); 264 | 265 | simple_modifier!( 266 | PhantomGo, 267 | modifiers => modifiers.phantom.is_some(), 268 | modifiers.phantom = match modifiers.phantom { 269 | Some(_) => None, 270 | None => Some(shared::game::PhantomGo {}), 271 | }, 272 | "Phantom go", 273 | "All stones are invisible when placed. They become visible when they affect the game (like hidden move go). Atari also reveals." 274 | ); 275 | 276 | simple_modifier!( 277 | CapturesGivePoints, 278 | modifiers => modifiers.captures_give_points.is_some(), 279 | modifiers.captures_give_points = match modifiers.captures_give_points { 280 | Some(_) => None, 281 | None => Some(shared::game::CapturesGivePoints {}), 282 | }, 283 | "Captures give points", 284 | "Only the one to remove stones from the board gets the points. Promotes aggressive play. You only get points for removed stones, not dead stones in your territory." 285 | ); 286 | 287 | simple_modifier!( 288 | Observable, 289 | modifiers => modifiers.observable, 290 | modifiers.observable = !modifiers.observable, 291 | "Observable", 292 | "All users who are not holding a seat can see all hidden stones and the true color of stones if one color go is enabled." 293 | ); 294 | 295 | simple_modifier!( 296 | NoUndo, 297 | modifiers => modifiers.no_undo, 298 | modifiers.no_undo = !modifiers.no_undo, 299 | "Undo not allowed", 300 | "Disables undo for all players." 301 | ); 302 | 303 | #[component] 304 | fn HiddenMoveGo(cx: Scope, modifiers: Signal) -> Element { 305 | let modifiers = *modifiers; 306 | let stone_count = use_signal(cx, || 4); 307 | 308 | dioxus_signals::use_effect(cx, move || { 309 | let count = *stone_count.read(); 310 | if let Some(mode) = &mut modifiers.write().hidden_move { 311 | mode.placement_count = count; 312 | } 313 | }); 314 | 315 | let flip_hidden_move = move || { 316 | let mut modifiers = modifiers.write(); 317 | modifiers.hidden_move = match modifiers.hidden_move { 318 | Some(_) => None, 319 | None => Some(shared::game::HiddenMoveGo { 320 | placement_count: *stone_count.read(), 321 | teams_share_stones: true, 322 | }), 323 | }; 324 | }; 325 | 326 | cx.render(rsx! { 327 | li { 328 | input { 329 | r#type: "checkbox", 330 | checked: modifiers.read().hidden_move.is_some(), 331 | onclick: move |_| flip_hidden_move(), 332 | } 333 | label { 334 | class: "tooltip", 335 | onclick: move |_| flip_hidden_move(), 336 | "Hidden move go" 337 | span { 338 | class: "tooltip-text", 339 | " 340 | Each team places stones before the game starts. 341 | The opponents and viewers can't see their stones. 342 | Stones are revealed if they cause a capture or prevent a move from being made. 343 | If two players pick the same point, neither one gets a stone there, but they still see a marker for it." 344 | } 345 | } 346 | span { 347 | class: "adjust", 348 | "Placement stones: " 349 | input { 350 | r#type: "number", 351 | value: "{stone_count}", 352 | onchange: move |e| stone_count.set(e.inner().value.parse().unwrap()) 353 | } 354 | } 355 | } 356 | }) 357 | } 358 | 359 | #[component(no_case_check)] 360 | fn NPlusOne(cx: Scope, modifiers: Signal) -> Element { 361 | let modifiers = *modifiers; 362 | let stone_count = use_signal(cx, || 4); 363 | 364 | dioxus_signals::use_effect(cx, move || { 365 | let count = *stone_count.read(); 366 | if let Some(mode) = &mut modifiers.write().n_plus_one { 367 | mode.length = count; 368 | } 369 | }); 370 | 371 | let flip = move || { 372 | let mut modifiers = modifiers.write(); 373 | modifiers.n_plus_one = match modifiers.n_plus_one { 374 | Some(_) => None, 375 | None => Some(shared::game::NPlusOne { 376 | length: *stone_count.read(), 377 | }), 378 | }; 379 | }; 380 | 381 | cx.render(rsx! { 382 | li { 383 | input { 384 | r#type: "checkbox", 385 | checked: modifiers.read().n_plus_one.is_some(), 386 | onclick: move |_| flip(), 387 | } 388 | label { 389 | class: "tooltip", 390 | onclick: move |_| flip(), 391 | "N+1" 392 | span { 393 | class: "tooltip-text", 394 | "You get an extra turn when you make a row of exactly N stones horizontally, vertically or diagonally." 395 | } 396 | } 397 | span { 398 | class: "adjust", 399 | ", with N = " 400 | input { 401 | r#type: "number", 402 | value: "{stone_count}", 403 | onchange: move |e| stone_count.set(e.inner().value.parse().unwrap()) 404 | } 405 | } 406 | } 407 | }) 408 | } 409 | 410 | #[component] 411 | fn TraitorGo(cx: Scope, modifiers: Signal) -> Element { 412 | let modifiers = *modifiers; 413 | let stone_count = use_signal(cx, || 4); 414 | 415 | dioxus_signals::use_effect(cx, move || { 416 | let count = *stone_count.read(); 417 | if let Some(mode) = &mut modifiers.write().traitor { 418 | mode.traitor_count = count; 419 | } 420 | }); 421 | 422 | let flip = move || { 423 | let mut modifiers = modifiers.write(); 424 | modifiers.traitor = match modifiers.traitor { 425 | Some(_) => None, 426 | None => Some(shared::game::TraitorGo { 427 | traitor_count: *stone_count.read(), 428 | }), 429 | }; 430 | }; 431 | 432 | cx.render(rsx! { 433 | li { 434 | input { 435 | r#type: "checkbox", 436 | checked: modifiers.read().traitor.is_some(), 437 | onclick: move |_| flip(), 438 | } 439 | label { 440 | class: "tooltip", 441 | onclick: move |_| flip(), 442 | "Traitor Go" 443 | span { 444 | class: "tooltip-text", 445 | "N of your stones are of the wrong color." 446 | } 447 | } 448 | span { 449 | class: "adjust", 450 | ", traitor count: " 451 | input { 452 | r#type: "number", 453 | value: "{stone_count}", 454 | onchange: move |e| stone_count.set(e.inner().value.parse().unwrap()) 455 | } 456 | } 457 | } 458 | }) 459 | } 460 | 461 | #[component] 462 | fn PonnukiIsPoints(cx: Scope, modifiers: Signal) -> Element { 463 | let modifiers = *modifiers; 464 | let stone_count = use_signal(cx, || 4); 465 | 466 | dioxus_signals::use_effect(cx, move || { 467 | let count = *stone_count.read(); 468 | if let Some(mode) = &mut modifiers.write().ponnuki_is_points { 469 | *mode = count; 470 | } 471 | }); 472 | 473 | let flip = move || { 474 | let mut modifiers = modifiers.write(); 475 | modifiers.ponnuki_is_points = match modifiers.ponnuki_is_points { 476 | Some(_) => None, 477 | None => Some(*stone_count.read()), 478 | }; 479 | }; 480 | 481 | cx.render(rsx! { 482 | li { 483 | input { 484 | r#type: "checkbox", 485 | checked: modifiers.read().ponnuki_is_points.is_some(), 486 | onclick: move |_| flip(), 487 | } 488 | label { 489 | class: "tooltip", 490 | onclick: move |_| flip(), 491 | "Ponnuki ia: " 492 | span { 493 | class: "tooltip-text", 494 | "Ponnuki requires a capture and all diagonals must be empty or different color" 495 | } 496 | } 497 | span { 498 | class: "adjust", 499 | input { 500 | r#type: "number", 501 | value: "{stone_count}", 502 | onchange: move |e| stone_count.set(e.inner().value.parse().unwrap()) 503 | } 504 | " points (can be negative)" 505 | } 506 | } 507 | }) 508 | } 509 | 510 | #[component] 511 | fn PresetSelectors(cx: Scope, chosen_preset: Signal) -> Element { 512 | let presets = [ 513 | Preset::Standard, 514 | Preset::Rengo, 515 | Preset::ThreeColor, 516 | Preset::FourColor, 517 | Preset::ThreeColorRengo, 518 | ]; 519 | 520 | #[rustfmt::skip] 521 | let class = sir::css!(" 522 | padding: 10px; 523 | li { 524 | cursor: pointer; 525 | width: 200px; 526 | padding: 5px; 527 | &.active { 528 | background-color: var(--bg-h-color); 529 | } 530 | } 531 | "); 532 | 533 | cx.render(rsx! { 534 | ul { 535 | class: class, 536 | for preset in presets { 537 | li { 538 | class: if preset == *chosen_preset.read() { "active" } else { "" }, 539 | onclick: move |_| chosen_preset.set(preset), 540 | preset.name() 541 | } 542 | } 543 | } 544 | }) 545 | } 546 | 547 | #[component] 548 | fn NameInput(cx: Scope, name: Signal) -> Element { 549 | #[rustfmt::skip] 550 | let class = sir::css!(" 551 | label { 552 | margin-right: 5px; 553 | } 554 | "); 555 | 556 | cx.render(rsx! { 557 | div { 558 | class: class, 559 | label { "Game name" } 560 | input { 561 | r#type: "text", 562 | value: "{name}", 563 | oninput: move |e| name.set(e.value.clone()), 564 | } 565 | } 566 | }) 567 | } 568 | -------------------------------------------------------------------------------- /shared/src/states/play.rs: -------------------------------------------------------------------------------- 1 | mod n_plus_one; 2 | mod tetris; 3 | pub(crate) mod traitor; 4 | 5 | use crate::game::{ 6 | find_groups, ActionChange, ActionKind, Board, BoardHistory, Color, GameState, Group, GroupVec, 7 | MakeActionError, MakeActionResult, Point, SharedState, VisibilityBoard, 8 | }; 9 | use serde::{Deserialize, Serialize}; 10 | 11 | use bitmaps::Bitmap; 12 | use tinyvec::tiny_vec; 13 | 14 | use super::ScoringState; 15 | 16 | type Revealed = bool; 17 | 18 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 19 | pub struct PlayState { 20 | // TODO: use smallvec? 21 | pub players_passed: Vec, 22 | pub last_stone: Option>, 23 | /// Optimization for superko 24 | pub capture_count: usize, 25 | } 26 | 27 | impl PlayState { 28 | pub fn new(seat_count: usize) -> Self { 29 | PlayState { 30 | players_passed: vec![false; seat_count], 31 | last_stone: None, 32 | capture_count: 0, 33 | } 34 | } 35 | 36 | fn place_stone( 37 | &mut self, 38 | shared: &mut SharedState, 39 | (x, y): Point, 40 | color_placed: Color, 41 | ) -> MakeActionResult> { 42 | let mut points_played = GroupVec::new(); 43 | 44 | if shared.mods.pixel { 45 | // In pixel mode coordinate 0,0 is outside the board. 46 | // This is to adjust for it. 47 | 48 | if x > shared.board.width || y > shared.board.height { 49 | return Err(MakeActionError::OutOfBounds); 50 | } 51 | let x = x as i32 - 1; 52 | let y = y as i32 - 1; 53 | 54 | let mut any_placed = false; 55 | let mut any_revealed = false; 56 | for &(x, y) in &[(x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1)] { 57 | let coord = match shared.board.wrap_point(x, y) { 58 | Some(x) => x, 59 | None => continue, 60 | }; 61 | 62 | let point = shared.board.point_mut(coord); 63 | if let Some(visibility) = &mut shared.board_visibility { 64 | if !visibility.get_point(coord).is_empty() { 65 | any_revealed = true; 66 | points_played.push(coord); 67 | } 68 | *visibility.point_mut(coord) = Bitmap::new(); 69 | } 70 | if !point.is_empty() { 71 | continue; 72 | } 73 | *point = color_placed; 74 | points_played.push(coord); 75 | any_placed = true; 76 | } 77 | if !any_placed { 78 | if any_revealed { 79 | self.last_stone = Some(points_played); 80 | return Ok(GroupVec::new()); 81 | } 82 | return Err(MakeActionError::PointOccupied); 83 | } 84 | } else { 85 | if !shared.board.point_within((x, y)) { 86 | return Err(MakeActionError::OutOfBounds); 87 | } 88 | 89 | // TODO: don't repeat yourself 90 | let point = shared.board.point_mut((x, y)); 91 | let revealed = if let Some(visibility) = &mut shared.board_visibility { 92 | let revealed = !visibility.get_point((x, y)).is_empty(); 93 | *visibility.point_mut((x, y)) = Bitmap::new(); 94 | revealed 95 | } else { 96 | false 97 | }; 98 | if !point.is_empty() { 99 | if revealed { 100 | self.last_stone = Some(tiny_vec![[Point; 8] => (x, y)]); 101 | return Ok(points_played); 102 | } 103 | return Err(MakeActionError::PointOccupied); 104 | } 105 | 106 | *point = color_placed; 107 | points_played.push((x, y)); 108 | } 109 | 110 | Ok(points_played) 111 | } 112 | 113 | fn capture( 114 | &self, 115 | shared: &mut SharedState, 116 | points_played: &mut GroupVec, 117 | color_placed: Color, 118 | ) -> (usize, Revealed) { 119 | let active_seat = shared.get_active_seat(); 120 | let mut captures = 0; 121 | let mut revealed = false; 122 | 123 | if shared.mods.phantom.is_some() { 124 | let groups = find_groups(&shared.board); 125 | // TODO: PUZZLE add a concept for pixel go atari 126 | let ataris = groups.iter().filter(|g| g.liberties == 1); 127 | for group in ataris { 128 | let reveals = reveal_group(shared.board_visibility.as_mut(), group, &shared.board); 129 | revealed = revealed || reveals; 130 | } 131 | } 132 | 133 | let mut kill = |shared: &mut SharedState, group: &Group| -> Revealed { 134 | let board = &mut shared.board; 135 | for point in &group.points { 136 | *board.point_mut(*point) = Color::empty(); 137 | captures += 1; 138 | } 139 | let reveals = reveal_group(shared.board_visibility.as_mut(), group, board); 140 | 141 | if let Some(ponnuki) = shared.mods.ponnuki_is_points { 142 | let surrounding_count = board.surrounding_points(group.points[0]).count(); 143 | if group.points.len() == 1 144 | && surrounding_count == 4 145 | && board 146 | .surrounding_points(group.points[0]) 147 | .all(|p| board.get_point(p) == color_placed) 148 | && board 149 | .surrounding_diagonal_points(group.points[0]) 150 | .all(|p| board.get_point(p) != color_placed) 151 | { 152 | shared.points[color_placed.0 as usize - 1] += ponnuki; 153 | } 154 | } 155 | 156 | reveals 157 | }; 158 | 159 | let groups = find_groups(&shared.board); 160 | let dead_opponents = groups 161 | .iter() 162 | .filter(|g| g.liberties == 0 && g.team != color_placed); 163 | 164 | for group in dead_opponents { 165 | // Don't forget about short-circuiting boolean operators... 166 | let reveals = kill(shared, group); 167 | revealed = revealed || reveals; 168 | } 169 | 170 | // TODO: only re-scan own previously dead grouos 171 | let groups = find_groups(&shared.board); 172 | let dead_own = groups 173 | .iter() 174 | .filter(|g| g.liberties == 0 && g.team == color_placed); 175 | 176 | for group in dead_own { 177 | let mut removed_move = false; 178 | for point in &group.points { 179 | if points_played.contains(point) && color_placed == active_seat.team { 180 | points_played.retain(|x| x != point); 181 | *shared.board.point_mut(*point) = Color::empty(); 182 | removed_move = true; 183 | } 184 | } 185 | let reveals = reveal_group(shared.board_visibility.as_mut(), group, &shared.board); 186 | revealed = revealed || reveals; 187 | 188 | // If no illegal move has been made (eg. we suicided with a traitor stone), kill the group. 189 | if !removed_move { 190 | // Don't forget about short-circuiting boolean operators... 191 | let reveals = kill(shared, group); 192 | revealed = revealed || reveals; 193 | } 194 | } 195 | 196 | if shared.mods.captures_give_points.is_some() { 197 | shared.points[active_seat.team.0 as usize - 1] += captures as i32 * 2; 198 | } 199 | 200 | (captures, revealed) 201 | } 202 | 203 | /// Superko 204 | /// We only need to scan back capture_count boards, as per Ten 1p's clever idea. 205 | /// The board can't possibly repeat further back than the number of removed stones. 206 | fn superko( 207 | &self, 208 | shared: &mut SharedState, 209 | captures: usize, 210 | hash: u64, 211 | ) -> MakeActionResult<()> { 212 | for BoardHistory { 213 | hash: old_hash, 214 | board: old_board, 215 | .. 216 | } in shared 217 | .board_history 218 | .iter() 219 | .rev() 220 | .take(self.capture_count + captures) 221 | { 222 | if *old_hash == hash && old_board == &shared.board { 223 | let BoardHistory { 224 | board: old_board, 225 | points: old_points, 226 | .. 227 | } = shared 228 | .board_history 229 | .last() 230 | .expect("board_history.last() shouldn't be None") 231 | .clone(); 232 | shared.board = old_board; 233 | shared.points = old_points; 234 | return Err(MakeActionError::Ko); 235 | } 236 | } 237 | 238 | Ok(()) 239 | } 240 | 241 | fn make_action_place( 242 | &mut self, 243 | shared: &mut SharedState, 244 | (x, y): (u32, u32), 245 | color_placed: Color, 246 | ) -> MakeActionResult { 247 | // TODO: should use some kind of set to make suicide prevention faster 248 | let mut points_played = self.place_stone(shared, (x, y), color_placed)?; 249 | if points_played.is_empty() { 250 | return Ok(ActionChange::None); 251 | } 252 | 253 | if let Some(rule) = &shared.mods.tetris { 254 | // This is valid because points_played is empty if the move is illegal. 255 | use tetris::TetrisResult::*; 256 | match tetris::check(&mut points_played, &mut shared.board, rule) { 257 | Nothing => {} 258 | Illegal => { 259 | return Err(MakeActionError::Illegal); 260 | } 261 | } 262 | } 263 | 264 | if shared.mods.phantom.is_some() { 265 | let seat = shared.get_active_seat(); 266 | let visibility = shared 267 | .board_visibility 268 | .as_mut() 269 | .expect("Visibility board not initialized with phantom go"); 270 | for &point in &points_played { 271 | // The hidden layer can't deal with being able to see someone else's stones, so if we played 272 | // a stone of wrong color (eg. a traitor), just reveal it. 273 | if shared.board.get_point(point) != seat.team { 274 | continue; 275 | } 276 | 277 | let mut v = Bitmap::new(); 278 | v.set(seat.team.as_usize(), true); 279 | 280 | *visibility.point_mut(point) = v; 281 | } 282 | } 283 | 284 | let (captures, revealed) = self.capture(shared, &mut points_played, color_placed); 285 | 286 | if points_played.is_empty() { 287 | let BoardHistory { board, points, .. } = shared 288 | .board_history 289 | .last() 290 | .expect("board_history.last() shouldn't be None") 291 | .clone(); 292 | shared.board = board; 293 | shared.points = points; 294 | 295 | if revealed { 296 | return Ok(ActionChange::None); 297 | } 298 | return Err(MakeActionError::Suicide); 299 | } 300 | 301 | let hash = shared.board.hash(); 302 | 303 | self.superko(shared, captures, hash)?; 304 | 305 | let new_turn = if let Some(rule) = &shared.mods.n_plus_one { 306 | use n_plus_one::NPlusOneResult::*; 307 | match n_plus_one::check( 308 | &points_played, 309 | &shared.board, 310 | shared.board_visibility.as_mut(), 311 | rule, 312 | ) { 313 | ExtraTurn => true, 314 | Nothing => false, 315 | } 316 | } else { 317 | false 318 | }; 319 | 320 | self.last_stone = Some(points_played); 321 | 322 | // TODO: Handle this at the view layer instead to have the marker visible for your own stones. 323 | if shared.mods.phantom.is_some() { 324 | self.last_stone = None; 325 | } 326 | 327 | for passed in &mut self.players_passed { 328 | *passed = false; 329 | } 330 | 331 | self.next_turn(shared, new_turn); 332 | self.capture_count += captures; 333 | 334 | Ok(ActionChange::None) 335 | } 336 | 337 | fn make_action_pass(&mut self, shared: &mut SharedState) -> MakeActionResult { 338 | let active_seat = shared.get_active_seat(); 339 | 340 | for (seat, passed) in shared.seats.iter().zip(self.players_passed.iter_mut()) { 341 | if seat.team == active_seat.team { 342 | *passed = true; 343 | } 344 | } 345 | 346 | self.next_turn(shared, false); 347 | 348 | if shared 349 | .seats 350 | .iter() 351 | .zip(&self.players_passed) 352 | .all(|(s, &pass)| s.resigned || pass) 353 | { 354 | for passed in &mut self.players_passed { 355 | *passed = false; 356 | } 357 | return Ok(ActionChange::PushState(GameState::scoring( 358 | &shared.board, 359 | &shared.seats, 360 | &shared.points, 361 | ))); 362 | } 363 | 364 | Ok(ActionChange::None) 365 | } 366 | 367 | fn make_action_cancel(&mut self, shared: &mut SharedState) -> MakeActionResult { 368 | // Undo a turn 369 | if shared.board_history.len() < 2 { 370 | return Err(MakeActionError::OutOfBounds); 371 | } 372 | 373 | if shared.mods.no_undo { 374 | return Err(MakeActionError::Illegal); 375 | } 376 | 377 | self.rollback_turn(shared, true) 378 | } 379 | 380 | fn rollback_turn( 381 | &mut self, 382 | shared: &mut SharedState, 383 | roll_visibility: bool, 384 | ) -> MakeActionResult { 385 | shared 386 | .board_history 387 | .pop() 388 | .ok_or(MakeActionError::OutOfBounds)?; 389 | let history = shared 390 | .board_history 391 | .last() 392 | .ok_or(MakeActionError::OutOfBounds)?; 393 | 394 | shared.board = history.board.clone(); 395 | if roll_visibility { 396 | shared.board_visibility = history.board_visibility.clone(); 397 | } 398 | shared.points = history.points.clone(); 399 | shared.turn = history.turn; 400 | shared.traitor = history.traitor.clone(); 401 | 402 | *self = history.state.assume::().clone(); 403 | 404 | Ok(ActionChange::None) 405 | } 406 | 407 | fn make_action_resign(&mut self, shared: &mut SharedState) -> MakeActionResult { 408 | let active_seat = shared 409 | .seats 410 | .get_mut(shared.turn) 411 | .expect("Game turn number invalid"); 412 | 413 | active_seat.resigned = true; 414 | 415 | if shared.seats.iter().filter(|s| !s.resigned).count() <= 1 { 416 | return Ok(ActionChange::PushState(GameState::Done(ScoringState::new( 417 | &shared.board, 418 | &shared.seats, 419 | &shared.points, 420 | )))); 421 | } 422 | 423 | loop { 424 | shared.turn += 1; 425 | if shared.turn >= shared.seats.len() { 426 | shared.turn = 0; 427 | } 428 | if !shared.get_active_seat().resigned { 429 | break; 430 | } 431 | } 432 | 433 | Ok(ActionChange::None) 434 | } 435 | 436 | pub fn make_action( 437 | &mut self, 438 | shared: &mut SharedState, 439 | player_id: u64, 440 | action: ActionKind, 441 | ) -> MakeActionResult { 442 | let active_seat = shared.get_active_seat(); 443 | if active_seat.player != Some(player_id) { 444 | return Err(MakeActionError::NotTurn); 445 | } 446 | 447 | let res = match action { 448 | ActionKind::Place(x, y) => { 449 | let depth = shared.board_history.len(); 450 | 451 | let res = self.make_action_place(shared, (x, y), active_seat.team); 452 | 453 | if res.is_ok() && shared.board_history.len() > depth && shared.traitor.is_some() { 454 | // Depth increased -> the move is legal. 455 | // Replay using traitor stone. 456 | 457 | let _ = self.rollback_turn(shared, true); 458 | 459 | let traitor = shared.traitor.clone(); 460 | let color_placed = if let Some(state) = &mut shared.traitor { 461 | state.next_color(active_seat.team) 462 | } else { 463 | unreachable!(); 464 | }; 465 | 466 | let res = self.make_action_place(shared, (x, y), color_placed); 467 | 468 | if res.is_err() { 469 | shared.traitor = traitor; 470 | } 471 | res 472 | } else if res.is_ok() && shared.traitor.is_some() { 473 | // Store visibility changes to history. 474 | let history = shared.board_history.last_mut().unwrap(); 475 | history.board_visibility = shared.board_visibility.clone(); 476 | 477 | res 478 | } else { 479 | res 480 | } 481 | } 482 | ActionKind::Pass => self.make_action_pass(shared), 483 | ActionKind::Cancel => self.make_action_cancel(shared), 484 | ActionKind::Resign => self.make_action_resign(shared), 485 | }; 486 | 487 | let res = res?; 488 | 489 | self.set_zen_teams(shared); 490 | 491 | Ok(res) 492 | } 493 | 494 | fn next_turn(&mut self, shared: &mut SharedState, new_turn: bool) { 495 | if !new_turn { 496 | loop { 497 | shared.turn += 1; 498 | if shared.turn >= shared.seats.len() { 499 | shared.turn = 0; 500 | } 501 | if !shared.get_active_seat().resigned { 502 | break; 503 | } 504 | } 505 | } 506 | 507 | shared.board_history.push(BoardHistory { 508 | hash: shared.board.hash(), 509 | board: shared.board.clone(), 510 | board_visibility: shared.board_visibility.clone(), 511 | state: GameState::Play(self.clone()), 512 | points: shared.points.clone(), 513 | turn: shared.turn, 514 | traitor: shared.traitor.clone(), 515 | }); 516 | } 517 | 518 | fn set_zen_teams(&mut self, shared: &mut SharedState) { 519 | let move_number = shared.board_history.len() - 1; 520 | if let Some(zen) = &shared.mods.zen_go { 521 | for seat in &mut shared.seats { 522 | seat.team = Color((move_number % zen.color_count as usize) as u8 + 1); 523 | } 524 | } 525 | } 526 | } 527 | 528 | pub(self) fn reveal_group( 529 | visibility: Option<&mut VisibilityBoard>, 530 | group: &Group, 531 | board: &Board, 532 | ) -> Revealed { 533 | let mut revealed = false; 534 | 535 | if let Some(visibility) = visibility { 536 | for &point in &group.points { 537 | revealed = revealed || !visibility.get_point(point).is_empty(); 538 | *visibility.point_mut(point) = Bitmap::new(); 539 | for point in board.surrounding_points(point) { 540 | revealed = revealed || !visibility.get_point(point).is_empty(); 541 | *visibility.point_mut(point) = Bitmap::new(); 542 | } 543 | } 544 | } 545 | 546 | revealed 547 | } 548 | -------------------------------------------------------------------------------- /server/src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate diesel; 3 | 4 | mod db; 5 | mod game_room; 6 | mod schema; 7 | mod server; 8 | 9 | use std::collections::HashMap; 10 | use std::time::{Duration, Instant}; 11 | 12 | use actix::prelude::*; 13 | use actix_web::web::Data; 14 | use actix_web::{middleware, web, App, Error, HttpRequest, HttpResponse, HttpServer}; 15 | use actix_web_actors::ws; 16 | 17 | use crate::server::GameServer; 18 | use shared::message::{self, ClientMessage, ClientMode, ServerMessage}; 19 | 20 | use serde::{Deserialize, Serialize}; 21 | 22 | macro_rules! catch { 23 | ($($code:tt)+) => { 24 | (|| Some({ $($code)+ }))() 25 | }; 26 | } 27 | 28 | /// How often heartbeat pings are sent 29 | const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); 30 | /// How long before lack of client response causes a timeout 31 | const CLIENT_TIMEOUT: Duration = Duration::from_secs(10); 32 | 33 | /// How often time syncs are sent 34 | const TIMESYNC_INTERVAL: Duration = Duration::from_secs(2); 35 | 36 | /// do websocket handshake and start `MyWebSocket` actor 37 | async fn ws_index( 38 | r: HttpRequest, 39 | stream: web::Payload, 40 | server_addr: web::Data>, 41 | ) -> Result { 42 | let actor = ClientWebSocket { 43 | hb: Instant::now(), 44 | id: 0, 45 | server_addr: server_addr.get_ref().clone(), 46 | game_addr: HashMap::new(), 47 | room_id: None, 48 | mode: ClientMode::Client, 49 | ratelimit_hb: Instant::now(), 50 | ratelimit_counter: 0, 51 | ratelimit_block_target: None, 52 | is_admin: false, 53 | }; 54 | ws::start(actor, &r, stream) 55 | } 56 | 57 | // TODO: see https://github.com/actix/examples/blob/master/websocket-chat/src/main.rs 58 | // for how to implement socket <-> server communication 59 | 60 | /// websocket connection is long running connection, it easier 61 | /// to handle with an actor 62 | struct ClientWebSocket { 63 | /// Client must send ping at least once per 10 seconds (CLIENT_TIMEOUT), 64 | /// otherwise we drop connection. 65 | hb: Instant, 66 | id: usize, 67 | server_addr: Addr, 68 | game_addr: HashMap>, 69 | room_id: Option, 70 | mode: ClientMode, 71 | 72 | ratelimit_hb: Instant, 73 | ratelimit_counter: u64, 74 | ratelimit_block_target: Option, 75 | 76 | is_admin: bool, 77 | } 78 | 79 | type Context = ws::WebsocketContext; 80 | 81 | impl Actor for ClientWebSocket { 82 | type Context = Context; 83 | 84 | /// Method is called on actor start. We start the heartbeat process here. 85 | fn started(&mut self, ctx: &mut Self::Context) { 86 | self.hb(ctx); 87 | 88 | // register self in game server. 89 | let addr = ctx.address(); 90 | self.server_addr 91 | .send(server::Connect { 92 | addr: addr.clone().recipient(), 93 | game_addr: addr.recipient(), 94 | }) 95 | .into_actor(self) 96 | .then(|res, act, ctx| { 97 | match res { 98 | Ok(res) => act.id = res, 99 | // something is wrong with chat server 100 | _ => ctx.stop(), 101 | } 102 | fut::ready(()) 103 | }) 104 | .wait(ctx); 105 | } 106 | 107 | fn stopping(&mut self, _: &mut Self::Context) -> Running { 108 | // notify chat server 109 | self.server_addr.do_send(server::Disconnect { id: self.id }); 110 | Running::Stop 111 | } 112 | } 113 | 114 | impl Handler for ClientWebSocket { 115 | type Result = (); 116 | 117 | fn handle(&mut self, msg: game_room::Message, ctx: &mut Self::Context) { 118 | match msg { 119 | game_room::Message::GameStatus { 120 | room_id, 121 | owner, 122 | members, 123 | view, 124 | } => { 125 | ctx.binary( 126 | ServerMessage::GameStatus { 127 | room_id, 128 | owner, 129 | members, 130 | seats: view 131 | .seats 132 | .into_iter() 133 | .map(|x| (x.player, x.team.0, x.resigned)) 134 | .collect(), 135 | turn: view.turn, 136 | board: view.board.into_iter().map(|x| x.0).collect(), 137 | board_visibility: view.board_visibility, 138 | hidden_stones_left: view.hidden_stones_left, 139 | size: view.size, 140 | state: view.state, 141 | mods: view.mods, 142 | points: view.points.to_vec(), 143 | move_number: view.move_number, 144 | clock: view.clock, 145 | } 146 | .pack(), 147 | ); 148 | } 149 | game_room::Message::BoardAt { view, room_id } => { 150 | ctx.binary(ServerMessage::BoardAt { view, room_id }.pack()); 151 | } 152 | game_room::Message::SGF { sgf, room_id } => { 153 | ctx.binary(ServerMessage::SGF { sgf, room_id }.pack()); 154 | } 155 | } 156 | } 157 | } 158 | 159 | impl Handler for ClientWebSocket { 160 | type Result = (); 161 | 162 | fn handle(&mut self, msg: server::Message, ctx: &mut Self::Context) { 163 | match msg { 164 | server::Message::AnnounceRoom(room_id, name) => { 165 | ctx.binary(ServerMessage::AnnounceGame { room_id, name }.pack()); 166 | } 167 | server::Message::CloseRoom(room_id) => { 168 | ctx.binary(ServerMessage::CloseGame { room_id }.pack()); 169 | } 170 | server::Message::Identify(res) => { 171 | ctx.binary( 172 | ServerMessage::Identify { 173 | user_id: res.user_id, 174 | token: res.token.to_string(), 175 | nick: res.nick, 176 | } 177 | .pack(), 178 | ); 179 | } 180 | server::Message::UpdateProfile(res) => { 181 | ctx.binary( 182 | ServerMessage::Profile(message::Profile { 183 | user_id: res.user_id, 184 | nick: res.nick, 185 | }) 186 | .pack(), 187 | ); 188 | } 189 | }; 190 | } 191 | } 192 | 193 | impl StreamHandler> for ClientWebSocket { 194 | fn handle(&mut self, msg: Result, ctx: &mut Self::Context) { 195 | use std::convert::TryInto; 196 | 197 | let now = Instant::now(); 198 | 199 | if !self.is_admin { 200 | let diff = now - self.ratelimit_hb; 201 | // Subtract the counter by 1 every 100ms 202 | self.ratelimit_counter = self 203 | .ratelimit_counter 204 | .saturating_sub((diff.as_millis() / 100).try_into().unwrap()); 205 | self.ratelimit_hb = now; 206 | 207 | if let Some(target) = self.ratelimit_block_target { 208 | if now < target { 209 | ctx.binary(ServerMessage::Error(message::Error::RateLimit).pack()); 210 | return; 211 | } 212 | self.ratelimit_block_target = None; 213 | } 214 | 215 | self.ratelimit_counter += 1; 216 | 217 | // Allow max 10 messages per second 218 | if self.ratelimit_counter > 10 { 219 | self.ratelimit_block_target = Some(now + Duration::from_secs(2)); 220 | return; 221 | } 222 | } 223 | 224 | match msg { 225 | Ok(ws::Message::Ping(msg)) => { 226 | self.hb = now; 227 | ctx.pong(&msg); 228 | } 229 | Ok(ws::Message::Pong(_)) => { 230 | self.hb = Instant::now(); 231 | } 232 | Ok(ws::Message::Text(_)) => {} 233 | Ok(ws::Message::Binary(bin)) => { 234 | let data = serde_cbor::from_slice::(&bin); 235 | match data { 236 | Ok(data) => self.handle_message(data, ctx), 237 | Err(e) => ctx.binary(ServerMessage::MsgError(format!("{}", e)).pack()), 238 | } 239 | } 240 | Ok(ws::Message::Close(reason)) => { 241 | ctx.close(reason); 242 | ctx.stop(); 243 | } 244 | _ => ctx.stop(), 245 | } 246 | } 247 | } 248 | 249 | impl ClientWebSocket { 250 | /// helper method that sends ping to client every second. 251 | /// 252 | /// also this method checks heartbeats from client 253 | fn hb(&self, ctx: &mut Context) { 254 | ctx.run_interval(HEARTBEAT_INTERVAL, |act, ctx| { 255 | // check client heartbeats 256 | if Instant::now().duration_since(act.hb) > CLIENT_TIMEOUT { 257 | // heartbeat timed out 258 | println!("Websocket Client heartbeat failed, disconnecting!"); 259 | 260 | // stop actor 261 | ctx.stop(); 262 | 263 | // don't try to send a ping 264 | return; 265 | } 266 | 267 | ctx.ping(b""); 268 | }); 269 | 270 | ctx.run_interval(TIMESYNC_INTERVAL, |_act, ctx| { 271 | ctx.binary(ServerMessage::ServerTime(shared::game::clock::Millisecond::now()).pack()); 272 | }); 273 | } 274 | 275 | fn handle_get_game_list(&mut self, ctx: &mut Context) { 276 | fn send_rooms(mut rooms: Vec<(u32, String)>, ctx: &mut Context) { 277 | // Sort newest first 278 | rooms.sort_unstable_by_key(|x| -(x.0 as i32)); 279 | for (room_id, name) in rooms { 280 | ctx.binary(ServerMessage::AnnounceGame { room_id, name }.pack()); 281 | } 282 | } 283 | 284 | self.server_addr 285 | .send(server::ListRooms) 286 | .into_actor(self) 287 | .then(|res, _act, ctx| { 288 | match res { 289 | Ok(res) => send_rooms(res, ctx), 290 | _ => ctx.stop(), 291 | } 292 | fut::ready(()) 293 | }) 294 | .wait(ctx); 295 | } 296 | 297 | fn handle_start_game(&mut self, msg: message::StartGame, ctx: &mut Context) { 298 | self.server_addr 299 | .send(server::CreateRoom { 300 | id: self.id, 301 | room: msg, 302 | leave_previous: match self.mode { 303 | ClientMode::Client => true, 304 | ClientMode::Integration => false, 305 | }, 306 | }) 307 | .into_actor(self) 308 | .then(|res, act, ctx| { 309 | match res { 310 | Ok(Ok((id, addr))) => { 311 | act.room_id = Some(id); 312 | act.game_addr.insert(id, addr.unwrap()); 313 | } 314 | Ok(Err(err)) => { 315 | ctx.binary(ServerMessage::Error(err).pack()); 316 | } 317 | _ => {} 318 | } 319 | fut::ready(()) 320 | }) 321 | .wait(ctx); 322 | } 323 | 324 | fn handle_join_game(&mut self, room_id: u32, ctx: &mut Context) { 325 | self.server_addr 326 | .send(server::Join { 327 | id: self.id, 328 | room_id, 329 | leave_previous: match self.mode { 330 | ClientMode::Client => true, 331 | ClientMode::Integration => false, 332 | }, 333 | }) 334 | .into_actor(self) 335 | .then(move |res, act, _| { 336 | if let Ok(Ok(addr)) = res { 337 | act.room_id = Some(room_id); 338 | act.game_addr.insert(room_id, addr); 339 | } 340 | fut::ready(()) 341 | }) 342 | .wait(ctx); 343 | } 344 | 345 | fn handle_leave_game(&mut self, room_id: Option, ctx: &mut Context) { 346 | self.server_addr 347 | .send(server::LeaveRoom { 348 | id: self.id, 349 | room_id, 350 | }) 351 | .into_actor(self) 352 | .then(move |_res, _act, _| fut::ready(())) 353 | .wait(ctx); 354 | } 355 | 356 | fn handle_identify(&mut self, token: Option, nick: Option, ctx: &mut Context) { 357 | self.server_addr 358 | .send(server::IdentifyAs { 359 | id: self.id, 360 | token, 361 | nick, 362 | }) 363 | .into_actor(self) 364 | .then(|res, act, ctx| { 365 | match res { 366 | Ok(Ok(res)) => { 367 | act.is_admin = res.is_admin; 368 | ctx.binary( 369 | ServerMessage::Identify { 370 | user_id: res.user_id, 371 | token: res.token.to_string(), 372 | nick: res.nick, 373 | } 374 | .pack(), 375 | ) 376 | } 377 | Ok(Err(err)) => { 378 | ctx.binary(ServerMessage::Error(err).pack()); 379 | } 380 | _ => ctx.stop(), 381 | } 382 | fut::ready(()) 383 | }) 384 | .wait(ctx); 385 | } 386 | 387 | fn handle_message(&mut self, msg: ClientMessage, ctx: &mut Context) { 388 | println!("WS: {:?}", msg); 389 | match msg { 390 | ClientMessage::GetGameList => { 391 | self.handle_get_game_list(ctx); 392 | } 393 | ClientMessage::StartGame(start) => { 394 | self.handle_start_game(start, ctx); 395 | } 396 | ClientMessage::JoinGame(room_id) => { 397 | self.handle_join_game(room_id, ctx); 398 | } 399 | ClientMessage::LeaveGame(room_id) => { 400 | self.handle_leave_game(room_id, ctx); 401 | } 402 | ClientMessage::GameAction { room_id, action } => { 403 | if let Some(addr) = &self.game_addr.get(&room_id.or(self.room_id).unwrap_or(0)) { 404 | addr.send(game_room::GameAction { 405 | id: self.id, 406 | action, 407 | }) 408 | .into_actor(self) 409 | .then(|res, _act, ctx| { 410 | match res { 411 | Ok(Ok(())) => {} 412 | Ok(Err(err)) => { 413 | ctx.binary(ServerMessage::Error(err).pack()); 414 | } 415 | _ => {} 416 | } 417 | fut::ready(()) 418 | }) 419 | .wait(ctx); 420 | } 421 | } 422 | ClientMessage::Identify { token, nick } => { 423 | self.handle_identify(token, nick, ctx); 424 | } 425 | ClientMessage::Admin(action) => { 426 | self.server_addr.do_send(server::AdminMessage { 427 | client_id: self.id, 428 | action, 429 | }); 430 | } 431 | ClientMessage::Mode(mode) => { 432 | self.mode = mode; 433 | } 434 | }; 435 | } 436 | } 437 | 438 | #[derive(Debug, Deserialize)] 439 | struct CreateGameBody { 440 | game: message::StartGame, 441 | #[serde(default)] 442 | players: Option>>, 443 | } 444 | 445 | #[derive(Debug, Serialize)] 446 | struct CreateGameResponse { 447 | id: u32, 448 | } 449 | 450 | async fn create_game( 451 | req: actix_web::HttpRequest, 452 | body: web::Json, 453 | server_addr: web::Data>, 454 | db_addr: web::Data>, 455 | ) -> actix_web::Result { 456 | println!("POST /game/create: {:?}", body); 457 | 458 | let token = match catch! { 459 | let header = req.headers().get("Authentication")?; 460 | header.to_str().ok()?.to_owned() 461 | } { 462 | Some(x) => x, 463 | None => return Ok(HttpResponse::BadRequest().body("Bearer token required")), 464 | }; 465 | 466 | let user = match db_addr.send(db::GetUserByToken(token)).await.unwrap() { 467 | Ok(x) => x, 468 | Err(_) => return Ok(HttpResponse::BadRequest().body("Invalid token")), 469 | }; 470 | 471 | if !user.has_integration_access { 472 | return Ok(HttpResponse::BadRequest().body("Invalid token")); 473 | } 474 | 475 | let CreateGameBody { game, players } = body.into_inner(); 476 | 477 | let resp = server_addr 478 | .send(server::CreateRoom { 479 | id: 0, 480 | room: game, 481 | leave_previous: false, 482 | }) 483 | .await.unwrap(); 484 | 485 | let (id, addr) = match resp { 486 | Ok((id, Some(addr))) => (id, addr), 487 | _ => return Ok(HttpResponse::BadRequest().body("Game creation error")), 488 | }; 489 | 490 | for (idx, &user_id) in players.iter().flatten().enumerate() { 491 | if let Some(user_id) = user_id { 492 | addr.do_send(game_room::GameActionAsUser { 493 | user_id, 494 | action: message::GameAction::TakeSeat(idx as _), 495 | }); 496 | } 497 | } 498 | 499 | Ok(HttpResponse::Ok().json(CreateGameResponse { id })) 500 | } 501 | 502 | #[derive(Debug, Serialize)] 503 | struct GetGameResponse { 504 | game: shared::game::GameView, 505 | } 506 | 507 | async fn get_game_view( 508 | req: actix_web::HttpRequest, 509 | server_addr: web::Data>, 510 | ) -> actix_web::Result { 511 | let room_id = req.match_info().get("id").unwrap().parse().unwrap(); 512 | 513 | let resp = server_addr.send(server::GetAdminView { room_id }).await.unwrap(); 514 | 515 | let view = match resp { 516 | Ok(view) => view, 517 | Err(_) => return Ok(HttpResponse::BadRequest().body("Game fetch error")), 518 | }; 519 | 520 | Ok(HttpResponse::Ok().json(GetGameResponse { game: view })) 521 | } 522 | 523 | #[derive(Debug, Serialize)] 524 | enum GameState { 525 | Play, 526 | Done, 527 | } 528 | 529 | #[derive(Debug, Serialize)] 530 | struct TeamResult { 531 | score: f32, 532 | resigned: bool, 533 | } 534 | 535 | #[derive(Debug, Serialize)] 536 | struct GetGameResultResponse { 537 | state: GameState, 538 | teams: Option>, 539 | winner: Option, 540 | } 541 | 542 | async fn get_game_result( 543 | req: actix_web::HttpRequest, 544 | server_addr: web::Data>, 545 | ) -> actix_web::Result { 546 | use shared::game::GameStateView; 547 | 548 | let room_id = req.match_info().get("id").unwrap().parse().unwrap(); 549 | 550 | let resp = server_addr.send(server::GetAdminView { room_id }).await.unwrap(); 551 | 552 | let view = match resp { 553 | Ok(view) => view, 554 | Err(_) => return Ok(HttpResponse::BadRequest().body("Game fetch error")), 555 | }; 556 | 557 | let response = match &view.state { 558 | GameStateView::Done(scoring) => { 559 | let mut teams: Vec<_> = scoring 560 | .scores 561 | .iter() 562 | .map(|&s| TeamResult { 563 | score: s as f32 / 2.0, 564 | resigned: false, 565 | }) 566 | .collect(); 567 | for seat in &view.seats { 568 | teams[seat.team.as_usize() - 1].resigned |= seat.resigned; 569 | } 570 | let mut winner = (0, 0.0); 571 | for (idx, team) in teams.iter().enumerate() { 572 | if team.resigned { 573 | continue; 574 | } 575 | if team.score > winner.1 { 576 | winner = (idx + 1, team.score); 577 | } 578 | } 579 | GetGameResultResponse { 580 | state: GameState::Done, 581 | teams: Some(teams), 582 | winner: Some(winner.0), 583 | } 584 | } 585 | _ => GetGameResultResponse { 586 | state: GameState::Play, 587 | teams: None, 588 | winner: None, 589 | }, 590 | }; 591 | 592 | Ok(HttpResponse::Ok().json(response)) 593 | } 594 | 595 | #[actix_rt::main] 596 | async fn main() -> std::io::Result<()> { 597 | std::env::set_var("RUST_LOG", "actix_server=info,actix_web=info"); 598 | env_logger::init(); 599 | 600 | let server = GameServer::default().start(); 601 | let db = SyncArbiter::start(1, db::DbActor::default); 602 | 603 | HttpServer::new(move || { 604 | App::new() 605 | // enable logger 606 | .wrap(middleware::Logger::default()) 607 | .app_data(Data::new(server.clone())) 608 | .app_data(Data::new(db.clone())) 609 | // websocket route 610 | .service(web::resource("/ws/").route(web::get().to(ws_index))) 611 | .service(web::resource("/api/game/create").route(web::post().to(create_game))) 612 | .service(web::resource("/api/game/{id}").route(web::get().to(get_game_view))) 613 | .service(web::resource("/api/game/{id}/result").route(web::get().to(get_game_result))) 614 | }) 615 | .bind("0.0.0.0:8088")? 616 | .run() 617 | .await 618 | } 619 | -------------------------------------------------------------------------------- /client/src/board.rs: -------------------------------------------------------------------------------- 1 | use shared::game::{GameStateView, Visibility}; 2 | use web_sys::wasm_bindgen::JsCast; 3 | use web_sys::DomRect; 4 | use web_sys::{wasm_bindgen::JsValue, HtmlCanvasElement}; 5 | 6 | use crate::palette::Palette; 7 | use crate::state::{self, GameHistory}; 8 | 9 | #[derive(Copy, Clone, PartialEq, Debug)] 10 | pub enum Direction { 11 | Up, 12 | Down, 13 | Left, 14 | Right, 15 | } 16 | 17 | #[derive(Copy, Clone, PartialEq, Debug)] 18 | pub enum Input { 19 | Place((u32, u32), bool), 20 | Move(Direction, bool), 21 | WiderEdge, 22 | SmallerEdge, 23 | None, 24 | } 25 | 26 | pub(crate) struct Board { 27 | pub(crate) palette: Palette, 28 | pub(crate) toroidal_edge_size: i32, 29 | pub(crate) board_displacement: (i32, i32), 30 | pub(crate) selection_pos: Option<(u32, u32)>, 31 | pub(crate) input: Input, 32 | pub(crate) show_hidden: bool, 33 | pub(crate) edge_size: f64, 34 | } 35 | 36 | impl Input { 37 | pub(crate) fn from_pointer( 38 | board: &Board, 39 | game: &state::GameView, 40 | mut p: (f64, f64), 41 | bounding: DomRect, 42 | clicked: bool, 43 | ) -> Input { 44 | let pixel_ratio = gloo_utils::window().device_pixel_ratio(); 45 | let edge_size = board.edge_size as f64 / pixel_ratio; 46 | let width = bounding.width() - (2.0 * edge_size); 47 | let height = bounding.height() - (2.0 * edge_size); 48 | let is_scoring = matches!(game.state, GameStateView::Scoring(_)); 49 | 50 | // Adjust the coordinates for canvas position 51 | p.0 -= bounding.left(); 52 | p.1 -= bounding.top(); 53 | 54 | if p.0 < edge_size { 55 | return Input::Move(Direction::Left, clicked); 56 | } 57 | if p.1 < edge_size { 58 | return Input::Move(Direction::Up, clicked); 59 | } 60 | if p.0 > width + edge_size { 61 | return Input::Move(Direction::Right, clicked); 62 | } 63 | if p.1 > height + edge_size { 64 | return Input::Move(Direction::Down, clicked); 65 | } 66 | 67 | p.0 -= edge_size; 68 | p.1 -= edge_size; 69 | let size = (game.size.0 as i32 + 2 * board.toroidal_edge_size) as f64; 70 | let pos = match game.mods.pixel && !is_scoring { 71 | true => ( 72 | (p.0 / (width / size) + 0.5) as i32, 73 | (p.1 / (height / size) + 0.5) as i32, 74 | ), 75 | false => ( 76 | (p.0 / (width / size)) as i32, 77 | (p.1 / (height / size)) as i32, 78 | ), 79 | }; 80 | 81 | Input::Place((pos.0 as u32, pos.1 as u32), clicked) 82 | } 83 | 84 | pub(crate) fn into_selection(self) -> Option<(u32, u32)> { 85 | match self { 86 | Input::Place(p, _) => Some(p), 87 | _ => None, 88 | } 89 | } 90 | } 91 | 92 | impl Board { 93 | pub(crate) fn render_gl( 94 | &self, 95 | canvas: &HtmlCanvasElement, 96 | game: &state::GameView, 97 | history: Option<&GameHistory>, 98 | ) -> Result<(), JsValue> { 99 | // Stone colors /////////////////////////////////////////////////////// 100 | 101 | let palette = &self.palette; 102 | let shadow_stone_colors = palette.shadow_stone_colors; 103 | let shadow_border_colors = palette.shadow_border_colors; 104 | let stone_colors = palette.stone_colors; 105 | let stone_colors_hidden = palette.stone_colors_hidden; 106 | let border_colors = palette.border_colors; 107 | let dead_mark_color = palette.dead_mark_color; 108 | 109 | let edge_size = self.edge_size; 110 | 111 | // Setup ////////////////////////////////////////////////////////////// 112 | 113 | let context = canvas 114 | .get_context("2d") 115 | .unwrap() 116 | .unwrap() 117 | .dyn_into::() 118 | .unwrap(); 119 | 120 | // let dpi = gloo_utils::window().device_pixel_ratio(); 121 | // context.scale(dpi, dpi)?; 122 | 123 | let board = match history { 124 | Some(h) => &h.board, 125 | None => &game.board, 126 | }; 127 | let board_visibility = match history { 128 | Some(h) => &h.board_visibility, 129 | None => &game.board_visibility, 130 | }; 131 | 132 | // TODO: actually handle non-square boards 133 | let view_board_size = game.size.0 as usize + 2 * self.toroidal_edge_size as usize; 134 | let board_size = game.size.0 as usize; 135 | let width = canvas.width() as f64; 136 | let height = canvas.height() as f64; 137 | let size = (canvas.width() as f64 - 2.0 * edge_size) / view_board_size as f64; 138 | let turn = game.seats[game.turn as usize].team.0; 139 | 140 | let draw_stone = 141 | |(x, y): (i32, i32), diameter: f64, fill: bool, stroke: bool| -> Result<(), JsValue> { 142 | context.begin_path(); 143 | context.arc( 144 | edge_size + (x as f64 + 0.5) * size, 145 | edge_size + (y as f64 + 0.5) * size, 146 | diameter / 2., 147 | 0.0, 148 | 2.0 * std::f64::consts::PI, 149 | )?; 150 | if fill { 151 | context.fill(); 152 | } 153 | if stroke { 154 | context.stroke(); 155 | } 156 | Ok(()) 157 | }; 158 | 159 | // Clear canvas /////////////////////////////////////////////////////// 160 | 161 | context.clear_rect(0.0, 0.0, canvas.width().into(), canvas.height().into()); 162 | 163 | context.set_fill_style(&JsValue::from_str(palette.background)); 164 | context.fill_rect(0.0, 0.0, canvas.width().into(), canvas.height().into()); 165 | 166 | // Toroidal edge scroll boxes ///////////////////////////////////////// 167 | 168 | if game.mods.toroidal.is_some() { 169 | context.set_stroke_style(&JsValue::from_str("#000000")); 170 | context.set_fill_style(&JsValue::from_str("#000000aa")); 171 | match self.input { 172 | Input::Move(Direction::Left, _) => { 173 | context.fill_rect(0.0, 0.0, edge_size, height); 174 | } 175 | Input::Move(Direction::Right, _) => { 176 | context.fill_rect(width - edge_size, 0.0, edge_size, height); 177 | } 178 | Input::Move(Direction::Up, _) => { 179 | context.fill_rect(0.0, 0.0, width, edge_size); 180 | } 181 | Input::Move(Direction::Down, _) => { 182 | context.fill_rect(0.0, height - edge_size, width, edge_size); 183 | } 184 | _ => {} 185 | } 186 | } 187 | 188 | // Board lines //////////////////////////////////////////////////////// 189 | 190 | context.set_line_width(1.0); 191 | context.set_stroke_style(&JsValue::from_str("#000000")); 192 | context.set_fill_style(&JsValue::from_str("#000000")); 193 | 194 | let line_edge_size = if game.mods.toroidal.is_some() { 195 | size / 2.0 196 | } else { 197 | 0.0 198 | }; 199 | 200 | for y in 0..view_board_size { 201 | context.begin_path(); 202 | context.move_to( 203 | edge_size - line_edge_size + size * 0.5, 204 | edge_size + (y as f64 + 0.5) * size, 205 | ); 206 | context.line_to( 207 | edge_size + line_edge_size + size * (view_board_size as f64 - 0.5), 208 | edge_size + (y as f64 + 0.5) * size, 209 | ); 210 | context.stroke(); 211 | } 212 | 213 | for x in 0..view_board_size { 214 | context.begin_path(); 215 | context.move_to( 216 | edge_size + (x as f64 + 0.5) * size, 217 | edge_size - line_edge_size + size * 0.5, 218 | ); 219 | context.line_to( 220 | edge_size + (x as f64 + 0.5) * size, 221 | edge_size + line_edge_size + size * (view_board_size as f64 - 0.5), 222 | ); 223 | context.stroke(); 224 | } 225 | 226 | // Starpoints ///////////////////////////////////////////////////////// 227 | 228 | if game.mods.toroidal.is_none() { 229 | let points: &[(i32, i32)] = match game.size.0 { 230 | 19 => &[ 231 | (3, 3), 232 | (9, 3), 233 | (15, 3), 234 | (3, 9), 235 | (9, 9), 236 | (15, 9), 237 | (3, 15), 238 | (9, 15), 239 | (15, 15), 240 | ], 241 | 17 => &[ 242 | (3, 3), 243 | (8, 3), 244 | (13, 3), 245 | (3, 8), 246 | (8, 8), 247 | (13, 8), 248 | (3, 13), 249 | (8, 13), 250 | (13, 13), 251 | ], 252 | 13 => &[(3, 3), (9, 3), (6, 6), (3, 9), (9, 9)], 253 | 9 => &[(4, 4)], 254 | _ => &[], 255 | }; 256 | for &(x, y) in points { 257 | let x = (x - self.board_displacement.0).rem_euclid(game.size.0 as i32); 258 | let y = (y - self.board_displacement.1).rem_euclid(game.size.1 as i32); 259 | draw_stone((x as _, y as _), size / 4., true, false)?; 260 | } 261 | } 262 | 263 | // Coordinates //////////////////////////////////////////////////////// 264 | 265 | let from_edge = edge_size - 20.0; 266 | 267 | context.set_font("bold 1.5em serif"); 268 | 269 | context.set_text_align("center"); 270 | context.set_text_baseline("middle"); 271 | 272 | for (i, y) in (0..game.size.1) 273 | .cycle() 274 | .skip( 275 | self.board_displacement.1 as usize + board_size - self.toroidal_edge_size as usize, 276 | ) 277 | .take(view_board_size) 278 | .enumerate() 279 | { 280 | let text = (game.size.1 - y).to_string(); 281 | let i = i as f64 + 0.5; 282 | context.fill_text(&text, from_edge, edge_size + i * size + 2.0)?; 283 | context.fill_text(&text, width - from_edge, edge_size + i * size + 2.0)?; 284 | } 285 | 286 | context.set_text_align("center"); 287 | context.set_text_baseline("baseline"); 288 | 289 | for (i, x) in (0..game.size.0) 290 | .cycle() 291 | .skip( 292 | self.board_displacement.0 as usize + board_size - self.toroidal_edge_size as usize, 293 | ) 294 | .take(view_board_size) 295 | .enumerate() 296 | { 297 | let letter = ('A'..'I') 298 | .chain('J'..='Z') 299 | .nth(x as usize) 300 | .unwrap() 301 | .to_string(); 302 | let i = i as f64 + 0.5; 303 | context.fill_text(&letter, edge_size + i * size, from_edge)?; 304 | context.fill_text(&letter, edge_size + i * size, height - from_edge)?; 305 | } 306 | 307 | // Mouse hover display //////////////////////////////////////////////// 308 | 309 | let is_scoring = matches!(game.state, GameStateView::Scoring(_)); 310 | if !is_scoring { 311 | if let Some(selection_pos) = self.selection_pos { 312 | let mut p = self.view_to_board_coord(game, selection_pos); 313 | if game.mods.pixel { 314 | p.0 -= 1; 315 | p.1 -= 1; 316 | } 317 | 318 | // TODO: This allocation is horrible, figure out how to avoid it 319 | // TODO: Also move these to shared 320 | let points = match game.mods.pixel { 321 | true => vec![ 322 | (p.0, p.1), 323 | (p.0 + 1, p.1), 324 | (p.0, p.1 + 1), 325 | (p.0 + 1, p.1 + 1), 326 | ], 327 | false => vec![p], 328 | }; 329 | let color = turn; 330 | // Teams start from 1 331 | context.set_fill_style(&JsValue::from_str(shadow_stone_colors[color as usize - 1])); 332 | context 333 | .set_stroke_style(&JsValue::from_str(shadow_border_colors[color as usize - 1])); 334 | 335 | for p in points { 336 | self.board_to_view_coord(game, p, |p| { 337 | draw_stone(p, size, true, true).unwrap(); 338 | }); 339 | } 340 | } 341 | } 342 | 343 | // Board stones /////////////////////////////////////////////////////// 344 | 345 | for (idx, &color) in board.iter().enumerate() { 346 | let x = idx % board_size; 347 | let y = idx / board_size; 348 | 349 | let visible = board_visibility 350 | .as_ref() 351 | .map(|v| v[idx] == 0) 352 | .unwrap_or(true); 353 | 354 | if color.0 == 0 || !visible { 355 | continue; 356 | } 357 | 358 | context.set_fill_style(&JsValue::from_str(stone_colors[color.0 as usize - 1])); 359 | context.set_stroke_style(&JsValue::from_str(border_colors[color.0 as usize - 1])); 360 | 361 | self.board_to_view_coord(game, (x as i32, y as i32), |(px, py)| { 362 | draw_stone((px as _, py as _), size, true, true).unwrap(); 363 | }); 364 | } 365 | 366 | // Hidden stones ////////////////////////////////////////////////////// 367 | 368 | if self.show_hidden { 369 | for (idx, &colors) in board_visibility.iter().flatten().enumerate() { 370 | let x = idx % board_size; 371 | let y = idx / board_size; 372 | 373 | let colors = Visibility::from_value(colors); 374 | 375 | if colors.is_empty() { 376 | continue; 377 | } 378 | 379 | for color in &colors { 380 | context.set_fill_style(&JsValue::from_str( 381 | stone_colors_hidden[color as usize - 1], 382 | )); 383 | context.set_stroke_style(&JsValue::from_str(border_colors[color as usize - 1])); 384 | 385 | self.board_to_view_coord(game, (x as i32, y as i32), |(px, py)| { 386 | draw_stone((px as _, py as _), size, true, true).unwrap(); 387 | }); 388 | } 389 | } 390 | } 391 | 392 | // Last stone marker ////////////////////////////////////////////////// 393 | 394 | let last_stone = match (&game.state, history) { 395 | (_, Some(h)) => h.last_stone.as_ref(), 396 | (GameStateView::Play(state), _) => state.last_stone.as_ref(), 397 | _ => None, 398 | }; 399 | 400 | if let Some(points) = last_stone { 401 | for &(x, y) in points { 402 | let mut color = board[y as usize * game.size.0 as usize + x as usize].0; 403 | 404 | if color == 0 { 405 | // White stones have the most fitting (read: black) marker for empty board 406 | color = 2; 407 | } 408 | 409 | context.set_stroke_style(&JsValue::from_str(dead_mark_color[color as usize - 1])); 410 | context.set_line_width(2.0); 411 | 412 | self.board_to_view_coord(game, (x as i32, y as i32), |(px, py)| { 413 | draw_stone((px as _, py as _), size / 2., false, true).unwrap(); 414 | }); 415 | } 416 | } 417 | 418 | // States ///////////////////////////////////////////////////////////// 419 | 420 | if history.is_none() { 421 | match &game.state { 422 | GameStateView::Scoring(scoring) | GameStateView::Done(scoring) => { 423 | for group in &scoring.groups { 424 | if group.alive { 425 | continue; 426 | } 427 | 428 | for &(x, y) in &group.points { 429 | self.board_to_view_coord(game, (x as i32, y as i32), |(x, y)| { 430 | context.set_line_width(2.0); 431 | context.set_stroke_style(&JsValue::from_str( 432 | dead_mark_color[group.team.0 as usize - 1], 433 | )); 434 | 435 | context.set_stroke_style(&JsValue::from_str( 436 | dead_mark_color[group.team.0 as usize - 1], 437 | )); 438 | 439 | context.begin_path(); 440 | context.move_to( 441 | edge_size + (x as f64 + 0.2) * size, 442 | edge_size + (y as f64 + 0.2) * size, 443 | ); 444 | context.line_to( 445 | edge_size + (x as f64 + 0.8) * size, 446 | edge_size + (y as f64 + 0.8) * size, 447 | ); 448 | context.stroke(); 449 | 450 | context.begin_path(); 451 | context.move_to( 452 | edge_size + (x as f64 + 0.8) * size, 453 | edge_size + (y as f64 + 0.2) * size, 454 | ); 455 | context.line_to( 456 | edge_size + (x as f64 + 0.2) * size, 457 | edge_size + (y as f64 + 0.8) * size, 458 | ); 459 | context.stroke(); 460 | }); 461 | } 462 | } 463 | 464 | for (idx, &color) in scoring.points.points.iter().enumerate() { 465 | let x = idx % board_size; 466 | let y = idx / board_size; 467 | 468 | if color.is_empty() { 469 | continue; 470 | } 471 | 472 | self.board_to_view_coord(game, (x as i32, y as i32), |(x, y)| { 473 | context.set_fill_style(&JsValue::from_str( 474 | stone_colors[color.0 as usize - 1], 475 | )); 476 | 477 | context.set_stroke_style(&JsValue::from_str( 478 | border_colors[color.0 as usize - 1], 479 | )); 480 | 481 | context.fill_rect( 482 | edge_size + (x as f64 + 1. / 3.) * size, 483 | edge_size + (y as f64 + 1. / 3.) * size, 484 | (1. / 3.) * size, 485 | (1. / 3.) * size, 486 | ); 487 | }); 488 | } 489 | } 490 | _ => {} 491 | } 492 | } 493 | 494 | // Toroidal edge grayout ////////////////////////////////////////////// 495 | 496 | if game.mods.toroidal.is_some() { 497 | context.set_stroke_style(&JsValue::from_str("#000000")); 498 | context.set_fill_style(&JsValue::from_str("#00000055")); 499 | let e = self.toroidal_edge_size as f64; 500 | context.fill_rect(edge_size, edge_size, e * size, height - edge_size * 2.0); 501 | context.fill_rect( 502 | edge_size + e * size, 503 | edge_size, 504 | width - edge_size * 2.0 - 2.0 * e * size, 505 | e * size, 506 | ); 507 | context.fill_rect( 508 | width - e * size - edge_size, 509 | edge_size, 510 | e * size, 511 | height - edge_size * 2.0, 512 | ); 513 | context.fill_rect( 514 | edge_size + e * size, 515 | height - e * size - edge_size, 516 | width - edge_size * 2.0 - 2.0 * e * size, 517 | e * size, 518 | ); 519 | } 520 | 521 | Ok(()) 522 | } 523 | 524 | fn view_to_board_coord(&self, game: &state::GameView, view: (u32, u32)) -> (i32, i32) { 525 | let edge = self.toroidal_edge_size; 526 | let size = game.size.0 as i32; 527 | let mut x = view.0 as i32; 528 | let mut y = view.1 as i32; 529 | 530 | if game.mods.toroidal.is_none() { 531 | return (x, y); 532 | } 533 | 534 | x -= edge; 535 | y -= edge; 536 | 537 | if x < 0 { 538 | x += size; 539 | } 540 | if y < 1 { 541 | y += size; 542 | } 543 | if x >= size { 544 | x -= size; 545 | } 546 | if y >= size { 547 | y -= size; 548 | } 549 | 550 | x = (x + self.board_displacement.0).rem_euclid(game.size.0 as i32); 551 | y = (y + self.board_displacement.1).rem_euclid(game.size.1 as i32); 552 | 553 | (x, y) 554 | } 555 | 556 | fn board_to_view_coord( 557 | &self, 558 | game: &state::GameView, 559 | board: (i32, i32), 560 | mut cb: impl FnMut((i32, i32)), 561 | ) { 562 | let edge = self.toroidal_edge_size; 563 | let size = game.size.0 as i32; 564 | 565 | if game.mods.toroidal.is_none() 566 | && (board.0 < 0 || board.1 < 0 || board.0 >= size || board.1 >= size) 567 | { 568 | return; 569 | } 570 | 571 | let x = (board.0 - self.board_displacement.0).rem_euclid(game.size.0 as i32); 572 | let y = (board.1 - self.board_displacement.1).rem_euclid(game.size.1 as i32); 573 | cb((x + edge, y + edge)); 574 | 575 | if x < edge { 576 | cb((x + size + edge, y + edge)); 577 | if y < edge { 578 | cb((x + size + edge, y + size + edge)); 579 | } 580 | if y >= size - edge { 581 | cb((x + size + edge, y - size + edge)); 582 | } 583 | } 584 | if y < edge { 585 | cb((x + edge, y + size + edge)); 586 | } 587 | if x >= size - edge { 588 | cb((x - size + edge, y + edge)); 589 | if y < edge { 590 | cb((x - size + edge, y + size + edge)); 591 | } 592 | if y >= size - edge { 593 | cb((x - size + edge, y - size + edge)); 594 | } 595 | } 596 | if y >= size - edge { 597 | cb((x + edge, y - size + edge)) 598 | } 599 | } 600 | } 601 | --------------------------------------------------------------------------------