├── .editorconfig ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── Makefile ├── README.md ├── deploy ├── dev │ ├── .env │ ├── Dockerfile │ └── docker-compose.yml ├── nginx.conf ├── prod │ ├── .env │ ├── Dockerfile │ └── docker-compose.yml └── test │ ├── .env │ ├── Dockerfile │ └── docker-compose.yml ├── docs └── images │ ├── grid_sphere.png │ ├── quad_sphere.png │ ├── uv_sphere_pole.jpg │ └── uv_sphere_side.jpg ├── frontend ├── .env ├── .gitignore ├── .prettierrc ├── README.md ├── index.html ├── libs │ └── index.ts ├── package.json ├── pnpm-lock.yaml ├── src │ ├── App.module.css │ ├── App.tsx │ ├── api │ │ ├── api_client.ts │ │ ├── index.ts │ │ ├── models.ts │ │ └── ws_client.ts │ ├── assets │ │ ├── favicon.ico │ │ └── gridMaterial.json │ ├── auth.ts │ ├── components │ │ ├── ModeChooser.tsx │ │ └── index.ts │ ├── constants.ts │ ├── gui │ │ ├── alert.ts │ │ ├── game_creation_form.ts │ │ ├── gui_components.ts │ │ ├── index.ts │ │ ├── player_bar.ts │ │ ├── select_menu.ts │ │ └── text.ts │ ├── index.css │ ├── index.tsx │ ├── logic │ │ ├── fields │ │ │ ├── enum.ts │ │ │ ├── grid_sphere.ts │ │ │ ├── interface.ts │ │ │ └── regular.ts │ │ ├── game_manager.ts │ │ ├── games │ │ │ ├── game.ts │ │ │ ├── multiplayer_game.ts │ │ │ └── singleplayer_game.ts │ │ ├── scene.ts │ │ └── stone_manager.ts │ ├── pages │ │ ├── 404.tsx │ │ ├── Game.tsx │ │ ├── Login.tsx │ │ ├── Settings.tsx │ │ └── rooms │ │ │ ├── [id].tsx │ │ │ └── index.tsx │ └── router.ts ├── tsconfig.json └── vite.config.ts ├── gamelib ├── Cargo.lock ├── Cargo.toml ├── rust-toolchain.toml └── src │ ├── aliases.rs │ ├── errors.rs │ ├── field │ ├── cube_sphere_builder.rs │ ├── field.rs │ ├── field_builder.rs │ ├── grid_sphere_builder.rs │ ├── mod.rs │ └── regular_field_builder.rs │ ├── file_converters │ ├── interfaces.rs │ └── mod.rs │ ├── game.rs │ ├── group.rs │ ├── history │ ├── manager.rs │ ├── mod.rs │ └── models.rs │ ├── ko_guard.rs │ ├── lib.rs │ ├── point.rs │ ├── state.rs │ └── tests │ ├── fixtures │ ├── game.rs │ └── mod.rs │ ├── mod.rs │ ├── test_cubic_sphere_builder.rs │ ├── test_grid_sphere_builder.rs │ ├── test_groups_merge.rs │ ├── test_ko_rule.rs │ ├── test_regular_field_builder.rs │ ├── test_stone_remover.rs │ └── test_undo.rs ├── server ├── Cargo.lock ├── Cargo.toml ├── libs │ ├── entity │ │ ├── Cargo.toml │ │ └── src │ │ │ ├── games.rs │ │ │ ├── histories.rs │ │ │ ├── history.rs │ │ │ ├── history_records.rs │ │ │ ├── lib.rs │ │ │ ├── rooms.rs │ │ │ └── users.rs │ └── migration │ │ ├── Cargo.lock │ │ ├── Cargo.toml │ │ ├── README.md │ │ └── src │ │ ├── lib.rs │ │ ├── m20220101_000001_create_table.rs │ │ ├── m20230103_213731_games.rs │ │ ├── m20230104_155530_rooms.rs │ │ ├── m20230106_155413_histories.rs │ │ ├── m20230108_204629_history_records.rs │ │ └── main.rs └── src │ ├── app.rs │ ├── apps │ ├── games │ │ ├── mod.rs │ │ ├── repositories.rs │ │ ├── routers.rs │ │ ├── schemas.rs │ │ └── services.rs │ ├── histories │ │ ├── mod.rs │ │ ├── repositories.rs │ │ └── schemas.rs │ ├── mod.rs │ ├── rooms │ │ ├── mod.rs │ │ ├── repositories.rs │ │ ├── routers.rs │ │ └── services.rs │ └── users │ │ ├── mod.rs │ │ ├── repositories.rs │ │ ├── routers.rs │ │ ├── schemas.rs │ │ └── services.rs │ ├── common │ ├── aliases.rs │ ├── config.rs │ ├── db │ │ ├── connection.rs │ │ └── mod.rs │ ├── errors.rs │ ├── mod.rs │ └── routing │ │ ├── app_state.rs │ │ ├── auth.rs │ │ └── mod.rs │ ├── main.rs │ └── tests │ ├── fixtures │ ├── create_room.rs │ ├── create_user.rs │ ├── mod.rs │ └── test_tools.rs │ ├── mod.rs │ ├── test_games_crud.rs │ ├── test_rooms_crud.rs │ └── test_users_crud.rs └── wasm ├── Cargo.lock ├── Cargo.toml ├── rust-toolchain.toml └── src └── lib.rs /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | 8 | [*.{js,jsx,ts,tsx,html,css,json}] 9 | charset = utf-8 10 | indent_style = tab 11 | indent_size = 2 12 | trim_trailing_whitespace = true 13 | 14 | [*.rs] 15 | indent_size = 4 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | 3 | *.log 4 | 5 | frontend/node_modules/ 6 | frontend/public/build/ 7 | 8 | /.env 9 | 10 | .DS_Store 11 | 12 | /Dockerfile 13 | /docker-compose.yml 14 | /volumes/ 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.cargo.extraEnv": { 3 | "RUSTUP_TOOLCHAIN": "nightly" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Kirill Gimranov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test_gamelib: 2 | cd ./gamelib && cargo test && cd - 3 | 4 | compile_gamelib_to_wasm: 5 | cd ./wasm &&\ 6 | wasm-pack build --target web || true &&\ 7 | rm -r ../frontend/src/pkg || true &&\ 8 | mv pkg ../frontend/src &&\ 9 | cd - 10 | 11 | pnpm_i: 12 | cd ./frontend && pnpm i || true && cd - 13 | 14 | run_dev: 15 | cd ./frontend && pnpm run dev || true && cd - 16 | 17 | front_build: 18 | cd ./frontend && pnpm run build || true && cd - 19 | 20 | test_server: 21 | rm .env Dockerfile docker-compose.yml || true &&\ 22 | cp ./deploy/test/* . &&\ 23 | cp ./deploy/test/.env . &&\ 24 | docker compose down &&\ 25 | docker compose build &&\ 26 | docker compose run server 27 | 28 | run_prod: 29 | rm .env Dockerfile docker-compose.yml || true &&\ 30 | cp ./deploy/prod/* . &&\ 31 | cp ./deploy/prod/.env . &&\ 32 | mkdir -p ./volumes/postgres_data &&\ 33 | docker compose down --remove-orphans &&\ 34 | docker compose up -d --build --force-recreate 35 | 36 | run_server: 37 | rm .env Dockerfile docker-compose.yml || true &&\ 38 | cp ./deploy/dev/* . &&\ 39 | cp ./deploy/dev/.env . &&\ 40 | mkdir -p ./volumes/postgres_data &&\ 41 | docker compose down --remove-orphans &&\ 42 | docker compose up -d --build --force-recreate 43 | 44 | deploy_front: 45 | git branch -D gh-pages || true &&\ 46 | git checkout -b gh-pages &&\ 47 | make compile_gamelib_to_wasm &&\ 48 | make pnpm_i &&\ 49 | make front_build &&\ 50 | mv ./frontend/dist/* ./ &&\ 51 | mv ./frontend/404.html ./ &&\ 52 | rm -rf ./frontend &&\ 53 | find . -not \( -wholename './.git/*' -or -name 'index.html' -or -wholename './assets/*' -or -name '404.html' \) -delete || true &&\ 54 | git add --all &&\ 55 | git commit -m "lol" &&\ 56 | git push -f -u origin gh-pages &&\ 57 | git checkout - &&\ 58 | make pnpm_i &&\ 59 | make compile_gamelib_to_wasm 60 | 61 | -------------------------------------------------------------------------------- /deploy/dev/.env: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgres://dgs:lmao@db/dgs 2 | PORT=8000 3 | ALLOWED_ORIGINS = "http://localhost:3000" 4 | MODE=DEV 5 | -------------------------------------------------------------------------------- /deploy/dev/Dockerfile: -------------------------------------------------------------------------------- 1 | # Building 2 | FROM rustlang/rust:nightly AS builder 3 | 4 | WORKDIR /app 5 | 6 | # Creating dummy main.rs file 7 | RUN mkdir ./src 8 | RUN echo "fn main(){}" > ./src/main.rs 9 | 10 | # Removing local packages from Cargo.toml 11 | COPY ./server/Cargo.toml . 12 | RUN sed -i "s/^\(\(entity = {\)\|\(migration = {\)\|\(spherical_go_game_lib = {\)\\).*//g" ./Cargo.toml 13 | 14 | # Copying deps and downloading and pre-building them 15 | RUN cargo build 16 | 17 | # Copying all the logic 18 | COPY ../gamelib /gamelib 19 | COPY ./server . 20 | 21 | RUN cargo build 22 | 23 | # Running 24 | FROM debian:11-slim AS runtime 25 | COPY --from=builder /app/target/debug/server /dgs_server 26 | ENTRYPOINT [ "./dgs_server" ] 27 | -------------------------------------------------------------------------------- /deploy/dev/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | server: 5 | container_name: "dgs-server" 6 | build: . 7 | depends_on: 8 | - db 9 | env_file: 10 | - .env 11 | ports: 12 | - "${PORT}:${PORT}" 13 | 14 | db: 15 | container_name: "dgs-db" 16 | image: postgres:15-alpine 17 | environment: 18 | POSTGRES_DB: "dgs" 19 | POSTGRES_USER: "dgs" 20 | POSTGRES_PASSWORD: "lmao" 21 | ports: 22 | - "5432:5432" 23 | volumes: 24 | - ./volumes/postgres_data:/var/lib/postgresql/data/ 25 | -------------------------------------------------------------------------------- /deploy/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | include mime.types; 3 | types { 4 | application/wasm wasm; 5 | text/javascript js; 6 | } 7 | 8 | server_name dgs.dominux.site; 9 | 10 | root /var/www/DGS_frontend; 11 | 12 | index index.html; 13 | 14 | # frontend 15 | location / { 16 | try_files $uri $uri/ /index.html; 17 | } 18 | 19 | # game websocket 20 | location /api/games/ws/ { 21 | proxy_pass http://127.0.0.1:8100/games/ws/; 22 | proxy_http_version 1.1; 23 | proxy_set_header Upgrade $http_upgrade; 24 | proxy_set_header Connection "Upgrade"; 25 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 26 | } 27 | 28 | # backend 29 | location /api/ { 30 | proxy_pass http://127.0.0.1:8100/; 31 | proxy_set_header Host $host; 32 | proxy_redirect off; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /deploy/prod/.env: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgres://dgs:lmao@db/dgs 2 | PORT=8100 3 | ALLOWED_ORIGINS = "https://dominux.github.io" 4 | MODE=PROD 5 | -------------------------------------------------------------------------------- /deploy/prod/Dockerfile: -------------------------------------------------------------------------------- 1 | # Building 2 | FROM rustlang/rust:nightly AS builder 3 | 4 | WORKDIR /app 5 | 6 | # Creating dummy main.rs file 7 | RUN mkdir ./src 8 | RUN echo "fn main(){}" > ./src/main.rs 9 | 10 | # Removing local packages from Cargo.toml 11 | COPY ./server/Cargo.toml . 12 | RUN sed -i "s/^\(\(entity = {\)\|\(migration = {\)\|\(spherical_go_game_lib = {\)\\).*//g" ./Cargo.toml 13 | 14 | # Copying deps and downloading and pre-building them 15 | RUN cargo build --release 16 | 17 | # Copying all the logic 18 | COPY ../gamelib /gamelib 19 | COPY ./server . 20 | 21 | RUN cargo build --release 22 | 23 | # Running 24 | FROM debian:11-slim AS runtime 25 | COPY --from=builder /app/target/release/server /dgs_server 26 | ENTRYPOINT [ "./dgs_server" ] 27 | -------------------------------------------------------------------------------- /deploy/prod/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | server: 5 | container_name: "dgs-server" 6 | build: . 7 | depends_on: 8 | - db 9 | env_file: 10 | - .env 11 | ports: 12 | - "${PORT}:${PORT}" 13 | 14 | db: 15 | container_name: "dgs-db" 16 | image: postgres:15-alpine 17 | environment: 18 | POSTGRES_DB: "dgs" 19 | POSTGRES_USER: "dgs" 20 | POSTGRES_PASSWORD: "lmao" 21 | ports: 22 | - "5432:5432" 23 | volumes: 24 | - ./volumes/postgres_data:/var/lib/postgresql/data/ 25 | -------------------------------------------------------------------------------- /deploy/test/.env: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgres://dgs:lmao@db 2 | PORT=8000 3 | ALLOWED_ORIGINS = "http://localhost:3000" 4 | MODE=TEST 5 | 6 | -------------------------------------------------------------------------------- /deploy/test/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rustlang/rust:nightly 2 | 3 | WORKDIR /server 4 | 5 | # Creating dummy main.rs file 6 | RUN mkdir ./src 7 | RUN echo "fn main(){}" > ./src/main.rs 8 | 9 | # Removing local packages from Cargo.toml 10 | COPY ./server/Cargo.toml . 11 | RUN sed -i "s/^\(\(entity = {\)\|\(migration = {\)\|\(spherical_go_game_lib = {\)\\).*//g" ./Cargo.toml 12 | 13 | # Copying deps and downloading and pre-building them 14 | RUN cargo build --tests 15 | 16 | # Copying all the logic 17 | COPY ./server . 18 | 19 | COPY ../gamelib /gamelib 20 | -------------------------------------------------------------------------------- /deploy/test/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | server: 5 | container_name: "dgs-app-test" 6 | build: . 7 | command: cargo test -- --test-threads=1 8 | depends_on: 9 | - db 10 | env_file: 11 | - .env 12 | tty: true 13 | stdin_open: true 14 | 15 | db: 16 | container_name: "dgs-db-test" 17 | image: postgres:15-alpine 18 | environment: 19 | POSTGRES_DB: "dgs" 20 | POSTGRES_USER: "dgs" 21 | POSTGRES_PASSWORD: "lmao" 22 | ports: 23 | - "5432:5432" 24 | -------------------------------------------------------------------------------- /docs/images/grid_sphere.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dominux/DGS/a7fb63fc7d0a8a124a949dff74d7c12df1922d53/docs/images/grid_sphere.png -------------------------------------------------------------------------------- /docs/images/quad_sphere.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dominux/DGS/a7fb63fc7d0a8a124a949dff74d7c12df1922d53/docs/images/quad_sphere.png -------------------------------------------------------------------------------- /docs/images/uv_sphere_pole.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dominux/DGS/a7fb63fc7d0a8a124a949dff74d7c12df1922d53/docs/images/uv_sphere_pole.jpg -------------------------------------------------------------------------------- /docs/images/uv_sphere_side.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dominux/DGS/a7fb63fc7d0a8a124a949dff74d7c12df1922d53/docs/images/uv_sphere_side.jpg -------------------------------------------------------------------------------- /frontend/.env: -------------------------------------------------------------------------------- 1 | VITE_DEV_API = 'http://localhost:8000' 2 | VITE_PROD_API = 'https://dgs.dominux.site/api' 3 | 4 | VITE_DEV_WS = 'ws://localhost:8000' 5 | VITE_PROD_WS = 'wss://dgs.dominux.site/api' 6 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | ## Usage 2 | 3 | Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`. 4 | 5 | This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template. 6 | 7 | ```bash 8 | $ npm install # or pnpm install or yarn install 9 | ``` 10 | 11 | ### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs) 12 | 13 | ## Available Scripts 14 | 15 | In the project directory, you can run: 16 | 17 | ### `npm dev` or `npm start` 18 | 19 | Runs the app in the development mode.
20 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 21 | 22 | The page will reload if you make edits.
23 | 24 | ### `npm run build` 25 | 26 | Builds the app for production to the `dist` folder.
27 | It correctly bundles Solid in production mode and optimizes the build for the best performance. 28 | 29 | The build is minified and the filenames include the hashes.
30 | Your app is ready to be deployed! 31 | 32 | ## Deployment 33 | 34 | You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.) 35 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Dominux Go Server 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /frontend/libs/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | My modification of some package: 3 | https://www.npmjs.com/package/@solid-primitives/local-store 4 | */ 5 | 6 | import { createSignal, getListener } from 'solid-js' 7 | /** 8 | * Create a new storage primitive that can retain any data type 9 | * with an interface compatible with the Web Storage API. 10 | * 11 | * @param prefix - Prefix to wrap all stored values with. 12 | * @param storage - Storage engine to use for recording the value 13 | * @return Returns a state reader, setter and clear function 14 | * 15 | * @example 16 | * ```ts 17 | * const [value, setValue] = createStorage('app'); 18 | * setValue('My new value'); 19 | * console.log(value()); 20 | * ``` 21 | */ 22 | function createLocalStore(prefix = null, storage = localStorage) { 23 | const signals = new Map() 24 | const propPrefix = prefix === null ? '' : `${prefix}.` 25 | return [ 26 | new Proxy( 27 | {}, 28 | { 29 | get(_, key) { 30 | if (key === 'toJSON') { 31 | return storage.getAll ? () => storage.getAll() : () => storage 32 | } 33 | if (getListener()) { 34 | let node = signals.get(key) 35 | if (!node) { 36 | // @ts-ignore 37 | node = createSignal(undefined, { equals: false }) 38 | signals.set(key, node) 39 | } 40 | node[0]() 41 | } 42 | const value = storage.getItem(`${propPrefix}${key}`) 43 | return JSON.parse(value) 44 | }, 45 | } 46 | ), 47 | (key, value) => { 48 | storage.setItem(`${propPrefix}${key}`, JSON.stringify(value)) 49 | const node = signals.get(key) 50 | node && node[1]() 51 | }, 52 | (key) => { 53 | storage.removeItem(`${propPrefix}${key}`) 54 | const node = signals.get(key) 55 | node && node[1]() 56 | }, 57 | () => { 58 | storage.clear() 59 | signals.clear() 60 | }, 61 | ] 62 | } 63 | export default createLocalStore 64 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-template-solid", 3 | "version": "0.0.0", 4 | "description": "", 5 | "scripts": { 6 | "start": "vite", 7 | "dev": "vite", 8 | "build": "vite build", 9 | "serve": "vite preview" 10 | }, 11 | "license": "MIT", 12 | "devDependencies": { 13 | "@suid/vite-plugin": "^0.1.2", 14 | "typescript": "^4.7.4", 15 | "vite": "^3.2.5", 16 | "vite-plugin-solid": "^2.3.0" 17 | }, 18 | "dependencies": { 19 | "@solidjs/router": "^0.6.0", 20 | "@suid/icons-material": "^0.5.5", 21 | "@suid/material": "^0.9.1", 22 | "axios": "^1.2.2", 23 | "babylonjs": "^5.38.0", 24 | "babylonjs-gui": "^5.38.0", 25 | "solid-js": "^1.6.8" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/App.module.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .canvas { 6 | width: 100vw; 7 | height: 100vh; 8 | position: fixed; 9 | left: 0; 10 | } 11 | 12 | .mode_chooser { 13 | position: absolute; 14 | top: 50%; 15 | transform: translateY(-50%) translateX(-50%); 16 | left: 50%; 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from 'solid-js' 2 | import { useNavigate, useRoutes } from '@solidjs/router' 3 | import { Card, CardActions, IconButton } from '@suid/material' 4 | import { Home } from '@suid/icons-material' 5 | 6 | import styles from './App.module.css' 7 | import routes, { fullLocation } from './router' 8 | import { BASENAME } from './constants' 9 | 10 | const App: Component = () => { 11 | const Routes = useRoutes(routes, BASENAME) 12 | const navigate = useNavigate() 13 | 14 | return ( 15 |
16 | 26 | 27 | navigate(fullLocation('/'))} 30 | > 31 | 32 | 33 | 34 | 35 | 36 | 37 |
38 | ) 39 | } 40 | 41 | export default App 42 | -------------------------------------------------------------------------------- /frontend/src/api/api_client.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | import createLocalStore from '../../libs' 4 | import { API } from '../constants' 5 | 6 | /** 7 | * Api client to work with 8 | */ 9 | export class ApiClient { 10 | constructor(readonly apiURI: string) {} 11 | 12 | protected buildAbsolutePath(path: string): string { 13 | return `${this.apiURI}${path}` 14 | } 15 | 16 | get headers() { 17 | const [store, _] = createLocalStore() 18 | 19 | if (store.user) { 20 | return { AUTHORIZATION: `${store.user.id}:${store.user.secure_id}` } 21 | } 22 | } 23 | 24 | async get(path: string, params?: Object) { 25 | return await axios 26 | .get(this.buildAbsolutePath(path), { 27 | params: params, 28 | headers: this.headers, 29 | }) 30 | .catch((error) => { 31 | throw Error(error.response.data) 32 | }) 33 | } 34 | 35 | async post(path: string, data?: Object, params?: Object) { 36 | return await axios 37 | .post(this.buildAbsolutePath(path), data, { 38 | params: params, 39 | headers: this.headers, 40 | }) 41 | .catch((error) => { 42 | throw Error(error.response.data) 43 | }) 44 | } 45 | 46 | async patch(path: string, data?: Object, params?: Object) { 47 | return await axios 48 | .patch(this.buildAbsolutePath(path), data, { 49 | params: params, 50 | headers: this.headers, 51 | }) 52 | .catch((error) => { 53 | throw Error(error.response.data) 54 | }) 55 | } 56 | } 57 | 58 | const apiClient = new ApiClient(API) 59 | export default apiClient 60 | -------------------------------------------------------------------------------- /frontend/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import FieldType from '../logic/fields/enum' 2 | import apiClient from './api_client' 3 | import { 4 | FetchedUser, 5 | GameWithHistory, 6 | GameWithLink, 7 | Room, 8 | User, 9 | } from './models' 10 | 11 | async function register(username: string): Promise { 12 | const res = await apiClient.post('/users', { username: username }) 13 | return res.data 14 | } 15 | 16 | async function getUser(id: string): Promise { 17 | const res = await apiClient.get(`/users/${id}`) 18 | return res.data 19 | } 20 | 21 | async function createRoom(): Promise { 22 | const res = await apiClient.post('/rooms') 23 | return res.data 24 | } 25 | 26 | async function getRoom(id: string): Promise { 27 | const res = await apiClient.get(`/rooms/${id}`) 28 | return res.data 29 | } 30 | 31 | async function enterRoom(id: string): Promise { 32 | const res = await apiClient.patch(`/rooms/${id}/enter`) 33 | return res.data 34 | } 35 | 36 | async function startGame( 37 | room_id: string, 38 | field_type: FieldType, 39 | size: number 40 | ): Promise { 41 | const res = await apiClient.post('/games', { room_id, field_type, size }) 42 | return res.data 43 | } 44 | 45 | async function getGameWithHistory(game_id: string): Promise { 46 | const res = await apiClient.get(`/games/${game_id}`) 47 | return res.data 48 | } 49 | 50 | const api = { 51 | register, 52 | getUser, 53 | createRoom, 54 | getRoom, 55 | enterRoom, 56 | startGame, 57 | getGameWithHistory, 58 | } 59 | 60 | export default api 61 | -------------------------------------------------------------------------------- /frontend/src/api/models.ts: -------------------------------------------------------------------------------- 1 | import FieldType from '../logic/fields/enum' 2 | 3 | export type FetchedUser = { 4 | id: string 5 | username: string 6 | } 7 | 8 | export interface User extends FetchedUser { 9 | secure_id: string 10 | } 11 | 12 | export type Room = { 13 | id: string 14 | player1_id: string 15 | player2_id: string | null 16 | game_id: string | null 17 | } 18 | 19 | export type Game = { 20 | id: string 21 | is_ended: boolean 22 | } 23 | 24 | export type GameWithLink = { 25 | game: Game 26 | ws_link: string 27 | } 28 | 29 | export interface StoredGame extends Game { 30 | field_type: FieldType 31 | size: number 32 | } 33 | 34 | export type MoveSchema = { 35 | game_id: string 36 | point_id: number 37 | } 38 | 39 | export type MoveResult = { 40 | point_id: number 41 | died_stones_ids: Array 42 | } 43 | 44 | export type History = { 45 | id: string 46 | game_id: string 47 | size: number 48 | field_type: FieldType 49 | } 50 | 51 | export type HistoryRecord = { 52 | id: string 53 | history_id: string 54 | move_number: number 55 | point_id: number 56 | died_points_ids: Array 57 | } 58 | 59 | export type HistoryWithRecords = { 60 | history: History 61 | records: Array 62 | } 63 | 64 | export type GameWithHistory = { 65 | game: Game 66 | history: HistoryWithRecords 67 | } 68 | -------------------------------------------------------------------------------- /frontend/src/api/ws_client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MOVE_RESULT_CHECK_INTERVAL_MS, 3 | RECONNECT_TIMEOUT_MS, 4 | } from '../constants' 5 | import { User } from './models' 6 | 7 | export default class WSClient { 8 | private socket: WebSocket 9 | messages: MessageQueue 10 | 11 | constructor(addr: string, user: User) { 12 | this.messages = new MessageQueue() 13 | this.socket = new WebSocket(addr) 14 | 15 | this.socket.onopen = (_) => { 16 | this.socket.send(`${user.id}:${user.secure_id}`) 17 | } 18 | this.socket.onerror = (e) => { 19 | console.log(e) 20 | } 21 | this.socket.onclose = (e) => { 22 | console.log(e) 23 | this.reconnect(addr, user) 24 | } 25 | 26 | this.socket.onmessage = (e) => { 27 | this.messages.push(e.data) 28 | } 29 | } 30 | 31 | private reconnect(addr: string, user: User) { 32 | const job = setInterval(() => { 33 | console.log('trying to reconnect') 34 | this.socket = new WSClient(addr, user).socket 35 | clearInterval(job) 36 | }, RECONNECT_TIMEOUT_MS) 37 | } 38 | 39 | public sendMsg(msg: string) { 40 | this.socket.send(msg) 41 | } 42 | 43 | public async waitForMsg(): Promise { 44 | let msg = undefined 45 | while (msg === undefined) { 46 | await new Promise((resolve) => 47 | setTimeout(resolve, MOVE_RESULT_CHECK_INTERVAL_MS) 48 | ) 49 | 50 | msg = this.messages.pop() 51 | } 52 | 53 | return msg 54 | } 55 | } 56 | 57 | class MessageQueue { 58 | protected queue: Array = [] 59 | 60 | push(msg: T) { 61 | this.queue.push(msg) 62 | } 63 | 64 | pop(): T | undefined { 65 | return this.queue.pop() 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /frontend/src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dominux/DGS/a7fb63fc7d0a8a124a949dff74d7c12df1922d53/frontend/src/assets/favicon.ico -------------------------------------------------------------------------------- /frontend/src/auth.ts: -------------------------------------------------------------------------------- 1 | import { useLocation, useNavigate } from '@solidjs/router' 2 | 3 | import createLocalStore from '../libs' 4 | import { fullLocation } from './router' 5 | 6 | export function checkAuth() { 7 | const [store, setStore] = createLocalStore() 8 | const navigate = useNavigate() 9 | const location = useLocation() 10 | 11 | if (!store.user) { 12 | setStore('redirect', location.pathname) 13 | 14 | navigate(fullLocation('/login')) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/components/ModeChooser.tsx: -------------------------------------------------------------------------------- 1 | import { Component, For } from 'solid-js' 2 | import { Button, Card, CardActions, Stack, Typography } from '@suid/material' 3 | 4 | import styles from '../App.module.css' 5 | 6 | export type ModeCardProps = { 7 | label: string 8 | onClick: Function 9 | } 10 | 11 | const ModeCard: Component = (props) => { 12 | return ( 13 | 14 | 15 | 18 | 19 | 20 | ) 21 | } 22 | 23 | export type ModeChooserProps = { 24 | modes: Array 25 | } 26 | 27 | const ModeChooser: Component = (props) => { 28 | return ( 29 |
30 | 31 | 32 | {(mode) => ( 33 | 34 | )} 35 | 36 | 37 |
38 | ) 39 | } 40 | 41 | export default ModeChooser 42 | -------------------------------------------------------------------------------- /frontend/src/components/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dominux/DGS/a7fb63fc7d0a8a124a949dff74d7c12df1922d53/frontend/src/components/index.ts -------------------------------------------------------------------------------- /frontend/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const MIN_GRIDSIZE = 5 2 | export const MAX_GRIDSIZE = 255 3 | export const INPUTFIELD_LENGTH_IN_SYMBOLS = 12 4 | 5 | export const RECONNECT_TIMEOUT_MS = 1000 6 | 7 | export const ERROR_MSG_TIMEOUT = 3 8 | 9 | export const SPHERE_RADIUS = 0.5 10 | export const REGULAR_FIELD_PADDING_TO_CELL_FRACTION = 0.5 11 | export const ACCENT_COLOR = '#e3f2fd' 12 | export const ALERT_COLOR = '#fa9393' 13 | 14 | export const FIELD_Y = 1 15 | 16 | export const API = import.meta.env.DEV 17 | ? import.meta.env.VITE_DEV_API 18 | : import.meta.env.VITE_PROD_API 19 | 20 | export const WS_API = import.meta.env.DEV 21 | ? import.meta.env.VITE_DEV_WS 22 | : import.meta.env.VITE_PROD_WS 23 | 24 | export const BASENAME = '' 25 | 26 | export const MOVE_RESULT_CHECK_INTERVAL_MS = 200 27 | 28 | export type EnvTexture = { 29 | url: string 30 | res: number 31 | } 32 | 33 | export const defaultEnvTextures = { 34 | 'Rainforest trail': { 35 | '1K': { 36 | url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/1k/rainforest_trail_1k.hdr', 37 | res: 512, 38 | }, 39 | '2K': { 40 | url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/2k/rainforest_trail_2k.hdr', 41 | res: 1024, 42 | }, 43 | '4K': { 44 | url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/4k/rainforest_trail_4k.hdr', 45 | res: 2048, 46 | }, 47 | '8K': { 48 | url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/8k/rainforest_trail_8k.hdr', 49 | res: 4096, 50 | }, 51 | }, 52 | 'Kiara Dawn': { 53 | '1K': { 54 | url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/1k/kiara_1_dawn_1k.hdr', 55 | res: 512, 56 | }, 57 | '2K': { 58 | url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/2k/kiara_1_dawn_2k.hdr', 59 | res: 1024, 60 | }, 61 | '4K': { 62 | url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/4k/kiara_1_dawn_4k.hdr', 63 | res: 2048, 64 | }, 65 | '8K': { 66 | url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/8k/kiara_1_dawn_8k.hdr', 67 | res: 4096, 68 | }, 69 | }, 70 | 'Chinese garden': { 71 | '1K': { 72 | url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/1k/chinese_garden_1k.hdr', 73 | res: 512, 74 | }, 75 | '2K': { 76 | url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/2k/chinese_garden_2k.hdr', 77 | res: 1024, 78 | }, 79 | '4K': { 80 | url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/4k/chinese_garden_4k.hdr', 81 | res: 2048, 82 | }, 83 | '8K': { 84 | url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/8k/chinese_garden_8k.hdr', 85 | res: 4096, 86 | }, 87 | }, 88 | 'Misty pines': { 89 | '1K': { 90 | url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/1k/misty_pines_1k.hdr', 91 | res: 512, 92 | }, 93 | '2K': { 94 | url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/2k/misty_pines_2k.hdr', 95 | res: 1024, 96 | }, 97 | '4K': { 98 | url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/4k/misty_pines_4k.hdr', 99 | res: 2048, 100 | }, 101 | '8K': { 102 | url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/8k/misty_pines_8k.hdr', 103 | res: 4096, 104 | }, 105 | }, 106 | 'Phalzer forest': { 107 | '1K': { 108 | url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/1k/phalzer_forest_01_1k.hdr', 109 | res: 512, 110 | }, 111 | '2K': { 112 | url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/2k/phalzer_forest_01_2k.hdr', 113 | res: 1024, 114 | }, 115 | '4K': { 116 | url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/4k/phalzer_forest_01_4k.hdr', 117 | res: 2048, 118 | }, 119 | '8K': { 120 | url: 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/8k/phalzer_forest_01_8k.hdr', 121 | res: 4096, 122 | }, 123 | }, 124 | } 125 | export const defaultTextureName = Object.keys(defaultEnvTextures)[0] 126 | export const defaultResolution = '1K' 127 | 128 | export const GRID_MATERIAL = 129 | 'https://raw.githubusercontent.com/Dominux/spherical-go/main/frontend/src/assets/gridMaterial.json' 130 | -------------------------------------------------------------------------------- /frontend/src/gui/alert.ts: -------------------------------------------------------------------------------- 1 | import * as GUI from 'babylonjs-gui' 2 | 3 | import { ALERT_COLOR, ERROR_MSG_TIMEOUT } from '../constants' 4 | 5 | export default class AlertComponent { 6 | readonly container: GUI.Container 7 | private textBlock: GUI.TextBlock 8 | 9 | constructor(advancedTexture: GUI.AdvancedDynamicTexture) { 10 | // Container 11 | this.container = new GUI.Container() 12 | this.container.width = '300px' 13 | this.container.height = '80px' 14 | this.container.background = ALERT_COLOR 15 | this.container.verticalAlignment = GUI.Control.VERTICAL_ALIGNMENT_TOP 16 | this.container.top = 10 17 | this.container.isVisible = false 18 | 19 | // Text 20 | this.textBlock = new GUI.TextBlock('alert', '') 21 | this.textBlock.color = 'white' 22 | 23 | // add controls 24 | this.container.addControl(this.textBlock) 25 | advancedTexture.addControl(this.container) 26 | } 27 | 28 | set errorMsg(newVal: string) { 29 | this.textBlock.text = newVal 30 | this.container.isVisible = true 31 | 32 | setTimeout( 33 | () => (this.container.isVisible = false), 34 | ERROR_MSG_TIMEOUT * 1000 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/gui/game_creation_form.ts: -------------------------------------------------------------------------------- 1 | import * as GUI from 'babylonjs-gui' 2 | 3 | import { 4 | ACCENT_COLOR, 5 | ALERT_COLOR, 6 | MAX_GRIDSIZE, 7 | MIN_GRIDSIZE, 8 | } from '../constants' 9 | import FieldType from '../logic/fields/enum' 10 | import GUIComponent from './gui_components' 11 | import SelectComponent from './select_menu' 12 | import TextComponent from './text' 13 | 14 | const ODD_RANGE_STR = [MIN_GRIDSIZE, MIN_GRIDSIZE + 2, MIN_GRIDSIZE + 4].join( 15 | ',' 16 | ) 17 | 18 | export default class GameCreationFormGUI { 19 | protected fieldTypeSelect: GUIComponent 20 | protected gridSizeInput: GUIComponent 21 | protected virtualKeyboard: GUIComponent 22 | protected gridSizeHint: GUIComponent 23 | protected startButton: GUIComponent 24 | protected stackMesh: BABYLON.Mesh 25 | 26 | protected _isValid: boolean = true 27 | 28 | protected get isValid() { 29 | return this._isValid 30 | } 31 | protected set isValid(newVal: boolean) { 32 | this._isValid = newVal 33 | 34 | // Enabling/disabling start button 35 | this.startButton.component.isEnabled = this._isValid 36 | } 37 | 38 | protected get fieldType(): FieldType { 39 | return this.fieldTypeSelect.component.selectedValue 40 | } 41 | protected get gridSize(): number { 42 | return parseInt(this.gridSizeInput.component.text) || 0 43 | } 44 | 45 | protected set errorMessage(newVal: string) { 46 | if (newVal) { 47 | this.gridSizeHint.component.text = newVal 48 | this.gridSizeHint.component.container.isVisible = true 49 | } else { 50 | this.gridSizeHint.component.container.isVisible = false 51 | } 52 | } 53 | 54 | constructor( 55 | readonly camera: BABYLON.Camera, 56 | onChangeFieldType: Function, 57 | onChangeGridSize: Function, 58 | defaultFieldType: FieldType, 59 | defaultGridSize: number, 60 | onSumbit: Function 61 | ) { 62 | const innerOnChangeFieldType = (selectedValue: FieldType) => { 63 | this.validateGridSize(this.gridSize) 64 | onChangeFieldType(selectedValue) 65 | } 66 | 67 | const stack = this.createStackPanel() 68 | this.stackMesh = stack.advancedTextureMesh 69 | 70 | this.fieldTypeSelect = this.createFieldTypeSelect(stack, defaultFieldType) 71 | this.fieldTypeSelect.component.onSelectRegister(innerOnChangeFieldType) 72 | 73 | this.gridSizeInput = this.createGridSizeInput( 74 | defaultGridSize, 75 | stack, 76 | onChangeGridSize 77 | ) 78 | this.gridSizeHint = this.createGridSizeHint(stack) 79 | this.virtualKeyboard = this.createVirtualKeyBoard( 80 | stack, 81 | this.gridSizeInput.component 82 | ) 83 | 84 | this.startButton = this.createStartButton(stack, onSumbit) 85 | 86 | this.validateGridSize(parseInt(this.gridSizeInput.component.text)) 87 | 88 | this.hide() 89 | } 90 | 91 | createStackPanel() { 92 | const stack = new GUI.StackPanel('game-creation-form') 93 | stack.spacing = 10 94 | stack.isHitTestVisible = false 95 | 96 | const plane = BABYLON.MeshBuilder.CreatePlane('field-type-plane', { 97 | size: 1, 98 | }) 99 | // plane.parent = this.camera 100 | plane.position.z = -0.1 101 | plane.position.y = 1.2 102 | plane.position.x = 0.8 103 | 104 | const advancedTexture = GUI.AdvancedDynamicTexture.CreateForMesh(plane) 105 | advancedTexture.addControl(stack) 106 | 107 | return new GUIComponent(stack, plane) 108 | } 109 | 110 | createFieldTypeSelect( 111 | stack: GUIComponent, 112 | defaultFieldType?: FieldType 113 | ): GUIComponent { 114 | const fieldTypeSelect = new SelectComponent( 115 | '80px', 116 | '360px', 117 | defaultFieldType 118 | ) 119 | 120 | fieldTypeSelect.shadowOffsetX = 10 121 | fieldTypeSelect.shadowOffsetY = 10 122 | 123 | Object.values(FieldType).forEach((fieldType) => 124 | fieldTypeSelect.addOption(fieldType, fieldType) 125 | ) 126 | 127 | stack.component.addControl(fieldTypeSelect.container) 128 | 129 | return new GUIComponent(fieldTypeSelect, stack.advancedTextureMesh) 130 | } 131 | 132 | createGridSizeInput( 133 | defaultGridSize: number = 0, 134 | stack: GUIComponent, 135 | onChangeGridSize: Function 136 | ): GUIComponent { 137 | const input = new GUI.InputText('grid-size', String(defaultGridSize)) 138 | input.height = '80px' 139 | input.width = '360px' 140 | input.fontSize = 50 141 | input.color = 'black' 142 | input.background = 'white' 143 | input.focusedBackground = ACCENT_COLOR 144 | input.disableMobilePrompt = true 145 | 146 | // Enabling only digits 147 | input.onBeforeKeyAddObservable.add((input) => { 148 | let key = input.currentKey 149 | 150 | input.addKey = key >= '0' && key <= '9' 151 | }) 152 | 153 | // Reactivity 154 | input.onTextChangedObservable.add((input) => { 155 | const value = parseInt(input.text) || 0 156 | onChangeGridSize(value) 157 | this.validateGridSize(value) 158 | }) 159 | 160 | stack.component.addControl(input) 161 | 162 | return new GUIComponent(input, stack.advancedTextureMesh) 163 | } 164 | 165 | createGridSizeHint(stack: GUIComponent) { 166 | // Container 167 | const text = new TextComponent('50px', '500px', 'white', ALERT_COLOR, 34) 168 | 169 | stack.component.addControl(text.container) 170 | 171 | return new GUIComponent(text, stack.advancedTextureMesh) 172 | } 173 | 174 | validateGridSize(value: number): boolean { 175 | if (value < MIN_GRIDSIZE) { 176 | this.errorMessage = `Number must be >= ${MIN_GRIDSIZE}` 177 | this.isValid = false 178 | return false 179 | } 180 | if (value > MAX_GRIDSIZE) { 181 | this.errorMessage = `Number must be <= ${MAX_GRIDSIZE}` 182 | this.isValid = false 183 | return false 184 | } 185 | if (this.fieldType === FieldType.GridSphere && value % 2 == 0) { 186 | this.errorMessage = `Number must be odd (${ODD_RANGE_STR}, ...)` 187 | this.isValid = false 188 | return false 189 | } 190 | 191 | this.errorMessage = '' 192 | this.isValid = true 193 | return true 194 | } 195 | 196 | createVirtualKeyBoard( 197 | stack: GUIComponent, 198 | gridSizeInput: GUI.InputText 199 | ): GUIComponent { 200 | const keyboard = GUI.VirtualKeyboard.CreateDefaultLayout() 201 | keyboard.verticalAlignment = GUI.Control.VERTICAL_ALIGNMENT_TOP 202 | keyboard.fontSize = 24 203 | 204 | keyboard.connect(gridSizeInput) 205 | 206 | stack.component.addControl(keyboard) 207 | 208 | return new GUIComponent(keyboard, stack.advancedTextureMesh) 209 | } 210 | 211 | createStartButton( 212 | stack: GUIComponent, 213 | onSumbit: Function 214 | ): GUIComponent { 215 | const button = GUI.Button.CreateSimpleButton( 216 | 'start-button', 217 | ' Start game ' 218 | ) 219 | button.height = '80px' 220 | button.width = '360px' 221 | button.fontSize = 44 222 | button.background = ACCENT_COLOR 223 | 224 | button.onPointerUpObservable.add(async () => { 225 | this.delete() 226 | 227 | await onSumbit() 228 | }) 229 | 230 | stack.component.addControl(button) 231 | 232 | return new GUIComponent(button, stack.advancedTextureMesh) 233 | } 234 | 235 | show() { 236 | this.stackMesh.isVisible = true 237 | } 238 | hide() { 239 | this.stackMesh.isVisible = false 240 | } 241 | 242 | delete() { 243 | this.stackMesh.dispose() 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /frontend/src/gui/gui_components.ts: -------------------------------------------------------------------------------- 1 | import * as GUI from 'babylonjs-gui' 2 | 3 | export default class GUIComponent { 4 | constructor( 5 | readonly component: Type, 6 | readonly advancedTextureMesh: BABYLON.Mesh 7 | ) {} 8 | 9 | initialize() { 10 | this.advancedTextureMesh.isVisible = true 11 | const advancedTexture = GUI.AdvancedDynamicTexture.CreateForMesh( 12 | this.advancedTextureMesh 13 | ) 14 | advancedTexture.addControl( 15 | this.component.container ? this.component.container : this.component 16 | ) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/gui/index.ts: -------------------------------------------------------------------------------- 1 | import * as GUI from 'babylonjs-gui' 2 | 3 | import FieldType from '../logic/fields/enum' 4 | import AlertComponent from './alert' 5 | import GameCreationFormGUI from './game_creation_form' 6 | import PlayerBarGUI from './player_bar' 7 | 8 | export default class GameGUI { 9 | protected gameCreationForm: GameCreationFormGUI 10 | protected playerBar: PlayerBarGUI 11 | protected globalTexture: GUI.AdvancedDynamicTexture 12 | 13 | protected alert: AlertComponent 14 | 15 | set isUndoButtonHidden(newVal: boolean) { 16 | this.playerBar.isUndoButtonHidden = newVal 17 | } 18 | 19 | constructor( 20 | readonly camera: BABYLON.Camera, 21 | onChangeFieldType: Function, 22 | onChangeGridSize: Function, 23 | defaultFieldType: FieldType, 24 | defaultGridSize: number, 25 | onSubmit: Function, 26 | onUndo: Function 27 | ) { 28 | this.globalTexture = GUI.AdvancedDynamicTexture.CreateFullscreenUI('gui') 29 | 30 | this.gameCreationForm = new GameCreationFormGUI( 31 | camera, 32 | onChangeFieldType, 33 | onChangeGridSize, 34 | defaultFieldType, 35 | defaultGridSize, 36 | onSubmit 37 | ) 38 | 39 | this.playerBar = new PlayerBarGUI(this.camera, onUndo) 40 | 41 | this.alert = new AlertComponent(this.globalTexture) 42 | } 43 | 44 | show() { 45 | this.gameCreationForm.show() 46 | } 47 | hide() { 48 | this.gameCreationForm.hide() 49 | } 50 | 51 | onStart() { 52 | this.playerBar.initialize() 53 | } 54 | 55 | onError(errorMsg: string) { 56 | this.alert.errorMsg = errorMsg 57 | } 58 | 59 | setBlackScore(value: number) { 60 | this.playerBar.setBlackScore(value) 61 | } 62 | 63 | setWhiteScore(value: number) { 64 | this.playerBar.setWhiteScore(value) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /frontend/src/gui/player_bar.ts: -------------------------------------------------------------------------------- 1 | import * as GUI from 'babylonjs-gui' 2 | 3 | import GUIComponent from './gui_components' 4 | import TextComponent from './text' 5 | 6 | export default class PlayerBarGUI { 7 | protected stack: GUIComponent 8 | protected blackScore: GUIComponent 9 | protected whiteScore: GUIComponent 10 | protected undoButton: GUIComponent 11 | 12 | set isUndoButtonHidden(newVal: boolean) { 13 | this.undoButton.component.isVisible = !newVal 14 | } 15 | 16 | constructor(readonly camera: BABYLON.Camera, onUndo: Function) { 17 | this.stack = this.createStackPanel() 18 | this.blackScore = this.createScoreComponent('black') 19 | this.whiteScore = this.createScoreComponent('white') 20 | this.undoButton = this.createUndoButton(onUndo) 21 | } 22 | 23 | createStackPanel() { 24 | const stack = new GUI.StackPanel('player-bar') 25 | stack.spacing = 10 26 | stack.isHitTestVisible = false 27 | 28 | const plane = BABYLON.MeshBuilder.CreatePlane('stack-plane', { 29 | size: 0.5, 30 | }) 31 | // plane.parent = this.camera 32 | plane.position.z = -0.1 33 | plane.position.y = 1.3 34 | plane.position.x = 0.8 35 | plane.isVisible = false 36 | 37 | const advancedTexture = GUI.AdvancedDynamicTexture.CreateForMesh(plane) 38 | advancedTexture.addControl(stack) 39 | 40 | return new GUIComponent(stack, plane) 41 | } 42 | 43 | createScoreComponent(color: string): GUIComponent { 44 | const background = color 45 | color = color === 'black' ? 'white' : 'black' 46 | const scoreBlock = new TextComponent( 47 | '200px', 48 | '760px', 49 | color, 50 | background, 51 | 120 52 | ) 53 | scoreBlock.container.isVisible = false 54 | 55 | this.stack.component.addControl(scoreBlock.container) 56 | 57 | return new GUIComponent(scoreBlock, this.stack.advancedTextureMesh) 58 | } 59 | 60 | createUndoButton(onUndo: Function): GUIComponent { 61 | const button = GUI.Button.CreateSimpleButton('undo-button', 'Undo move') 62 | button.background = 'white' 63 | button.height = '160px' 64 | button.width = '450px' 65 | button.fontSize = 60 66 | button.isVisible = false 67 | 68 | button.onPointerClickObservable.add(() => { 69 | onUndo() 70 | }) 71 | 72 | this.stack.component.addControl(button) 73 | 74 | return new GUIComponent(button, this.stack.advancedTextureMesh) 75 | } 76 | 77 | initialize() { 78 | this.stack.advancedTextureMesh.isVisible = true 79 | this.blackScore.component.container.isVisible = true 80 | this.whiteScore.component.container.isVisible = true 81 | this.undoButton.component.isVisible = true 82 | this.isUndoButtonHidden = true 83 | } 84 | 85 | setBlackScore(value: number) { 86 | this.blackScore.component.text = String(value) 87 | } 88 | 89 | setWhiteScore(value: number) { 90 | this.whiteScore.component.text = String(value) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /frontend/src/gui/select_menu.ts: -------------------------------------------------------------------------------- 1 | import * as GUI from 'babylonjs-gui' 2 | 3 | import { ACCENT_COLOR } from '../constants' 4 | 5 | // https://github.com/BabylonJS/Babylon.js/issues/3910 6 | export default class SelectComponent { 7 | readonly container: GUI.Container 8 | readonly options: GUI.StackPanel 9 | readonly button: GUI.Button 10 | public selected? 11 | public selectedValue? 12 | protected stackSize = 0 13 | protected onSelect?: Function 14 | 15 | constructor( 16 | protected height: string, 17 | protected width: string, 18 | defaultValue: any, 19 | readonly color: string = 'black', 20 | readonly fontSize: number = 32, 21 | readonly background: string = 'white', 22 | readonly accentColor: string = ACCENT_COLOR 23 | ) { 24 | // Container 25 | this.container = new GUI.Container() 26 | this.container.height = this.height 27 | this.container.width = this.width 28 | this.container.verticalAlignment = GUI.Control.VERTICAL_ALIGNMENT_TOP 29 | this.container.isHitTestVisible = false 30 | 31 | // Primary button 32 | this.button = GUI.Button.CreateSimpleButton( 33 | null, 34 | defaultValue || 'Please Select' 35 | ) 36 | this.button.height = this.height 37 | this.button.background = this.background 38 | this.button.color = this.color 39 | this.button.fontSize = fontSize 40 | this.button.verticalAlignment = GUI.Control.VERTICAL_ALIGNMENT_TOP 41 | 42 | // Options panel 43 | this.options = new GUI.StackPanel() 44 | this.options.verticalAlignment = GUI.Control.VERTICAL_ALIGNMENT_TOP 45 | this.options.top = this.height 46 | this.options.isVisible = false 47 | this.options.isVertical = true 48 | 49 | this.button.onPointerUpObservable.add(() => { 50 | this.options.isVisible = !this.options.isVisible 51 | 52 | // Adjusting container size 53 | this.options.isVisible ? this.onOpenMenu() : this.onCloseMenu() 54 | }) 55 | 56 | // add controls 57 | this.container.addControl(this.button) 58 | this.container.addControl(this.options) 59 | 60 | // Selection 61 | this.selected = null 62 | this.selectedValue = defaultValue 63 | } 64 | 65 | addOption(value, text, color?, background?) { 66 | const button = GUI.Button.CreateSimpleButton(text, text) 67 | 68 | button.height = this.height 69 | button.paddingTop = '-1px' 70 | button.background = background || this.accentColor 71 | button.color = color || this.color 72 | button.fontSize = this.fontSize 73 | 74 | button.onPointerUpObservable.add(() => { 75 | this.options.isVisible = false 76 | this.button.children[0].text = text 77 | this.selected = button 78 | this.selectedValue = value 79 | 80 | this.onCloseMenu() 81 | 82 | if (this.onSelect !== undefined) this.onSelect(this.selectedValue) 83 | }) 84 | 85 | this.stackSize += parseFloat(this.height) 86 | 87 | this.options.addControl(button) 88 | } 89 | 90 | onSelectRegister(func: Function) { 91 | this.onSelect = func 92 | } 93 | 94 | onOpenMenu() { 95 | this.container.height = `${ 96 | parseFloat(this.container.height) + this.stackSize 97 | }px` 98 | } 99 | onCloseMenu() { 100 | this.container.height = `${ 101 | parseFloat(this.container.height) - this.stackSize 102 | }px` 103 | } 104 | 105 | ///////////////////////////////////////// 106 | //// Descriptors 107 | ///////////////////////////////////////// 108 | 109 | get top() { 110 | return this.container.top 111 | } 112 | set top(value) { 113 | this.container.top = value 114 | } 115 | 116 | get left() { 117 | return this.container.left 118 | } 119 | set left(value) { 120 | this.container.left = value 121 | } 122 | 123 | get shadowOffsetX() { 124 | return this.container.shadowOffsetX 125 | } 126 | set shadowOffsetX(value) { 127 | this.container.shadowOffsetX = value 128 | } 129 | 130 | get shadowOffsetY() { 131 | return this.container.shadowOffsetY 132 | } 133 | set shadowOffsetY(value) { 134 | this.container.shadowOffsetY = value 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /frontend/src/gui/text.ts: -------------------------------------------------------------------------------- 1 | import * as GUI from 'babylonjs-gui' 2 | 3 | export default class TextComponent { 4 | readonly container: GUI.Container 5 | private textBlock: GUI.TextBlock 6 | 7 | constructor( 8 | protected height: string, 9 | protected width: string, 10 | readonly color: string, 11 | readonly background: string, 12 | fontSize: number = 32 13 | ) { 14 | // Container 15 | this.container = new GUI.Container() 16 | this.container.width = this.width 17 | this.container.height = this.height 18 | this.container.isHitTestVisible = false 19 | this.container.background = this.background 20 | 21 | // Text 22 | this.textBlock = new GUI.TextBlock() 23 | this.textBlock.fontSize = fontSize 24 | this.textBlock.color = this.color 25 | 26 | // add controls 27 | this.container.addControl(this.textBlock) 28 | } 29 | 30 | get text() { 31 | return this.textBlock.text 32 | } 33 | set text(value: string) { 34 | this.textBlock.text = value 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | /* @refresh reload */ 2 | import { render } from 'solid-js/web' 3 | import { Router } from '@solidjs/router' 4 | 5 | import './index.css' 6 | import App from './App' 7 | 8 | render( 9 | () => ( 10 | 11 | 12 | 13 | ), 14 | document.getElementById('root') as HTMLElement 15 | ) 16 | -------------------------------------------------------------------------------- /frontend/src/logic/fields/enum.ts: -------------------------------------------------------------------------------- 1 | import GridSphere from './grid_sphere' 2 | import { Field } from './interface' 3 | import RegularField from './regular' 4 | 5 | enum FieldType { 6 | GridSphere = 'GridSphere', 7 | Regular = 'Regular', 8 | } 9 | 10 | export default FieldType 11 | 12 | export function getFieldFromType(fieldType: FieldType): Field { 13 | switch (fieldType) { 14 | case FieldType.Regular: 15 | return RegularField 16 | break 17 | 18 | default: 19 | return GridSphere 20 | break 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/logic/fields/interface.ts: -------------------------------------------------------------------------------- 1 | import { MoveResult } from '../../api/models' 2 | import Game from '../games/game' 3 | import StoneManager, { CreateStoneScheme } from '../stone_manager' 4 | 5 | export interface Field { 6 | game: Game | undefined 7 | 8 | init(scene: BABYLON.Scene, gridSize: number): Promise 9 | 10 | start( 11 | game: Game, 12 | onEndMove: Function, 13 | onDeath: Function, 14 | onError: Function 15 | ): Promise 16 | 17 | get playerTurn(): string 18 | get blackScore(): number 19 | get whiteScore(): number 20 | 21 | makeMoveProgramatically(moveResult: MoveResult): void 22 | putStoneProgramatically( 23 | moveResult: MoveResult, 24 | color: 'Black' | 'White' 25 | ): void 26 | undoMove(): void 27 | getCreateStoneSchema(id: number, color: BABYLON.Color3): CreateStoneScheme 28 | delete(): void 29 | } 30 | 31 | export function returnStonesBack( 32 | stoneManager: StoneManager, 33 | stones: Array 34 | ): void { 35 | stoneManager.clear() 36 | 37 | for (const stone of stones) { 38 | stoneManager.create(stone) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/logic/game_manager.ts: -------------------------------------------------------------------------------- 1 | import createLocalStore from '../../libs' 2 | import { EnvTexture } from '../constants' 3 | import GameGUI from '../gui' 4 | import FieldType, { getFieldFromType } from './fields/enum' 5 | import { Field } from './fields/interface' 6 | import Game from './games/game' 7 | import Scene from './scene' 8 | 9 | export default class GameManager { 10 | readonly _scene: Scene 11 | readonly _GUI: GameGUI 12 | 13 | fieldType: FieldType = FieldType.GridSphere 14 | gridSize: number = 9 15 | 16 | protected field?: Field 17 | protected isStarted: boolean = false 18 | protected isMultiplayer: boolean 19 | 20 | constructor( 21 | canvas: HTMLCanvasElement, 22 | sphereRadius: number, 23 | readonly gameKlass: { new (): Game } 24 | ) { 25 | this._scene = new Scene(canvas, sphereRadius) 26 | 27 | const onChangeFieldType = async (newVal: FieldType) => { 28 | this.fieldType = newVal 29 | await this.setField() 30 | } 31 | const onChangeGridSize = async (newVal: number) => { 32 | this.gridSize = newVal 33 | await this.setField() 34 | } 35 | const onSumbit = async () => await this.gameStart() 36 | const onUndo = () => this.undo() 37 | 38 | this._GUI = new GameGUI( 39 | this._scene._camera, 40 | onChangeFieldType, 41 | onChangeGridSize, 42 | this.fieldType, 43 | this.gridSize, 44 | onSumbit, 45 | onUndo 46 | ) 47 | } 48 | 49 | showGUI() { 50 | this._GUI.show() 51 | } 52 | hideGUI() { 53 | this._GUI.hide() 54 | } 55 | 56 | async setField() { 57 | const klass = getFieldFromType(this.fieldType) 58 | 59 | // Deleting old field 60 | this.field?.delete() 61 | 62 | // Creating a new one 63 | this.field = await klass.init(this._scene._scene, this.gridSize) 64 | } 65 | 66 | async gameStart() { 67 | const [store, _setStore] = createLocalStore() 68 | 69 | this.isStarted = true 70 | 71 | // Starting game 72 | const game = new this.gameKlass( 73 | this.fieldType, 74 | this.gridSize, 75 | (blackStones, whiteStones) => 76 | this.onRecreateGame(blackStones, whiteStones) 77 | ) 78 | 79 | await this.field?.start( 80 | game, 81 | () => this.onEndMove(), 82 | () => this.onDeath(), 83 | (errorMsg: string) => this._GUI.onError(errorMsg) 84 | ) 85 | 86 | this.isMultiplayer = game.wsClient 87 | 88 | this._GUI.onStart() 89 | 90 | // Setting initial score 91 | this.setBlackScore(game.blackScore) 92 | this.setWhiteScore(game.whiteScore) 93 | 94 | // if it's multiplayer and it's not player's turn 95 | if ( 96 | this.isMultiplayer && 97 | ((store.room.player2_id === store.user.id && 98 | store.game?.history?.records.length % 2 === 0) || 99 | (store.room.player1_id === store.user.id && 100 | store.game?.history?.records.length % 2 === 1)) 101 | ) { 102 | this.field.canMove = false 103 | 104 | const moveResult = await game.waitForOpponentMove() 105 | this.field?.makeMoveProgramatically(moveResult) 106 | 107 | this.field.canMove = true 108 | } 109 | } 110 | 111 | undo() { 112 | this.field?.undoMove() 113 | 114 | this.setBlackScore(this.field?.blackScore) 115 | this.setWhiteScore(this.field?.whiteScore) 116 | this.setIsUndoMoveDisabled() 117 | } 118 | 119 | onRecreateGame(blackStones: Array, whiteStones: Array) { 120 | // Putting stones 121 | for (const [stones, color] of [ 122 | [blackStones, 'Black'], 123 | [whiteStones, 'White'], 124 | ]) { 125 | for (const stone of stones) { 126 | const moveResult = { 127 | point_id: stone, 128 | died_stones_ids: [], 129 | } 130 | this.field?.putStoneProgramatically(moveResult, color) 131 | } 132 | } 133 | } 134 | 135 | setIsUndoMoveDisabled() { 136 | this._GUI.isUndoButtonHidden = 137 | this.isMultiplayer || this.field?.game?.moveNumber <= 1 138 | } 139 | 140 | setEnvTexture(envTexture: EnvTexture) { 141 | this._scene.setEnv(envTexture) 142 | } 143 | 144 | setBlackScore(value: number) { 145 | this._GUI.setBlackScore(value) 146 | } 147 | 148 | setWhiteScore(value: number) { 149 | this._GUI.setWhiteScore(value) 150 | } 151 | 152 | onEndMove() { 153 | this.setIsUndoMoveDisabled() 154 | } 155 | 156 | onDeath() { 157 | if ( 158 | this.field.game.playerTurn.toLowerCase() === 159 | (this.isMultiplayer ? 'black' : 'white') 160 | ) { 161 | this.setBlackScore(this.field?.blackScore) 162 | } else { 163 | this.setWhiteScore(this.field?.whiteScore) 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /frontend/src/logic/games/game.ts: -------------------------------------------------------------------------------- 1 | export default interface Game { 2 | start(): Promise 3 | 4 | makeMove(pointID: number): Promise> 5 | 6 | undoMove(): void 7 | 8 | get playerTurn(): any 9 | 10 | get whiteScore(): any 11 | 12 | get blackScore(): any 13 | 14 | get moveNumber(): any 15 | 16 | get blackStones(): any 17 | 18 | get whiteStones(): any 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/logic/games/multiplayer_game.ts: -------------------------------------------------------------------------------- 1 | import createLocalStore from '../../../libs' 2 | import api from '../../api/index' 3 | import WSClient from '../../api/ws_client' 4 | import FieldType from '../fields/enum' 5 | import Game from './game' 6 | import { MoveResult, MoveSchema, GameWithHistory } from '../../api/models' 7 | import { WS_API } from '../../constants' 8 | 9 | export default class MultiplayerGame implements Game { 10 | private wsClient: WSClient | null = null 11 | private _moveNumber: number = 1 12 | private _blackStones: Array = [] 13 | private _whiteStones: Array = [] 14 | private _blackScore = 0 15 | private _whiteScore = 0 16 | 17 | constructor( 18 | public fieldType: FieldType, 19 | public size: number, 20 | readonly onRecreateGame: Function 21 | ) {} 22 | 23 | async start() { 24 | const [store, setStore] = createLocalStore() 25 | 26 | if (store.room.game_id === null) { 27 | let room = store.room 28 | 29 | // Starting game 30 | try { 31 | const game_with_link = await api.startGame( 32 | store.room?.id, 33 | this.fieldType, 34 | this.size 35 | ) 36 | room.game_id = game_with_link.game.id 37 | } catch (_) { 38 | room = await api.getRoom(store.room?.id) 39 | } 40 | 41 | setStore('room', room) 42 | } 43 | 44 | // Fetching game 45 | const game = await api.getGameWithHistory(store.room.game_id) 46 | setStore('game', game) 47 | 48 | // Recreating game 49 | this.recreateGame(game) 50 | 51 | // Opening ws 52 | this.wsClient = new WSClient( 53 | `${WS_API}/games/ws/${store.room.id}`, 54 | store.user 55 | ) 56 | } 57 | 58 | async makeMove(pointID: number): Promise { 59 | const [store, _setStore] = createLocalStore() 60 | 61 | // Sending message 62 | const move_schema: MoveSchema = { 63 | game_id: store.room.game_id, 64 | point_id: pointID, 65 | } 66 | this.wsClient?.sendMsg(JSON.stringify(move_schema)) 67 | 68 | // Waiting for response from server 69 | const msg = await this.wsClient?.waitForMsg() 70 | 71 | const move_result = JSON.parse(msg) 72 | 73 | if (move_result.error !== undefined) { 74 | throw new Error(move_result.error) 75 | } 76 | 77 | // Making post move actions 78 | this.postMoveActions(move_result) 79 | 80 | return move_result.died_stones_ids 81 | } 82 | 83 | async waitForOpponentMove(): Promise { 84 | const msg = await this.wsClient?.waitForMsg() 85 | const move_result: MoveResult = JSON.parse(msg) 86 | 87 | // Post move actions 88 | this.postMoveActions(move_result) 89 | 90 | return move_result 91 | } 92 | 93 | private postMoveActions(move_result: MoveResult) { 94 | this._moveNumber++ 95 | 96 | if (this.playerTurn == 'Black') { 97 | this._blackStones.push(move_result.point_id) 98 | this._whiteStones = this._whiteStones.filter((p) => 99 | move_result.died_stones_ids.includes(p) 100 | ) 101 | this._blackScore += move_result.died_stones_ids.length 102 | } else { 103 | this._whiteStones.push(move_result.point_id) 104 | this._blackStones = this._whiteStones.filter((p) => 105 | move_result.died_stones_ids.includes(p) 106 | ) 107 | this._whiteScore += move_result.died_stones_ids.length 108 | } 109 | } 110 | 111 | recreateGame(game: GameWithHistory) { 112 | if (!game.history.records.length) return 113 | 114 | let blackStones: Array = [] 115 | let whiteStones: Array = [] 116 | 117 | // Recreating moves 118 | let isBlackTurn = true 119 | for (const record of game.history.records) { 120 | if (isBlackTurn) { 121 | blackStones.push(record.point_id) 122 | 123 | if (record.died_points_ids) { 124 | whiteStones = whiteStones.filter( 125 | (pid) => !record.died_points_ids.includes(pid) 126 | ) 127 | 128 | this._blackScore += record.died_points_ids.length 129 | } 130 | } else { 131 | whiteStones.push(record.point_id) 132 | 133 | if (record.died_points_ids) { 134 | blackStones = blackStones.filter( 135 | (pid) => !record.died_points_ids.includes(pid) 136 | ) 137 | 138 | this._whiteScore += record.died_points_ids.length 139 | } 140 | } 141 | 142 | // Changing turn 143 | isBlackTurn = !isBlackTurn 144 | } 145 | 146 | // Setting right moveNumber 147 | this._moveNumber += game.history.records.length 148 | 149 | // put stones and scores 150 | this.onRecreateGame(blackStones, whiteStones) 151 | } 152 | 153 | undoMove(): void { 154 | throw new Error('Method not implemented.') 155 | } 156 | 157 | get playerTurn() { 158 | return this._moveNumber % 2 === 0 ? 'Black' : 'White' 159 | } 160 | get whiteScore(): any { 161 | return this._whiteScore 162 | } 163 | get blackScore(): any { 164 | return this._blackScore 165 | } 166 | get moveNumber(): any { 167 | return this._moveNumber 168 | } 169 | get blackStones(): any { 170 | return this._blackStones 171 | } 172 | get whiteStones(): any { 173 | return this._whiteStones 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /frontend/src/logic/games/singleplayer_game.ts: -------------------------------------------------------------------------------- 1 | import Game from './game' 2 | import init, { Game as GameLib } from '../../pkg/wasm_gamelib' 3 | import FieldType from '../fields/enum' 4 | 5 | await init() 6 | 7 | export default class SingleplayerGame implements Game { 8 | protected inner: GameLib 9 | 10 | constructor(fieldType: FieldType, size: number, _onRecreateGame: Function) { 11 | this.inner = new GameLib(fieldType, size, true) 12 | } 13 | 14 | async start() { 15 | this.inner.start() 16 | } 17 | 18 | async makeMove(pointID: number): Promise> { 19 | return [...this.inner.make_move(pointID)] 20 | } 21 | 22 | undoMove() { 23 | return this.inner.undo_move() 24 | } 25 | 26 | get playerTurn() { 27 | return this.inner.player_turn() 28 | } 29 | 30 | get whiteScore() { 31 | return this.inner.get_white_score() 32 | } 33 | 34 | get blackScore() { 35 | return this.inner.get_black_score() 36 | } 37 | 38 | get moveNumber() { 39 | return this.inner.get_move_number() 40 | } 41 | 42 | get blackStones() { 43 | return [...this.inner.get_black_stones()] 44 | } 45 | 46 | get whiteStones() { 47 | return [...this.inner.get_white_stones()] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /frontend/src/logic/scene.ts: -------------------------------------------------------------------------------- 1 | import * as BABYLON from 'babylonjs' 2 | import { 3 | defaultEnvTextures, 4 | defaultResolution, 5 | defaultTextureName, 6 | EnvTexture, 7 | } from '../constants' 8 | 9 | export default class Scene { 10 | readonly _scene: BABYLON.Scene 11 | readonly _camera: BABYLON.ArcRotateCamera 12 | readonly _ground: BABYLON.GroundMesh 13 | protected _XRHelper: BABYLON.WebXRDefaultExperience | undefined 14 | 15 | constructor(canvas: HTMLCanvasElement, sphereRadius: number) { 16 | const engine = new BABYLON.Engine(canvas, true) 17 | 18 | this._scene = new BABYLON.Scene(engine) 19 | 20 | // Creating ground 21 | this._ground = BABYLON.MeshBuilder.CreateGround('ground', { 22 | height: 30, 23 | width: 30, 24 | }) 25 | this._ground.setEnabled(false) 26 | 27 | // Loading environment 28 | this.setEnv(defaultEnvTextures[defaultTextureName][defaultResolution]) 29 | 30 | this._camera = new BABYLON.ArcRotateCamera( 31 | 'camera', 32 | -Math.PI / 2, 33 | Math.PI / 2, 34 | sphereRadius * 4, 35 | new BABYLON.Vector3(0, 1, 0), 36 | this._scene 37 | ) 38 | this._camera.attachControl(canvas, true) 39 | this._camera.lowerRadiusLimit = sphereRadius * 3.1 40 | this._camera.upperRadiusLimit = sphereRadius * 10 41 | this._camera.wheelPrecision = 100 42 | 43 | // Loading VR 44 | this.loadVR() 45 | 46 | engine.runRenderLoop(() => { 47 | this._scene.render() 48 | }) 49 | 50 | window.addEventListener('resize', () => { 51 | engine.resize() 52 | }) 53 | } 54 | 55 | public setEnv(envTexture: EnvTexture) { 56 | setTimeout(() => { 57 | const hdrTexture = new BABYLON.HDRCubeTexture( 58 | envTexture.url, 59 | this._scene, 60 | envTexture.res 61 | ) 62 | 63 | this._scene.createDefaultSkybox(hdrTexture) 64 | }, 0) 65 | } 66 | 67 | protected async loadVR() { 68 | this._XRHelper = await this._scene.createDefaultXRExperienceAsync({ 69 | floorMeshes: [this._ground], 70 | }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /frontend/src/logic/stone_manager.ts: -------------------------------------------------------------------------------- 1 | import * as BABYLON from 'babylonjs' 2 | import { FIELD_Y } from '../constants' 3 | 4 | export type CreateStoneScheme = { 5 | id: number 6 | position: BABYLON.Vector3 7 | color: BABYLON.Color3 8 | rotation: BABYLON.Vector3 9 | } 10 | 11 | type Stone = { 12 | id: number 13 | stone: BABYLON.Mesh 14 | } 15 | 16 | export default class StoneManager { 17 | protected stones: Array = [] 18 | readonly stoneSize = 1.6 19 | readonly height: number 20 | readonly multiplier: number 21 | 22 | constructor(readonly scene: BABYLON.Scene, gridSize: number, k: number) { 23 | this.height = (0.2 * this.stoneSize * k) / gridSize 24 | this.multiplier = 1 + this.height * 0.95 25 | } 26 | 27 | create(stoneSchema: CreateStoneScheme) { 28 | const id = stoneSchema.id 29 | 30 | const diameter = this.height * 3 31 | 32 | // Creating stone 33 | const stone = BABYLON.MeshBuilder.CreateSphere(`sphere_${id}`, { 34 | diameterX: diameter, 35 | diameterY: this.height, 36 | diameterZ: diameter, 37 | }) 38 | 39 | // Setting right position 40 | stoneSchema.position.y -= FIELD_Y 41 | stone.position = stoneSchema.position.scale(this.multiplier) 42 | stone.position.y += FIELD_Y 43 | 44 | // Creating stone's material 45 | const material = new BABYLON.PBRMetallicRoughnessMaterial('stone') 46 | material._albedoColor = stoneSchema.color 47 | material.roughness = 0.9 48 | material.metallic = 0.15 49 | stone.material = material 50 | 51 | // Setting stone's rotation 52 | stone.rotation = stoneSchema.rotation 53 | 54 | this.stones.push({ id, stone }) 55 | } 56 | 57 | delete(indexes: Array) { 58 | const newStones = [] 59 | 60 | for (const stone of this.stones) { 61 | if (indexes.includes(stone.id)) { 62 | stone.stone.dispose() 63 | } else { 64 | newStones.push(stone) 65 | } 66 | } 67 | 68 | this.stones = newStones 69 | } 70 | 71 | clear() { 72 | this.stones.forEach((stone) => stone.stone.dispose()) 73 | this.stones = [] 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /frontend/src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | export default function NotFound() { 2 | return <>Not Found 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/pages/Game.tsx: -------------------------------------------------------------------------------- 1 | import { Component, onMount, createSignal, Show } from 'solid-js' 2 | import { useLocation, useNavigate } from '@solidjs/router' 3 | 4 | import styles from '../App.module.css' 5 | import { SPHERE_RADIUS } from '../constants' 6 | import GameManager from '../logic/game_manager' 7 | import ModeChooser from '../components/ModeChooser' 8 | import MultiplayerGame from '../logic/games/multiplayer_game' 9 | import SingleplayerGame from '../logic/games/singleplayer_game' 10 | import createLocalStore from '../../libs' 11 | import api from '../api' 12 | import { fullLocation } from '../router' 13 | import { Settings } from './Settings' 14 | 15 | const GamePage: Component = () => { 16 | const [gameManager, setGameManager] = createSignal() 17 | const navigate = useNavigate() 18 | const location = useLocation() 19 | const [store, setStore] = createLocalStore() 20 | 21 | const modes = [ 22 | { 23 | label: 'SinglePlayer', 24 | onClick: () => { 25 | { 26 | navigate(fullLocation('/singleplayer')) 27 | startGUI() 28 | } 29 | }, 30 | }, 31 | { 32 | label: 'MultiPlayer', 33 | onClick: () => { 34 | navigate(fullLocation('/rooms')) 35 | }, 36 | }, 37 | ] 38 | 39 | onMount(() => { 40 | // Creating game 41 | setTimeout(async () => { 42 | let innerGame = isMultiplayer() ? MultiplayerGame : SingleplayerGame 43 | let gm = new GameManager(canvas, SPHERE_RADIUS, innerGame) 44 | await gm.setField() 45 | setGameManager(gm) 46 | 47 | if (!isRoot()) await startGUI() 48 | }, 0) 49 | }) 50 | 51 | let canvas: HTMLCanvasElement 52 | 53 | const isRoot = () => 54 | location.pathname === fullLocation('/') || 55 | location.pathname === fullLocation('') 56 | const isMultiplayer = () => location.pathname === fullLocation('/multiplayer') 57 | 58 | async function startGUI() { 59 | if (isMultiplayer()) { 60 | const room = await api.getRoom(store.room.id) 61 | setStore('room', room) 62 | await joinStartedGame() 63 | } 64 | 65 | if (isMultiplayer() && store.user.id === store.room.player2_id) return 66 | 67 | // Show GUI 68 | if (!isMultiplayer() || !store.room.game_id) { 69 | let gm = gameManager() 70 | gm?.showGUI() 71 | } 72 | } 73 | 74 | async function joinStartedGame() { 75 | // Fetching 76 | if (!store.room.game_id) return 77 | 78 | const game = await api.getGameWithHistory(store.room.game_id) 79 | 80 | // Storing 81 | setStore('game', game) 82 | 83 | // Setting 84 | let gm = gameManager() 85 | 86 | gm.fieldType = game.history.history.field_type 87 | gm.gridSize = game.history.history.size 88 | 89 | await gm?.setField() 90 | 91 | // Starting game 92 | await gm?.gameStart() 93 | } 94 | 95 | return ( 96 | <> 97 | {/* Game Canvas */} 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | ) 107 | } 108 | 109 | export default GamePage 110 | -------------------------------------------------------------------------------- /frontend/src/pages/Login.tsx: -------------------------------------------------------------------------------- 1 | import Avatar from '@suid/material/Avatar' 2 | import Button from '@suid/material/Button' 3 | import CssBaseline from '@suid/material/CssBaseline' 4 | import TextField from '@suid/material/TextField' 5 | import Box from '@suid/material/Box' 6 | import LockOutlinedIcon from '@suid/icons-material/LockOutlined' 7 | import Typography from '@suid/material/Typography' 8 | import Container from '@suid/material/Container' 9 | import { createTheme, ThemeProvider } from '@suid/material/styles' 10 | import { createSignal, onMount } from 'solid-js' 11 | import { useNavigate } from '@solidjs/router' 12 | 13 | import createLocalStore from '../../libs' 14 | import api from '../api' 15 | import { fullLocation } from '../router' 16 | 17 | const theme = createTheme() 18 | 19 | export default function Login() { 20 | const [usernameError, setUsernameError] = createSignal('') 21 | const [store, setStore] = createLocalStore() 22 | const navigate = useNavigate() 23 | 24 | onMount(() => { 25 | // If user is registered => moving him to rooms 26 | if (store.user) { 27 | navigate(fullLocation('/rooms')) 28 | } 29 | }) 30 | 31 | const handleSubmit = async (event: SubmitEvent) => { 32 | event.preventDefault() 33 | const data = new FormData(event.currentTarget) 34 | 35 | // Creating user 36 | const user = await api.register(data.get('username')) 37 | 38 | // Setting it to store 39 | setStore('user', user) 40 | 41 | // Redirecting back 42 | const redirect_url = store.redirect || fullLocation('/rooms') 43 | navigate(redirect_url) 44 | } 45 | 46 | const validateUsername = (e: InputEvent) => { 47 | const username: string = e.target?.value 48 | 49 | const errMsg = username.length === 0 ? 'Field is required' : '' 50 | setUsernameError(errMsg) 51 | } 52 | 53 | return ( 54 | 55 | 56 | 57 | 65 | 66 | 67 | 68 | 69 | Registration 70 | 71 | 77 | 90 | {/* */} 100 | 109 | 110 | 111 | 112 | 113 | ) 114 | } 115 | -------------------------------------------------------------------------------- /frontend/src/pages/Settings.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | GMobiledata, 3 | Settings as SettingsIcon, 4 | Warning, 5 | } from '@suid/icons-material' 6 | import { 7 | Button, 8 | Card, 9 | CardActions, 10 | Dialog, 11 | DialogActions, 12 | DialogContent, 13 | DialogContentText, 14 | DialogTitle, 15 | IconButton, 16 | NativeSelect, 17 | Stack, 18 | } from '@suid/material' 19 | import { createSignal, For } from 'solid-js' 20 | 21 | import { 22 | defaultEnvTextures, 23 | defaultResolution, 24 | defaultTextureName, 25 | } from '../constants' 26 | import GameManager from '../logic/game_manager' 27 | 28 | export type SettingsProps = { 29 | gm: GameManager 30 | } 31 | 32 | export function Settings(props: SettingsProps) { 33 | const [isOpened, setIsOpened] = createSignal(false) 34 | const [textureName, setTextureName] = createSignal(defaultTextureName) 35 | const [resolution, setResolution] = createSignal(defaultResolution) 36 | 37 | function updateTextureName(e: any) { 38 | const newVal = e.target.value 39 | setTextureName(newVal) 40 | 41 | updateTexture() 42 | } 43 | function updateResolution(e: any) { 44 | const newVal = e.target.value 45 | setResolution(newVal) 46 | 47 | updateTexture() 48 | } 49 | 50 | function updateTexture() { 51 | const envTexture = defaultEnvTextures[textureName()][resolution()] 52 | 53 | props.gm.setEnvTexture(envTexture) 54 | } 55 | 56 | return ( 57 | <> 58 | 68 | 69 | setIsOpened(true)}> 70 | 71 | 72 | 73 | 74 | 75 | setIsOpened(false)} 79 | > 80 | Settings 81 | 82 | 85 | 90 |
91 | Higher resolution requires downloading heavy   92 | HDR textures  (~ 93 | 10-500 MB) and more processing power from your machine 94 |
95 |
96 | 97 | 98 | 99 |
100 |
Texture
101 | 102 | 103 | {(name) => } 104 | 105 | 106 |
107 | 108 |
109 |
Resolution
110 | 111 | 112 | {(res) => } 113 | 114 | 115 |
116 |
117 |
118 | 119 | 120 | 121 | 122 |
123 | 124 | ) 125 | } 126 | -------------------------------------------------------------------------------- /frontend/src/pages/rooms/[id].tsx: -------------------------------------------------------------------------------- 1 | import CssBaseline from '@suid/material/CssBaseline' 2 | import Box from '@suid/material/Box' 3 | import Container from '@suid/material/Container' 4 | import { createTheme, ThemeProvider } from '@suid/material/styles' 5 | import { Component, createSignal, onMount, Show } from 'solid-js' 6 | import { useLocation, useNavigate, useParams } from '@solidjs/router' 7 | import { Button, Card, CardContent, Stack, Typography } from '@suid/material' 8 | import { grey } from '@suid/material/colors' 9 | 10 | import createLocalStore from '../../../libs' 11 | import api from '../../api' 12 | import { checkAuth } from '../../auth' 13 | import { FetchedUser } from '../../api/models' 14 | import { fullLocation } from '../../router' 15 | 16 | const theme = createTheme() 17 | 18 | export default function RoomPage() { 19 | const [store, setStore] = createLocalStore() 20 | const params = useParams() 21 | const navigate = useNavigate() 22 | const location = useLocation() 23 | const [anotherPlayer, setAnotherPlayer] = createSignal( 24 | null 25 | ) 26 | 27 | onMount(async () => { 28 | checkAuth() 29 | 30 | // Getting room 31 | try { 32 | const room = await api.getRoom(params.id) 33 | 34 | // Inserting/Updating room in store 35 | setStore('room', room) 36 | } catch (e) { 37 | navigate(fullLocation('/404')) 38 | return 39 | } 40 | 41 | // If the user is the player 1 -> another player is (or will be) the player 2 42 | if (isUserPlayer1()) { 43 | /// Trying to fetch player 2 44 | if (store.room.player2_id !== null) { 45 | const player_2 = await api.getUser(store.room.player2_id) 46 | setAnotherPlayer(player_2) 47 | } 48 | } else { 49 | // user can be the player 2, so it's up to him to decide whether to be or not 50 | const player_1 = await api.getUser(store.room.player1_id) 51 | setAnotherPlayer(player_1) 52 | } 53 | }) 54 | 55 | const isUserPlayer1 = () => store.room?.player1_id === store.user?.id 56 | const isWaitingForGameToStart = () => !isUserPlayer1() && !store.room?.game_id 57 | 58 | const copyLinkToClipboard = async () => 59 | navigator.clipboard.writeText(window.location.href) 60 | 61 | async function enterRoom() { 62 | const room = await api.enterRoom(store.room.id) 63 | setStore('room', room) 64 | } 65 | 66 | return ( 67 | 68 | 69 | 70 | 78 | 79 | 88 | 89 | 95 | 96 | 97 | 98 | 101 | 102 | 103 | 112 | 113 | 114 | 115 | 121 | 132 | 133 | 134 | 135 | 136 | ) 137 | } 138 | 139 | type PlayerCardProps = { 140 | bg: string 141 | color: string 142 | player_name: string 143 | } 144 | 145 | const PlayerCard: Component = (props) => { 146 | return ( 147 | 148 | 149 | {props.player_name} 150 | 151 | 152 | ) 153 | } 154 | -------------------------------------------------------------------------------- /frontend/src/pages/rooms/index.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@suid/material/Button' 2 | import CssBaseline from '@suid/material/CssBaseline' 3 | import Box from '@suid/material/Box' 4 | import Container from '@suid/material/Container' 5 | import { createTheme, ThemeProvider } from '@suid/material/styles' 6 | import { onMount } from 'solid-js' 7 | 8 | import createLocalStore from '../../../libs' 9 | import api from '../../api' 10 | import { useNavigate } from '@solidjs/router' 11 | import { checkAuth } from '../../auth' 12 | import { fullLocation } from '../../router' 13 | 14 | const theme = createTheme() 15 | 16 | export default function RoomsPage() { 17 | const [_store, setStore] = createLocalStore() 18 | const navigate = useNavigate() 19 | 20 | onMount(() => { 21 | checkAuth() 22 | }) 23 | 24 | const createRoom = async () => { 25 | // Creating room 26 | const room = await api.createRoom() 27 | 28 | // Storing it 29 | setStore('room', room) 30 | 31 | // Moving to the room 32 | navigate(fullLocation(`/rooms/${room.id}`)) 33 | } 34 | 35 | return ( 36 | 37 | 38 | 39 | 47 | 56 | 57 | 58 | 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /frontend/src/router.ts: -------------------------------------------------------------------------------- 1 | import { BASENAME } from './constants' 2 | import NotFound from './pages/404' 3 | import GamePage from './pages/Game' 4 | import LoginPage from './pages/Login' 5 | import RoomsPage from './pages/rooms' 6 | import RoomPage from './pages/rooms/[id]' 7 | 8 | export function fullLocation(location: string): string { 9 | return `${BASENAME}${location}` 10 | } 11 | 12 | const routes = [ 13 | { 14 | path: ['/', '/singleplayer', '/multiplayer'], 15 | component: GamePage, 16 | }, 17 | { 18 | path: '/login', 19 | component: LoginPage, 20 | }, 21 | { 22 | path: '/rooms', 23 | children: [ 24 | { 25 | path: '/', 26 | component: RoomsPage, 27 | }, 28 | { 29 | path: '/:id', 30 | component: RoomPage, 31 | }, 32 | ], 33 | }, 34 | { 35 | path: '/404', 36 | component: NotFound, 37 | }, 38 | ] 39 | 40 | export default routes 41 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "ESNext", 5 | "module": "ESNext", 6 | "moduleResolution": "node", 7 | "allowSyntheticDefaultImports": true, 8 | "esModuleInterop": true, 9 | "jsx": "preserve", 10 | "jsxImportSource": "solid-js", 11 | "types": ["vite/client", "babylonjs", "babylonjs-gui"], 12 | "noEmit": true, 13 | "isolatedModules": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import solidPlugin from 'vite-plugin-solid' 3 | import suidPlugin from '@suid/vite-plugin' 4 | 5 | export default defineConfig({ 6 | plugins: [suidPlugin(), solidPlugin()], 7 | server: { 8 | port: 3000, 9 | }, 10 | build: { 11 | target: 'esnext', 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /gamelib/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "either" 7 | version = "1.7.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "3f107b87b6afc2a64fd13cac55fe06d6c8859f12d4b14cbcdd2c67d0976781be" 10 | 11 | [[package]] 12 | name = "itertools" 13 | version = "0.10.3" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "a9a9d19fa1e79b6215ff29b9d6880b706147f16e9b1dbb1e4e5947b5b02bc5e3" 16 | dependencies = [ 17 | "either", 18 | ] 19 | 20 | [[package]] 21 | name = "itoa" 22 | version = "1.0.2" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d" 25 | 26 | [[package]] 27 | name = "proc-macro2" 28 | version = "1.0.40" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "dd96a1e8ed2596c337f8eae5f24924ec83f5ad5ab21ea8e455d3566c69fbcaf7" 31 | dependencies = [ 32 | "unicode-ident", 33 | ] 34 | 35 | [[package]] 36 | name = "quote" 37 | version = "1.0.20" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "3bcdf212e9776fbcb2d23ab029360416bb1706b1aea2d1a5ba002727cbcab804" 40 | dependencies = [ 41 | "proc-macro2", 42 | ] 43 | 44 | [[package]] 45 | name = "ryu" 46 | version = "1.0.10" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "f3f6f92acf49d1b98f7a81226834412ada05458b7364277387724a237f062695" 49 | 50 | [[package]] 51 | name = "serde" 52 | version = "1.0.137" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "61ea8d54c77f8315140a05f4c7237403bf38b72704d031543aa1d16abbf517d1" 55 | dependencies = [ 56 | "serde_derive", 57 | ] 58 | 59 | [[package]] 60 | name = "serde_derive" 61 | version = "1.0.137" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | checksum = "1f26faba0c3959972377d3b2d306ee9f71faee9714294e41bb777f83f88578be" 64 | dependencies = [ 65 | "proc-macro2", 66 | "quote", 67 | "syn", 68 | ] 69 | 70 | [[package]] 71 | name = "serde_json" 72 | version = "1.0.81" 73 | source = "registry+https://github.com/rust-lang/crates.io-index" 74 | checksum = "9b7ce2b32a1aed03c558dc61a5cd328f15aff2dbc17daad8fb8af04d2100e15c" 75 | dependencies = [ 76 | "itoa", 77 | "ryu", 78 | "serde", 79 | ] 80 | 81 | [[package]] 82 | name = "spherical_go_game_lib" 83 | version = "0.1.0" 84 | dependencies = [ 85 | "itertools", 86 | "serde", 87 | "serde_json", 88 | "thiserror", 89 | ] 90 | 91 | [[package]] 92 | name = "syn" 93 | version = "1.0.98" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "c50aef8a904de4c23c788f104b7dddc7d6f79c647c7c8ce4cc8f73eb0ca773dd" 96 | dependencies = [ 97 | "proc-macro2", 98 | "quote", 99 | "unicode-ident", 100 | ] 101 | 102 | [[package]] 103 | name = "thiserror" 104 | version = "1.0.31" 105 | source = "registry+https://github.com/rust-lang/crates.io-index" 106 | checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a" 107 | dependencies = [ 108 | "thiserror-impl", 109 | ] 110 | 111 | [[package]] 112 | name = "thiserror-impl" 113 | version = "1.0.31" 114 | source = "registry+https://github.com/rust-lang/crates.io-index" 115 | checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a" 116 | dependencies = [ 117 | "proc-macro2", 118 | "quote", 119 | "syn", 120 | ] 121 | 122 | [[package]] 123 | name = "unicode-ident" 124 | version = "1.0.1" 125 | source = "registry+https://github.com/rust-lang/crates.io-index" 126 | checksum = "5bd2fe26506023ed7b5e1e315add59d6f584c621d037f9368fea9cfb988f368c" 127 | -------------------------------------------------------------------------------- /gamelib/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "spherical_go_game_lib" 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 | thiserror = "1.0.30" 10 | itertools = "0.10.3" 11 | 12 | serde = { version = "1.0.137", features = ["derive"]} 13 | serde_json = { version = "1.0.81"} 14 | -------------------------------------------------------------------------------- /gamelib/rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "nightly" 3 | components = [] 4 | targets = [] 5 | profile = "default" 6 | -------------------------------------------------------------------------------- /gamelib/src/aliases.rs: -------------------------------------------------------------------------------- 1 | pub type SizeType = u8; 2 | 3 | pub type PointID = usize; 4 | -------------------------------------------------------------------------------- /gamelib/src/errors.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use crate::{aliases::PointID, state::GameState}; 4 | 5 | #[derive(thiserror::Error, Debug)] 6 | pub enum GameLoadingError { 7 | #[error("file does not exist")] 8 | FileNotFound(#[from] io::Error), 9 | } 10 | 11 | pub type GameLoadingResult = Result; 12 | 13 | /// All common errors 14 | #[derive(thiserror::Error, Debug)] 15 | pub enum GameError { 16 | #[error("{0}")] 17 | ValidationError(String), 18 | #[error("{action} is not possible at {current:?} game state")] 19 | GameStateError { current: GameState, action: String }, 20 | #[error("Point with id \"{0}\" is blocked")] 21 | PointBlocked(PointID), 22 | #[error("Point with id \"{0}\" is not empty")] 23 | PointOccupied(PointID), 24 | #[error("Suicide move is not permitted")] 25 | SuicideMoveIsNotPermitted, 26 | 27 | #[error("Error during game loading: \"{0}\"")] 28 | GameLoadingError(String), 29 | #[error("Undo move is not possible because moves history is not used in this game")] 30 | UndoIsImpossible, 31 | #[error("Game history is clear, you have nothing to undo")] 32 | UndoOnClearHistory, 33 | } 34 | 35 | pub type GameResult = Result; 36 | -------------------------------------------------------------------------------- /gamelib/src/field/field.rs: -------------------------------------------------------------------------------- 1 | use std::{cell::RefCell, rc::Rc}; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use crate::{aliases::PointID, point::PointWrapper}; 6 | 7 | pub type PointOwner = Rc>; 8 | 9 | #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] 10 | pub enum FieldType { 11 | Regular, 12 | CubicSphere, 13 | GridSphere, 14 | } 15 | 16 | #[derive(Debug, PartialEq)] 17 | pub struct Field { 18 | points: Vec, 19 | pub field_type: FieldType, 20 | } 21 | 22 | impl Field { 23 | pub fn new(points: Vec, field_type: FieldType) -> Self { 24 | Self { points, field_type } 25 | } 26 | 27 | #[allow(dead_code)] 28 | #[inline] 29 | pub fn len(&self) -> usize { 30 | self.points.len() 31 | } 32 | 33 | pub fn get_point(&self, point_id: &PointID) -> Rc> { 34 | self.points[*point_id].clone() 35 | } 36 | 37 | pub fn get_neighbor_points( 38 | &self, 39 | point_id: &PointID, 40 | ) -> [Option>>; 4] { 41 | let point = self.get_point(point_id); 42 | let p = point.borrow(); 43 | [p.top, p.right, p.bottom, p.left].map(|id| id.map(|id| self.get_point(&id))) 44 | } 45 | } 46 | 47 | // Custom implementation cause Rc> does not create completely new object in memory on .clone() 48 | // (IMAO just a bruh moment) 49 | impl Clone for Field { 50 | fn clone(&self) -> Self { 51 | let points = self 52 | .points 53 | .iter() 54 | .map(|point| Rc::new(RefCell::new(point.borrow().clone()))) 55 | .collect(); 56 | Self { 57 | points, 58 | field_type: self.field_type, 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /gamelib/src/field/field_builder.rs: -------------------------------------------------------------------------------- 1 | use crate::{errors, FieldType, SizeType}; 2 | 3 | use super::{CubicSphereFieldBuilder, Field, GridSphereFieldBuilder, RegularFieldBuilder}; 4 | 5 | /// Build field accordingly to given size and field type 6 | #[inline] 7 | pub(crate) fn build_field(size: &SizeType, field_type: FieldType) -> errors::GameResult { 8 | let field = match field_type { 9 | FieldType::CubicSphere => CubicSphereFieldBuilder::default().with_size(size)?, 10 | FieldType::GridSphere => GridSphereFieldBuilder::default().with_size(size), 11 | FieldType::Regular => RegularFieldBuilder::default().with_size(size), 12 | }; 13 | Ok(field) 14 | } 15 | -------------------------------------------------------------------------------- /gamelib/src/field/mod.rs: -------------------------------------------------------------------------------- 1 | mod cube_sphere_builder; 2 | mod field; 3 | mod field_builder; 4 | mod grid_sphere_builder; 5 | mod regular_field_builder; 6 | 7 | pub use cube_sphere_builder::CubicSphereFieldBuilder; 8 | pub use field::{Field, FieldType, PointOwner}; 9 | pub(crate) use field_builder::build_field; 10 | pub use grid_sphere_builder::GridSphereFieldBuilder; 11 | pub use regular_field_builder::RegularFieldBuilder; 12 | -------------------------------------------------------------------------------- /gamelib/src/field/regular_field_builder.rs: -------------------------------------------------------------------------------- 1 | use std::{cell::RefCell, rc::Rc}; 2 | 3 | use crate::{ 4 | point::{Point, PointWrapper}, 5 | SizeType, 6 | }; 7 | 8 | use super::{field::FieldType, Field}; 9 | 10 | /// Struct to build RegularField 11 | pub struct RegularFieldBuilder; 12 | 13 | impl Default for RegularFieldBuilder { 14 | fn default() -> Self { 15 | Self {} 16 | } 17 | } 18 | 19 | impl RegularFieldBuilder { 20 | #[allow(dead_code)] 21 | pub fn with_size(&self, size: &SizeType) -> Field { 22 | self.construct(size) 23 | } 24 | 25 | #[allow(dead_code)] 26 | fn construct(&self, size: &SizeType) -> Field { 27 | let size = *size as usize; 28 | 29 | // Creating points 30 | let points: Vec<_> = { 31 | let points_count = size.pow(2); 32 | 33 | (0..points_count) 34 | .map(|i| { 35 | let top = if i < size { None } else { Some(i - size) }; 36 | let bottom = if i >= (points_count - size) { 37 | None 38 | } else { 39 | Some(i + size) 40 | }; 41 | let left = if i % size == 0 { None } else { Some(i - 1) }; 42 | let right = if i % size == size - 1 { 43 | None 44 | } else { 45 | Some(i + 1) 46 | }; 47 | 48 | Rc::new(RefCell::new(PointWrapper::new( 49 | Point::new(i), 50 | top, 51 | left, 52 | right, 53 | bottom, 54 | ))) 55 | }) 56 | .collect() 57 | }; 58 | 59 | Field::new(points, FieldType::Regular) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /gamelib/src/file_converters/interfaces.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::prelude::*; 3 | 4 | use serde::de::DeserializeOwned; 5 | use serde::Serialize; 6 | 7 | use crate::errors::GameLoadingResult; 8 | 9 | /// This logic just reads/writes to/from file 10 | pub trait FileConverter { 11 | fn load(filepath: &str) -> GameLoadingResult { 12 | let mut file = File::open(filepath)?; 13 | let mut content = String::new(); 14 | file.read_to_string(&mut content)?; 15 | Ok(content) 16 | } 17 | 18 | fn save(filepath: &str, content: String) -> GameLoadingResult<()> { 19 | let mut file = File::open(filepath)?; 20 | file.write(content.as_bytes())?; 21 | Ok(()) 22 | } 23 | } 24 | 25 | pub trait JSONizer 26 | where 27 | T: Serialize + DeserializeOwned, 28 | { 29 | fn deserialize(json: &str) -> serde_json::Result { 30 | serde_json::from_str(json) 31 | } 32 | 33 | fn serialize(game: &T) -> serde_json::Result { 34 | serde_json::to_string(game) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /gamelib/src/file_converters/mod.rs: -------------------------------------------------------------------------------- 1 | /// Mod for converting the game to/from files 2 | pub mod interfaces; 3 | -------------------------------------------------------------------------------- /gamelib/src/group.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashSet, ops::BitOrAssign}; 2 | 3 | use crate::{ 4 | aliases::PointID, 5 | field::Field, 6 | point::{PlayerColor, PointStatus}, 7 | }; 8 | 9 | #[derive(Debug, Clone, PartialEq)] 10 | pub struct Group { 11 | pub(crate) points_ids: HashSet, 12 | pub(crate) liberties: HashSet, 13 | } 14 | 15 | impl Group { 16 | /// Consider making any new point a group 17 | pub fn new(point_id: &PointID, field: &Field, color: &PlayerColor) -> Self { 18 | let mut points_ids = HashSet::new(); 19 | points_ids.insert(*point_id); 20 | Self { 21 | points_ids, 22 | liberties: field 23 | .get_neighbor_points(point_id) 24 | .into_iter() 25 | .filter_map(|point| { 26 | let enemy = color.different_color(); 27 | match point { 28 | Some(point) if point.borrow().inner.has_color(enemy) => None, 29 | Some(point) => Some(*point.borrow().id()), 30 | _ => None, 31 | } 32 | }) 33 | .collect(), 34 | } 35 | } 36 | 37 | pub fn new_from_points( 38 | points_ids: HashSet, 39 | field: &Field, 40 | color: &PlayerColor, 41 | ) -> Vec { 42 | let mut groups: Vec<_> = points_ids 43 | .iter() 44 | .map(|point_id| Self::new(point_id, field, color)) 45 | .collect(); 46 | 47 | // Going through all the groups until we have only unmergeable ones 48 | let mut i = 0; 49 | while i < groups.len() { 50 | // Poping group 51 | let mut group = groups.remove(i); 52 | 53 | // Going through other groups to find if they have same liberties 54 | let joints_groups = groups 55 | .drain_filter(|other| { 56 | group 57 | .points_ids 58 | .iter() 59 | .any(|point_id| other.has_liberty(point_id)) 60 | }) 61 | .collect::>(); 62 | 63 | // Don't increment if we have groups to merge, cause in that case 64 | // we miss checking liberties of those groups after merging them cause 65 | // we won't check them anymore 66 | let to_increment = joints_groups.is_empty(); 67 | 68 | // Merging joint groups 69 | for other in joints_groups { 70 | group |= other 71 | } 72 | 73 | // Inserting group back 74 | groups.insert(i, group); 75 | 76 | if to_increment { 77 | i += 1 78 | } 79 | } 80 | 81 | groups 82 | } 83 | 84 | /// Merge another group into current 85 | pub fn merge(&mut self, mut other: Group) { 86 | // Removing intersections between them 87 | self.liberties = &self.liberties - &other.points_ids; 88 | other.liberties = &other.liberties - &self.points_ids; 89 | 90 | // Merging them 91 | self.points_ids = &self.points_ids | &other.points_ids; 92 | self.liberties = &self.liberties | &other.liberties; 93 | } 94 | 95 | pub fn refresh_liberties(&mut self, field: &Field) { 96 | self.liberties = self 97 | .points_ids 98 | .iter() 99 | .map(|id| { 100 | field 101 | .get_neighbor_points(id) 102 | .into_iter() 103 | .filter_map(|point| match point { 104 | Some(p) if p.borrow().inner.is_occupied() => None, 105 | Some(p) => Some(*p.borrow().id()), 106 | None => None, 107 | }) 108 | .collect::>() 109 | }) 110 | .flatten() 111 | .collect() 112 | } 113 | 114 | pub fn delete(self, field: &Field) { 115 | for id in self.points_ids { 116 | field.get_point(&id).borrow_mut().inner.status = PointStatus::Empty; 117 | } 118 | } 119 | 120 | /// Defines if the group has a liberty with the given point id 121 | #[inline] 122 | pub fn has_liberty(&self, point_id: &PointID) -> bool { 123 | self.liberties.contains(point_id) 124 | } 125 | 126 | #[inline] 127 | pub fn liberties_amount(&self) -> usize { 128 | self.liberties.len() 129 | } 130 | 131 | #[inline] 132 | pub fn points_amount(&self) -> usize { 133 | self.points_ids.len() 134 | } 135 | } 136 | 137 | impl BitOrAssign for Group { 138 | fn bitor_assign(&mut self, rhs: Self) { 139 | self.merge(rhs) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /gamelib/src/history/manager.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use crate::{ 4 | errors::{GameError, GameResult}, 5 | field::build_field, 6 | game::Game, 7 | group::Group, 8 | ko_guard::KoGuard, 9 | point::PointStatus, 10 | state::GameState, 11 | PlayerColor, 12 | }; 13 | 14 | use super::{StoredGame, StoredGameMove, StoredGameMoveType}; 15 | 16 | /// Manager to convert game to history and vice versa 17 | pub(crate) struct HistoryManager { 18 | history: StoredGame, 19 | } 20 | 21 | impl HistoryManager { 22 | pub(crate) fn new(history: StoredGame) -> Self { 23 | Self { history } 24 | } 25 | 26 | pub(crate) fn load(&self) -> GameResult { 27 | // Creating a field 28 | let field = build_field(&self.history.meta.size, self.history.meta.field_type)?; 29 | let mut black_stones = HashSet::new(); 30 | let mut white_stones = HashSet::new(); 31 | let mut black_score = 0; 32 | let mut white_score = 0; 33 | 34 | let mut is_game_finished = false; 35 | let mut move_number = 0; 36 | 37 | let mut ko_guard = KoGuard::default(); 38 | 39 | // Going through the history 40 | for (i, record) in self.history.moves.iter().enumerate() { 41 | move_number += 1; 42 | 43 | match record.move_type { 44 | StoredGameMoveType::Move => (), 45 | StoredGameMoveType::Pass => continue, 46 | StoredGameMoveType::Surrender => { 47 | is_game_finished = true; 48 | break; 49 | } 50 | } 51 | 52 | // Converting to players/enemies context 53 | let reminder = move_number % 2; 54 | let (mut players_stones, mut enemies_stones, mut players_score, color) = match reminder 55 | { 56 | 0 => (white_stones, black_stones, white_score, PlayerColor::White), 57 | _ => (black_stones, white_stones, black_score, PlayerColor::Black), 58 | }; 59 | 60 | // Main move processing 61 | { 62 | let point_id = record.point_id.ok_or(GameError::GameLoadingError( 63 | format!("expected point ID in move with number {move_number}").to_string(), 64 | ))?; 65 | field.get_point(&point_id).borrow_mut().inner.status = PointStatus::Occupied(color); 66 | players_stones.insert(point_id); 67 | 68 | if !record.died.is_empty() { 69 | for dead_stone in &record.died { 70 | field.get_point(dead_stone).borrow_mut().inner.status = PointStatus::Empty; 71 | } 72 | 73 | enemies_stones = &enemies_stones - &record.died; 74 | players_score += record.died.len(); 75 | } 76 | } 77 | 78 | // Converting back 79 | if reminder == 0 { 80 | white_stones = players_stones; 81 | black_stones = enemies_stones; 82 | white_score = players_score; 83 | } else { 84 | black_stones = players_stones; 85 | white_stones = enemies_stones; 86 | black_score = players_score; 87 | } 88 | 89 | // If it's prelast iteration - setting ko guard 90 | if i + 2 == self.history.moves.len() { 91 | ko_guard = KoGuard::new(black_stones.clone(), white_stones.clone()); 92 | } 93 | } 94 | 95 | let state = if is_game_finished { 96 | GameState::Ended 97 | } else { 98 | GameState::Started 99 | }; 100 | 101 | // Creating groups 102 | let mut black_groups = Group::new_from_points(black_stones, &field, &PlayerColor::Black); 103 | let mut white_groups = Group::new_from_points(white_stones, &field, &PlayerColor::White); 104 | 105 | // Refreshing their liberties 106 | black_groups 107 | .iter_mut() 108 | .for_each(|g| g.refresh_liberties(&field)); 109 | white_groups 110 | .iter_mut() 111 | .for_each(|g| g.refresh_liberties(&field)); 112 | 113 | // Setting actually used (next) move_number 114 | move_number += 1; 115 | 116 | Ok(Game::new_with_all_fields( 117 | state, 118 | field, 119 | black_groups, 120 | white_groups, 121 | Some(move_number), 122 | Some(black_score), 123 | Some(white_score), 124 | ko_guard, 125 | )) 126 | } 127 | 128 | pub(crate) fn append_record(&mut self, record: StoredGameMove) { 129 | self.history.moves.push(record) 130 | } 131 | 132 | pub(crate) fn pop_record(&mut self) -> GameResult<()> { 133 | self.history 134 | .moves 135 | .pop() 136 | .ok_or(GameError::UndoOnClearHistory) 137 | .map(|_| ()) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /gamelib/src/history/mod.rs: -------------------------------------------------------------------------------- 1 | mod manager; 2 | mod models; 3 | 4 | pub(crate) use manager::HistoryManager; 5 | pub use models::{StoredGame, StoredGameMeta, StoredGameMove, StoredGameMoveType}; 6 | -------------------------------------------------------------------------------- /gamelib/src/history/models.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use crate::{aliases::PointID, FieldType, SizeType}; 6 | 7 | /// 8 | /// Consider a stored game to be like this: 9 | /// 10 | /// ```jsonc 11 | /// { 12 | /// "meta": { 13 | /// "field_type": "GridSphere", 14 | /// "size": 9 15 | /// }, 16 | /// "moves": [ 17 | /// { 18 | /// "moveType": "Move", 19 | /// "pointID": 228, 20 | /// "died": [5, 78] 21 | /// }, 22 | /// { 23 | /// "moveType": "Move", 24 | /// "pointID": 1337, 25 | /// }, 26 | /// { 27 | /// "moveType": "Pass", 28 | /// }, 29 | /// ... 30 | /// ] 31 | /// } 32 | /// ``` 33 | /// 34 | #[derive(Debug, Clone, Deserialize, Serialize)] 35 | pub struct StoredGame { 36 | pub meta: StoredGameMeta, 37 | pub moves: Vec, 38 | } 39 | 40 | #[derive(Debug, Clone, Deserialize, Serialize)] 41 | pub struct StoredGameMeta { 42 | pub field_type: FieldType, 43 | pub size: SizeType, 44 | } 45 | 46 | #[derive(Debug, Clone, Deserialize, Serialize)] 47 | #[serde(rename_all = "camelCase")] 48 | pub struct StoredGameMove { 49 | pub move_type: StoredGameMoveType, 50 | 51 | #[serde(default = "Option::default")] 52 | pub point_id: Option, 53 | 54 | #[serde(default = "HashSet::default")] 55 | pub died: HashSet, 56 | } 57 | 58 | #[derive(Debug, Clone, Deserialize, Serialize)] 59 | pub enum StoredGameMoveType { 60 | Move, 61 | Pass, 62 | Surrender, 63 | } 64 | -------------------------------------------------------------------------------- /gamelib/src/ko_guard.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use crate::PointID; 4 | 5 | #[derive(Debug, Clone, PartialEq)] 6 | pub struct KoGuard { 7 | black_points: HashSet, 8 | white_points: HashSet, 9 | } 10 | 11 | impl KoGuard { 12 | pub fn new(black_points: HashSet, white_points: HashSet) -> Self { 13 | Self { 14 | black_points, 15 | white_points, 16 | } 17 | } 18 | 19 | pub fn update( 20 | &mut self, 21 | new_black_points: HashSet, 22 | new_white_points: HashSet, 23 | ) { 24 | self.black_points = new_black_points; 25 | self.white_points = new_white_points; 26 | } 27 | 28 | /// Checks if the ko rule was violated 29 | pub fn check( 30 | &self, 31 | next_black_points: HashSet, 32 | next_white_points: HashSet, 33 | ) -> bool { 34 | self.black_points == next_black_points && self.white_points == next_white_points 35 | } 36 | } 37 | 38 | impl Default for KoGuard { 39 | fn default() -> Self { 40 | Self { 41 | black_points: HashSet::default(), 42 | white_points: HashSet::default(), 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /gamelib/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![feature(drain_filter)] 2 | #![feature(hash_drain_filter)] 3 | #![feature(slice_flatten)] 4 | 5 | use std::collections::HashSet; 6 | 7 | pub use aliases::{PointID, SizeType}; 8 | use errors::GameResult; 9 | use field::build_field; 10 | pub use field::FieldType; 11 | use group::Group; 12 | use history::HistoryManager; 13 | pub use history::{StoredGame, StoredGameMeta, StoredGameMove, StoredGameMoveType}; 14 | pub use point::PlayerColor; 15 | 16 | mod aliases; 17 | pub mod errors; 18 | mod field; 19 | mod file_converters; 20 | mod game; 21 | mod group; 22 | mod history; 23 | mod ko_guard; 24 | mod point; 25 | mod state; 26 | 27 | pub struct Game { 28 | pub(crate) inner: game::Game, 29 | pub(crate) history_manager: Option, 30 | } 31 | 32 | impl Game { 33 | /// Create the game 34 | pub fn new( 35 | field_type: FieldType, 36 | size: &SizeType, 37 | use_history: bool, 38 | ) -> errors::GameResult { 39 | // Creating a field by it's field_type 40 | let field = build_field(size, field_type)?; 41 | 42 | // Creating a history manager 43 | let history_manager = if use_history { 44 | let meta = StoredGameMeta { 45 | field_type, 46 | size: *size, 47 | }; 48 | let stored_game = StoredGame { 49 | meta, 50 | moves: Vec::new(), 51 | }; 52 | let history_manager = HistoryManager::new(stored_game); 53 | Some(history_manager) 54 | } else { 55 | None 56 | }; 57 | 58 | let game = Self { 59 | inner: game::Game::new(field), 60 | history_manager, 61 | }; 62 | Ok(game) 63 | } 64 | 65 | /// Load game from history 66 | pub fn new_from_history(history: StoredGame) -> GameResult { 67 | let history_manager = HistoryManager::new(history); 68 | let inner = history_manager.load()?; 69 | 70 | Ok(Self { 71 | inner, 72 | history_manager: Some(history_manager), 73 | }) 74 | } 75 | 76 | /// Make a move 77 | /// 78 | /// Returns list of stoned became dead by this move 79 | pub fn make_move(&mut self, point_id: &PointID) -> errors::GameResult> { 80 | let died_stones = self.inner.make_move(point_id)?; 81 | 82 | // Filling history if we have it 83 | self.history_manager.as_mut().map(|history| { 84 | // TODO: add other types of moves (like pass and surrender) 85 | let record = StoredGameMove { 86 | move_type: StoredGameMoveType::Move, 87 | point_id: Some(*point_id), 88 | died: died_stones.clone(), 89 | }; 90 | history.append_record(record) 91 | }); 92 | 93 | Ok(died_stones) 94 | } 95 | 96 | /// Undo previous move 97 | pub fn undo_move(&mut self) -> errors::GameResult<()> { 98 | if self.history_manager.is_none() { 99 | return Err(errors::GameError::UndoIsImpossible); 100 | } 101 | let hm = self.history_manager.as_mut().unwrap(); 102 | 103 | // Undoing move 104 | hm.pop_record()?; 105 | 106 | // Recreating game 107 | self.inner = hm.load()?; 108 | 109 | Ok(()) 110 | } 111 | 112 | /// Start game 113 | pub fn start(&mut self) -> errors::GameResult<()> { 114 | self.inner.start() 115 | } 116 | 117 | /// End game 118 | pub fn end(&mut self) -> errors::GameResult<()> { 119 | self.inner.end() 120 | } 121 | 122 | #[inline] 123 | pub fn is_not_started(&self) -> bool { 124 | self.inner.is_not_started() 125 | } 126 | 127 | #[inline] 128 | pub fn is_started(&self) -> bool { 129 | self.inner.is_started() 130 | } 131 | 132 | #[inline] 133 | pub fn is_ended(&self) -> bool { 134 | self.inner.is_ended() 135 | } 136 | 137 | #[inline] 138 | pub fn get_black_stones(&self) -> Vec { 139 | Self::get_points_ids_from_groups(&self.inner.black_groups) 140 | } 141 | 142 | #[inline] 143 | pub fn get_white_stones(&self) -> Vec { 144 | Self::get_points_ids_from_groups(&self.inner.white_groups) 145 | } 146 | 147 | #[inline] 148 | fn get_points_ids_from_groups(groups: &Vec) -> Vec { 149 | groups.iter().flat_map(|g| g.points_ids.clone()).collect() 150 | } 151 | 152 | #[inline] 153 | pub fn get_black_score(&self) -> Option { 154 | self.inner.get_black_score() 155 | } 156 | 157 | #[inline] 158 | pub fn get_white_score(&self) -> Option { 159 | self.inner.get_white_score() 160 | } 161 | 162 | #[inline] 163 | pub fn player_turn(&self) -> Option { 164 | self.inner.player_turn() 165 | } 166 | 167 | #[inline] 168 | pub fn field_type(&self) -> FieldType { 169 | self.inner.field.field_type 170 | } 171 | 172 | #[inline] 173 | pub fn get_move_number(&self) -> Option { 174 | self.inner.get_move_number() 175 | } 176 | } 177 | 178 | #[cfg(test)] 179 | mod tests; 180 | -------------------------------------------------------------------------------- /gamelib/src/point.rs: -------------------------------------------------------------------------------- 1 | use crate::aliases::PointID; 2 | 3 | /// Represents a single point in a game field 4 | #[derive(Clone, Debug, PartialEq)] 5 | pub struct Point { 6 | pub id: PointID, 7 | pub(crate) status: PointStatus, 8 | } 9 | 10 | impl Point { 11 | pub fn new(id: PointID) -> Self { 12 | Self { 13 | id, 14 | status: PointStatus::default(), 15 | } 16 | } 17 | 18 | // #[inline] 19 | // pub fn is_empty(&self) -> bool { 20 | // matches!(self.status, PointStatus::Empty) 21 | // } 22 | 23 | // #[inline] 24 | // pub fn is_blocked(&self) -> bool { 25 | // matches!(self.status, PointStatus::Blocked) 26 | // } 27 | 28 | #[inline] 29 | pub fn is_occupied(&self) -> bool { 30 | matches!(self.status, PointStatus::Occupied(_)) 31 | } 32 | 33 | #[inline] 34 | pub fn has_color(&self, color: PlayerColor) -> bool { 35 | match &self.status { 36 | PointStatus::Occupied(c) if *c == color => true, 37 | _ => false, 38 | } 39 | } 40 | } 41 | 42 | #[derive(Clone, Debug, PartialEq)] 43 | pub enum PointStatus { 44 | Empty, 45 | Occupied(PlayerColor), 46 | // Blocked, 47 | } 48 | 49 | impl Default for PointStatus { 50 | fn default() -> Self { 51 | Self::Empty 52 | } 53 | } 54 | 55 | /// Represents a player for the game. 56 | /// For now we need only color 57 | #[derive(Clone, Debug, PartialEq, Copy)] 58 | pub enum PlayerColor { 59 | Black, 60 | White, 61 | } 62 | 63 | impl PlayerColor { 64 | pub fn different_color(&self) -> Self { 65 | match self { 66 | Self::Black => Self::White, 67 | Self::White => Self::Black, 68 | } 69 | } 70 | } 71 | 72 | #[derive(Debug, Clone, PartialEq)] 73 | pub struct PointWrapper { 74 | pub inner: Point, 75 | pub top: Option, 76 | pub left: Option, 77 | pub right: Option, 78 | pub bottom: Option, 79 | } 80 | 81 | impl PointWrapper { 82 | pub fn new( 83 | inner: Point, 84 | top: Option, 85 | left: Option, 86 | right: Option, 87 | bottom: Option, 88 | ) -> Self { 89 | Self { 90 | inner, 91 | top, 92 | left, 93 | right, 94 | bottom, 95 | } 96 | } 97 | 98 | #[inline] 99 | pub fn id(&self) -> &PointID { 100 | &self.inner.id 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /gamelib/src/state.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Clone, Copy, PartialEq)] 2 | pub enum GameState { 3 | NotStarted, 4 | Started, 5 | Ended, 6 | } 7 | 8 | impl Default for GameState { 9 | fn default() -> Self { 10 | Self::NotStarted 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /gamelib/src/tests/fixtures/game.rs: -------------------------------------------------------------------------------- 1 | use crate::{FieldType, Game, SizeType}; 2 | 3 | pub fn create_and_start_game(field_type: FieldType, size: &SizeType, use_history: bool) -> Game { 4 | let mut game = Game::new(field_type, size, use_history).unwrap(); 5 | game.start().unwrap(); 6 | game 7 | } 8 | -------------------------------------------------------------------------------- /gamelib/src/tests/fixtures/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod game; 2 | -------------------------------------------------------------------------------- /gamelib/src/tests/mod.rs: -------------------------------------------------------------------------------- 1 | mod fixtures; 2 | 3 | mod test_cubic_sphere_builder; 4 | mod test_grid_sphere_builder; 5 | mod test_groups_merge; 6 | mod test_ko_rule; 7 | mod test_regular_field_builder; 8 | mod test_stone_remover; 9 | mod test_undo; 10 | -------------------------------------------------------------------------------- /gamelib/src/tests/test_grid_sphere_builder.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | use std::rc::Rc; 3 | 4 | use crate::{ 5 | field::{Field, FieldType, GridSphereFieldBuilder}, 6 | point::{Point, PointWrapper}, 7 | }; 8 | 9 | const COMPRESSED_FIELD: [[usize; 4]; 54] = [ 10 | [20, 9, 1, 3], 11 | [19, 0, 2, 4], 12 | [18, 1, 17, 5], 13 | [0, 10, 4, 6], 14 | [1, 3, 5, 7], 15 | [2, 4, 16, 8], 16 | [3, 11, 7, 12], 17 | [4, 6, 8, 13], 18 | [5, 7, 15, 14], 19 | [0, 20, 10, 21], 20 | [3, 9, 11, 22], 21 | [6, 10, 12, 23], 22 | [6, 11, 13, 24], 23 | [7, 12, 14, 25], 24 | [8, 13, 15, 26], 25 | [8, 14, 16, 27], 26 | [5, 15, 17, 28], 27 | [2, 16, 18, 29], 28 | [2, 17, 19, 30], 29 | [1, 18, 20, 31], 30 | [0, 19, 9, 32], 31 | [9, 31, 22, 33], 32 | [10, 21, 23, 34], 33 | [11, 22, 24, 35], 34 | [12, 23, 25, 36], 35 | [13, 24, 26, 37], 36 | [14, 25, 27, 38], 37 | [15, 26, 28, 39], 38 | [16, 27, 29, 40], 39 | [17, 28, 30, 41], 40 | [18, 29, 31, 42], 41 | [19, 30, 32, 43], 42 | [20, 31, 21, 44], 43 | [21, 34, 44, 53], 44 | [22, 35, 33, 52], 45 | [23, 36, 34, 51], 46 | [24, 37, 35, 51], 47 | [25, 38, 36, 48], 48 | [26, 39, 37, 45], 49 | [27, 40, 38, 45], 50 | [28, 41, 39, 46], 51 | [29, 42, 40, 47], 52 | [30, 43, 41, 47], 53 | [31, 44, 42, 50], 54 | [32, 33, 43, 53], 55 | [39, 46, 38, 48], 56 | [40, 47, 45, 49], 57 | [41, 42, 46, 50], 58 | [45, 49, 37, 51], 59 | [46, 50, 48, 52], 60 | [47, 43, 49, 53], 61 | [48, 52, 36, 35], 62 | [49, 53, 51, 34], 63 | [50, 44, 52, 33], 64 | ]; 65 | 66 | #[test] 67 | fn test_grid_sphere_builder_with_size_3() { 68 | let expected_field = { 69 | let points = COMPRESSED_FIELD 70 | .iter() 71 | .enumerate() 72 | .map(|(id, p)| { 73 | Rc::new(RefCell::new(PointWrapper::new( 74 | Point::new(id), 75 | Some(p[0]), 76 | Some(p[1]), 77 | Some(p[2]), 78 | Some(p[3]), 79 | ))) 80 | }) 81 | .collect(); 82 | Field::new(points, FieldType::GridSphere) 83 | }; 84 | 85 | let real = GridSphereFieldBuilder::default().with_size(&3); 86 | 87 | for id in 0..real.len() { 88 | assert_eq!( 89 | *expected_field.get_point(&id).borrow(), 90 | *real.get_point(&id).borrow() 91 | ) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /gamelib/src/tests/test_groups_merge.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | aliases::PointID, 3 | field::{CubicSphereFieldBuilder, Field}, 4 | group::Group, 5 | point::{PlayerColor, PointStatus}, 6 | }; 7 | 8 | #[test] 9 | fn test_merge_groups() { 10 | // Creating a game field 11 | let field = CubicSphereFieldBuilder::default().with_size(&5).unwrap(); 12 | 13 | // Making a couple of moves 14 | let point_id_1 = 1; 15 | let mut group_1 = make_move(&field, &point_id_1, PlayerColor::Black); 16 | let point_id_2 = field 17 | .get_neighbor_points(&point_id_1) 18 | .into_iter() 19 | .filter_map(|point| point.map(|p| *p.borrow().id())) 20 | .next() 21 | .unwrap(); 22 | let mut group_2 = make_move(&field, &point_id_2, PlayerColor::Black); 23 | 24 | let expected_group = { 25 | let mut group_1 = group_1.clone(); 26 | 27 | // Removing intersections between them 28 | group_1.liberties = &group_1.liberties - &group_2.points_ids; 29 | group_2.liberties = &group_2.liberties - &group_1.points_ids; 30 | 31 | // Merging them 32 | group_1.points_ids = &group_1.points_ids | &group_2.points_ids; 33 | group_1.liberties = &group_1.liberties | &group_2.liberties; 34 | 35 | group_1 36 | }; 37 | 38 | // Merging groups 39 | group_1 |= group_2.clone(); 40 | 41 | assert_eq!(group_1.points_ids, expected_group.points_ids); 42 | assert_eq!(group_1.liberties, expected_group.liberties); 43 | } 44 | 45 | /// Mock function to make moves without validations at all 46 | fn make_move(field: &Field, point_id: &PointID, color: PlayerColor) -> Group { 47 | field.get_point(point_id).borrow_mut().inner.status = PointStatus::Occupied(color.clone()); 48 | Group::new(point_id, field, &color) 49 | } 50 | -------------------------------------------------------------------------------- /gamelib/src/tests/test_ko_rule.rs: -------------------------------------------------------------------------------- 1 | use crate::{errors::GameError, FieldType}; 2 | 3 | use super::fixtures::game::create_and_start_game; 4 | 5 | #[test] 6 | fn test_get_blocking_error() { 7 | // Creating and starting game 8 | let mut game = create_and_start_game(FieldType::GridSphere, &5, false); 9 | 10 | /* 11 | Starting creating 12 | 13 | Reminder: 14 | 0 1 2 3 4 15 | 5 6 7 8 9 16 | 10 11 12 13 14 17 | 15 16 17 18 19 18 | 20 21 22 23 24 19 | */ 20 | { 21 | // Creating a typical case 22 | let moves = [7, 8, 11, 14, 17, 18, 13]; 23 | for id in moves { 24 | game.make_move(&id).unwrap(); 25 | } 26 | 27 | // Making first killing move 28 | let result = game.make_move(&12); 29 | assert!(result.is_ok()); 30 | 31 | // Trying to kill blocked stone 32 | // We must get blocking error here 33 | let result = game.make_move(&13); 34 | assert!(matches!(result, Err(GameError::PointBlocked(13)))); 35 | 36 | // Trying to put stone at another points by both players 37 | // like a case when one player had made a Ko-threat and another didn't miss it 38 | for id in [68, 69] { 39 | assert!(game.make_move(&id).is_ok()) 40 | } 41 | 42 | // Trying to put by the player that previously got the error a stone at the same point 43 | let result = game.make_move(&13); 44 | assert!(result.is_ok(), "{}", result.unwrap_err()); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /gamelib/src/tests/test_regular_field_builder.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | use std::rc::Rc; 3 | 4 | use crate::{ 5 | field::{Field, FieldType, RegularFieldBuilder}, 6 | point::{Point, PointWrapper}, 7 | }; 8 | 9 | const COMPRESSED_FIELD: [[Option; 4]; 25] = [ 10 | [None, None, Some(1), Some(5)], 11 | [None, Some(0), Some(2), Some(6)], 12 | [None, Some(1), Some(3), Some(7)], 13 | [None, Some(2), Some(4), Some(8)], 14 | [None, Some(3), None, Some(9)], 15 | [Some(0), None, Some(6), Some(10)], 16 | [Some(1), Some(5), Some(7), Some(11)], 17 | [Some(2), Some(6), Some(8), Some(12)], 18 | [Some(3), Some(7), Some(9), Some(13)], 19 | [Some(4), Some(8), None, Some(14)], 20 | [Some(5), None, Some(11), Some(15)], 21 | [Some(6), Some(10), Some(12), Some(16)], 22 | [Some(7), Some(11), Some(13), Some(17)], 23 | [Some(8), Some(12), Some(14), Some(18)], 24 | [Some(9), Some(13), None, Some(19)], 25 | [Some(10), None, Some(16), Some(20)], 26 | [Some(11), Some(15), Some(17), Some(21)], 27 | [Some(12), Some(16), Some(18), Some(22)], 28 | [Some(13), Some(17), Some(19), Some(23)], 29 | [Some(14), Some(18), None, Some(24)], 30 | [Some(15), None, Some(21), None], 31 | [Some(16), Some(20), Some(22), None], 32 | [Some(17), Some(21), Some(23), None], 33 | [Some(18), Some(22), Some(24), None], 34 | [Some(19), Some(23), None, None], 35 | ]; 36 | 37 | #[test] 38 | fn test_regular_field_builder_with_size_5() { 39 | let expected_field = { 40 | let points = COMPRESSED_FIELD 41 | .iter() 42 | .enumerate() 43 | .map(|(id, p)| { 44 | Rc::new(RefCell::new(PointWrapper::new( 45 | Point::new(id), 46 | p[0], 47 | p[1], 48 | p[2], 49 | p[3], 50 | ))) 51 | }) 52 | .collect(); 53 | Field::new(points, FieldType::Regular) 54 | }; 55 | 56 | let real = RegularFieldBuilder::default().with_size(&5); 57 | 58 | for id in 0..real.len() { 59 | assert_eq!( 60 | *expected_field.get_point(&id).borrow(), 61 | *real.get_point(&id).borrow() 62 | ) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /gamelib/src/tests/test_stone_remover.rs: -------------------------------------------------------------------------------- 1 | use crate::{point::PointStatus, FieldType}; 2 | 3 | use super::fixtures::game::create_and_start_game; 4 | 5 | #[test] 6 | fn test_remove_one_stone() { 7 | // Creating and starting game 8 | let mut game = create_and_start_game(FieldType::GridSphere, &10, false); 9 | 10 | /* 11 | Starting creating 12 | 13 | Considering the white is to be useless 14 | and the black is to surround the first white stone till it's dead 15 | */ 16 | { 17 | let white_stone_to_die = 25; 18 | let moves = [15, white_stone_to_die, 24, 100, 26, 101]; 19 | for id in moves { 20 | let deadlist = game.make_move(&id).unwrap(); 21 | assert_eq!(deadlist.len(), 0) 22 | } 23 | 24 | let white_groups_count = game.inner.white_groups.len(); 25 | 26 | // Making killing move 27 | let deadlist = game.make_move(&35).unwrap(); 28 | 29 | assert_eq!(deadlist.len(), 1); 30 | assert_eq!(game.inner.white_groups.len() + 1, white_groups_count); 31 | assert!(matches!( 32 | game.inner 33 | .field 34 | .get_point(&white_stone_to_die) 35 | .borrow() 36 | .inner 37 | .status, 38 | PointStatus::Empty 39 | )) 40 | } 41 | } 42 | 43 | #[test] 44 | fn test_remove_one_stone_at_border() { 45 | // Creating and starting game 46 | let mut game = create_and_start_game(FieldType::GridSphere, &5, false); 47 | 48 | /* 49 | Starting creating 50 | 51 | Considering the white is to be useless 52 | and the black is to surround the first white stone till it's dead 53 | */ 54 | { 55 | let moves = [139, 122, 121, 134, 123, 144]; 56 | for id in moves { 57 | let deadlist = game.make_move(&id).unwrap(); 58 | assert_eq!(deadlist.len(), 0) 59 | } 60 | 61 | // Making killing move 62 | let deadlist = game.make_move(&102).unwrap(); 63 | assert_eq!(deadlist.len(), 1) 64 | } 65 | 66 | // for id in 0..(5_usize.pow(2) * 6) { 67 | // println!("{:?}", game.inner.field.get_point(&id).borrow()); 68 | // } 69 | // assert!(false) 70 | } 71 | 72 | #[test] 73 | fn test_refreshing_enemies_liberties_after_losing_group() { 74 | // Creating and starting game 75 | let mut game = create_and_start_game(FieldType::GridSphere, &5, false); 76 | 77 | // Making moves 78 | let moves = [ 79 | 42, 81, 82, 60, 63, 41, 61, 62, 101, 83, 80, 100, 61, 79, 40, 81, 3, 102, 80, 59, 120, 81, 80 | 121, 81 | ]; 82 | for id in moves { 83 | game.make_move(&id).unwrap(); 84 | } 85 | 86 | // Making a killing move 87 | let result = game.make_move(&62); 88 | assert!(result.is_ok(), "{}", result.unwrap_err()); 89 | let deadlist = result.unwrap(); 90 | assert_eq!(deadlist.len(), 1); 91 | } 92 | -------------------------------------------------------------------------------- /gamelib/src/tests/test_undo.rs: -------------------------------------------------------------------------------- 1 | use super::fixtures::game::create_and_start_game; 2 | use crate::FieldType; 3 | 4 | #[test] 5 | fn test_undo() { 6 | // Creating and starting game 7 | let mut game = create_and_start_game(FieldType::Regular, &5, true); 8 | 9 | /* 10 | Reminder: 11 | 0 1 2 3 4 12 | 5 6 7 8 9 13 | 10 11 12 13 14 14 | 15 16 17 18 19 15 | 20 21 22 23 24 16 | */ 17 | { 18 | // Creating a typical case 19 | let moves = [7, 8, 11, 14, 12, 18, 13, 3]; 20 | for id in moves { 21 | game.make_move(&id).unwrap(); 22 | } 23 | 24 | // Freezing a game 25 | let freezed_game = game.inner.clone(); 26 | 27 | // Making one more move 28 | game.make_move(&15).unwrap(); 29 | 30 | // Making undo 31 | assert!(game.undo_move().is_ok()); 32 | 33 | // Comparing games 34 | assert_eq!(game.inner, freezed_game) 35 | } 36 | } 37 | 38 | #[test] 39 | fn test_undo_with_clear_history() { 40 | // Creating and starting game 41 | let mut game = create_and_start_game(FieldType::GridSphere, &5, true); 42 | 43 | // Trying to undo move 44 | assert!(game.undo_move().is_err()); 45 | } 46 | -------------------------------------------------------------------------------- /server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "server" 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 | tokio = { version = "1.23.0", features = ["full"] } 10 | axum = { version = "0.6.2", features = ["ws"] } 11 | tower-http = { version = "0.3.5", features = ["cors", "trace"], default_features = false } 12 | tracing = "0.1.37" 13 | tracing-subscriber = { version = "0.3.16", features = ["env-filter"]} 14 | futures = "0.3.25" 15 | 16 | serde = { version = "1.0.152", features = [ "derive" ] } 17 | serde_json = "1.0.91" 18 | 19 | sea-orm = { version = "^0", features = [ "sqlx-postgres", "runtime-tokio-rustls", "macros", "postgres-array" ] } 20 | 21 | thiserror = "1.0.38" 22 | 23 | uuid = { version = "1.2.2", features = ["serde", "v4"] } 24 | 25 | entity = {path="./libs/entity"} 26 | migration = {path="./libs/migration"} 27 | spherical_go_game_lib = {path="../gamelib"} 28 | 29 | [dev-dependencies] 30 | axum-test-helper = "0.2.0" 31 | serde_json = "1.0.91" 32 | -------------------------------------------------------------------------------- /server/libs/entity/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "entity" 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 | sea-orm = { version = "^0", features = [ "sqlx-postgres", "runtime-tokio-rustls", "macros", "postgres-array" ] } 10 | 11 | serde = { version = "1.0.152", features = [ "derive" ] } 12 | 13 | migration = {path="../migration"} 14 | spherical_go_game_lib = {path="../../../gamelib"} 15 | -------------------------------------------------------------------------------- /server/libs/entity/src/games.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.6 2 | 3 | use sea_orm::entity::prelude::*; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] 7 | #[sea_orm(table_name = "games")] 8 | pub struct Model { 9 | #[sea_orm(primary_key, auto_increment = false)] 10 | pub id: Uuid, 11 | pub is_ended: bool, 12 | } 13 | 14 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 15 | pub enum Relation { 16 | #[sea_orm(has_many = "super::histories::Entity")] 17 | Histories, 18 | #[sea_orm(has_many = "super::rooms::Entity")] 19 | Rooms, 20 | } 21 | 22 | impl Related for Entity { 23 | fn to() -> RelationDef { 24 | Relation::Histories.def() 25 | } 26 | } 27 | 28 | impl Related for Entity { 29 | fn to() -> RelationDef { 30 | Relation::Rooms.def() 31 | } 32 | } 33 | 34 | impl ActiveModelBehavior for ActiveModel {} 35 | -------------------------------------------------------------------------------- /server/libs/entity/src/histories.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.6 2 | 3 | use migration::FieldType; 4 | use sea_orm::entity::prelude::*; 5 | use serde::{Deserialize, Serialize}; 6 | use spherical_go_game_lib::{SizeType, StoredGameMeta}; 7 | 8 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] 9 | #[sea_orm(table_name = "histories")] 10 | pub struct Model { 11 | #[sea_orm(primary_key, auto_increment = false)] 12 | pub id: Uuid, 13 | pub game_id: Uuid, 14 | pub size: i16, 15 | pub field_type: FieldType, 16 | } 17 | 18 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 19 | pub enum Relation { 20 | #[sea_orm( 21 | belongs_to = "super::games::Entity", 22 | from = "Column::GameId", 23 | to = "super::games::Column::Id", 24 | on_update = "NoAction", 25 | on_delete = "Cascade" 26 | )] 27 | Games, 28 | #[sea_orm(has_many = "super::history_records::Entity")] 29 | HistoryRecords, 30 | } 31 | 32 | impl Related for Entity { 33 | fn to() -> RelationDef { 34 | Relation::Games.def() 35 | } 36 | } 37 | 38 | impl Related for Entity { 39 | fn to() -> RelationDef { 40 | Relation::HistoryRecords.def() 41 | } 42 | } 43 | 44 | impl ActiveModelBehavior for ActiveModel {} 45 | 46 | impl Into for Model { 47 | fn into(self) -> StoredGameMeta { 48 | StoredGameMeta { 49 | field_type: self.field_type.into(), 50 | size: self.size as SizeType, 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /server/libs/entity/src/history.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.6 2 | 3 | use migration::FieldType; 4 | use sea_orm::entity::prelude::*; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] 8 | #[sea_orm(table_name = "history")] 9 | pub struct Model { 10 | #[sea_orm(primary_key, auto_increment = false)] 11 | pub id: Uuid, 12 | pub game_id: Uuid, 13 | pub size: i16, 14 | pub field_type: FieldType, 15 | } 16 | 17 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 18 | pub enum Relation { 19 | #[sea_orm( 20 | belongs_to = "super::games::Entity", 21 | from = "Column::GameId", 22 | to = "super::games::Column::Id", 23 | on_update = "NoAction", 24 | on_delete = "NoAction" 25 | )] 26 | Games, 27 | } 28 | 29 | impl Related for Entity { 30 | fn to() -> RelationDef { 31 | Relation::Games.def() 32 | } 33 | } 34 | 35 | impl ActiveModelBehavior for ActiveModel {} 36 | -------------------------------------------------------------------------------- /server/libs/entity/src/history_records.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.6 2 | 3 | use std::collections::HashSet; 4 | 5 | use sea_orm::entity::prelude::*; 6 | use serde::{Deserialize, Serialize}; 7 | use spherical_go_game_lib::{PointID, StoredGameMove, StoredGameMoveType}; 8 | 9 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] 10 | #[sea_orm(table_name = "history_records")] 11 | pub struct Model { 12 | #[sea_orm(primary_key, auto_increment = false)] 13 | pub id: Uuid, 14 | pub history_id: Uuid, 15 | pub move_number: i32, 16 | pub point_id: i32, 17 | pub died_points_ids: Vec, 18 | } 19 | 20 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 21 | pub enum Relation { 22 | #[sea_orm( 23 | belongs_to = "super::histories::Entity", 24 | from = "Column::HistoryId", 25 | to = "super::histories::Column::Id", 26 | on_update = "NoAction", 27 | on_delete = "Cascade" 28 | )] 29 | Histories, 30 | } 31 | 32 | impl Related for Entity { 33 | fn to() -> RelationDef { 34 | Relation::Histories.def() 35 | } 36 | } 37 | 38 | impl ActiveModelBehavior for ActiveModel {} 39 | 40 | impl Into for Model { 41 | fn into(self) -> StoredGameMove { 42 | // TODO: update logic once you add other move types to the db table 43 | StoredGameMove { 44 | move_type: StoredGameMoveType::Move, 45 | point_id: Some(self.point_id as PointID), 46 | died: HashSet::from_iter(self.died_points_ids.into_iter().map(|p| p as PointID)), 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /server/libs/entity/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod games; 2 | pub mod histories; 3 | pub mod history_records; 4 | pub mod rooms; 5 | pub mod users; 6 | -------------------------------------------------------------------------------- /server/libs/entity/src/rooms.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.6 2 | 3 | use sea_orm::entity::prelude::*; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] 7 | #[sea_orm(table_name = "rooms")] 8 | pub struct Model { 9 | #[sea_orm(primary_key, auto_increment = false)] 10 | pub id: Uuid, 11 | pub player1_id: Uuid, 12 | pub player2_id: Option, 13 | pub game_id: Option, 14 | } 15 | 16 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 17 | pub enum Relation { 18 | #[sea_orm( 19 | belongs_to = "super::games::Entity", 20 | from = "Column::GameId", 21 | to = "super::games::Column::Id", 22 | on_update = "NoAction", 23 | on_delete = "NoAction" 24 | )] 25 | Games, 26 | #[sea_orm( 27 | belongs_to = "super::users::Entity", 28 | from = "Column::Player1Id", 29 | to = "super::users::Column::Id", 30 | on_update = "NoAction", 31 | on_delete = "NoAction" 32 | )] 33 | Users2, 34 | #[sea_orm( 35 | belongs_to = "super::users::Entity", 36 | from = "Column::Player2Id", 37 | to = "super::users::Column::Id", 38 | on_update = "NoAction", 39 | on_delete = "NoAction" 40 | )] 41 | Users1, 42 | } 43 | 44 | impl Related for Entity { 45 | fn to() -> RelationDef { 46 | Relation::Games.def() 47 | } 48 | } 49 | 50 | impl ActiveModelBehavior for ActiveModel {} 51 | -------------------------------------------------------------------------------- /server/libs/entity/src/users.rs: -------------------------------------------------------------------------------- 1 | //! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.6 2 | 3 | use sea_orm::entity::prelude::*; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] 7 | #[sea_orm(table_name = "users")] 8 | pub struct Model { 9 | #[sea_orm(primary_key, auto_increment = false)] 10 | pub id: Uuid, 11 | pub username: String, 12 | pub secure_id: Uuid, 13 | } 14 | 15 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] 16 | pub enum Relation {} 17 | 18 | impl ActiveModelBehavior for ActiveModel {} 19 | -------------------------------------------------------------------------------- /server/libs/migration/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "migration" 3 | version = "0.1.0" 4 | edition = "2021" 5 | publish = false 6 | 7 | [lib] 8 | name = "migration" 9 | path = "src/lib.rs" 10 | 11 | [dependencies] 12 | async-std = { version = "^1", features = ["attributes", "tokio1"] } 13 | 14 | serde = { version = "1.0.152", features = [ "derive" ] } 15 | 16 | spherical_go_game_lib = {path="../../../gamelib"} 17 | 18 | [dependencies.sea-orm-migration] 19 | version = "^0.10.0" 20 | features = [ "runtime-tokio-rustls", "sqlx-postgres" ] 21 | -------------------------------------------------------------------------------- /server/libs/migration/README.md: -------------------------------------------------------------------------------- 1 | # Running Migrator CLI 2 | 3 | - Generate a new migration file 4 | ```sh 5 | cargo run -- migrate generate MIGRATION_NAME 6 | ``` 7 | - Apply all pending migrations 8 | ```sh 9 | cargo run 10 | ``` 11 | ```sh 12 | cargo run -- up 13 | ``` 14 | - Apply first 10 pending migrations 15 | ```sh 16 | cargo run -- up -n 10 17 | ``` 18 | - Rollback last applied migrations 19 | ```sh 20 | cargo run -- down 21 | ``` 22 | - Rollback last 10 applied migrations 23 | ```sh 24 | cargo run -- down -n 10 25 | ``` 26 | - Drop all tables from the database, then reapply all migrations 27 | ```sh 28 | cargo run -- fresh 29 | ``` 30 | - Rollback all applied migrations, then reapply all migrations 31 | ```sh 32 | cargo run -- refresh 33 | ``` 34 | - Rollback all applied migrations 35 | ```sh 36 | cargo run -- reset 37 | ``` 38 | - Check the status of all migrations 39 | ```sh 40 | cargo run -- status 41 | ``` 42 | -------------------------------------------------------------------------------- /server/libs/migration/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub use sea_orm_migration::prelude::*; 2 | 3 | mod m20220101_000001_create_table; 4 | mod m20230103_213731_games; 5 | mod m20230104_155530_rooms; 6 | mod m20230106_155413_histories; 7 | mod m20230108_204629_history_records; 8 | 9 | pub use m20230106_155413_histories::FieldType; 10 | 11 | pub struct Migrator; 12 | 13 | #[async_trait::async_trait] 14 | impl MigratorTrait for Migrator { 15 | fn migrations() -> Vec> { 16 | vec![ 17 | Box::new(m20220101_000001_create_table::Migration), 18 | Box::new(m20230103_213731_games::Migration), 19 | Box::new(m20230104_155530_rooms::Migration), 20 | Box::new(m20230106_155413_histories::Migration), 21 | Box::new(m20230108_204629_history_records::Migration), 22 | ] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /server/libs/migration/src/m20220101_000001_create_table.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::prelude::*; 2 | 3 | #[derive(DeriveMigrationName)] 4 | pub struct Migration; 5 | 6 | #[async_trait::async_trait] 7 | impl MigrationTrait for Migration { 8 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 9 | manager 10 | .create_table( 11 | Table::create() 12 | .table(User::Table) 13 | .if_not_exists() 14 | .col(ColumnDef::new(User::Id).uuid().not_null().primary_key()) 15 | .col(ColumnDef::new(User::Username).string_len(15).not_null()) 16 | .col(ColumnDef::new(User::SecureId).uuid().not_null()) 17 | .to_owned(), 18 | ) 19 | .await 20 | } 21 | 22 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 23 | manager 24 | .drop_table(Table::drop().table(User::Table).to_owned()) 25 | .await 26 | } 27 | } 28 | 29 | /// Learn more at https://docs.rs/sea-query#iden 30 | #[derive(Iden)] 31 | #[iden = "users"] 32 | pub(crate) enum User { 33 | Table, 34 | Id, 35 | Username, 36 | #[iden = "secure_id"] 37 | SecureId, 38 | } 39 | -------------------------------------------------------------------------------- /server/libs/migration/src/m20230103_213731_games.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::prelude::*; 2 | 3 | #[derive(DeriveMigrationName)] 4 | pub struct Migration; 5 | 6 | #[async_trait::async_trait] 7 | impl MigrationTrait for Migration { 8 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 9 | manager 10 | .create_table( 11 | Table::create() 12 | .table(Game::Table) 13 | .if_not_exists() 14 | .col(ColumnDef::new(Game::Id).uuid().not_null().primary_key()) 15 | .col(ColumnDef::new(Game::IsEnded).boolean().not_null()) 16 | .to_owned(), 17 | ) 18 | .await 19 | } 20 | 21 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 22 | manager 23 | .drop_table(Table::drop().table(Game::Table).to_owned()) 24 | .await 25 | } 26 | } 27 | 28 | /// Learn more at https://docs.rs/sea-query#iden 29 | #[derive(Iden)] 30 | #[iden = "games"] 31 | pub(crate) enum Game { 32 | Table, 33 | Id, 34 | IsEnded, 35 | } 36 | -------------------------------------------------------------------------------- /server/libs/migration/src/m20230104_155530_rooms.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::prelude::*; 2 | 3 | use crate::{m20220101_000001_create_table::User, m20230103_213731_games::Game}; 4 | 5 | #[derive(DeriveMigrationName)] 6 | pub struct Migration; 7 | 8 | #[async_trait::async_trait] 9 | impl MigrationTrait for Migration { 10 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 11 | manager 12 | .create_table( 13 | Table::create() 14 | .table(Room::Table) 15 | .if_not_exists() 16 | .col(ColumnDef::new(Room::Id).uuid().not_null().primary_key()) 17 | .col(ColumnDef::new(Room::Player1Id).uuid().not_null()) 18 | .foreign_key( 19 | ForeignKey::create() 20 | .name("fk-room-player1-id") 21 | .from_col(Room::Player1Id) 22 | .to(User::Table, User::Id), 23 | ) 24 | .col(ColumnDef::new(Room::Player2Id).uuid()) 25 | .foreign_key( 26 | ForeignKey::create() 27 | .name("fk-room-player2-id") 28 | .from_col(Room::Player2Id) 29 | .to(User::Table, User::Id), 30 | ) 31 | .col(ColumnDef::new(Room::GameId).uuid()) 32 | .foreign_key( 33 | ForeignKey::create() 34 | .name("fk-room-game-id") 35 | .from_col(Room::GameId) 36 | .to(Game::Table, Game::Id), 37 | ) 38 | .to_owned(), 39 | ) 40 | .await 41 | } 42 | 43 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 44 | manager 45 | .drop_table(Table::drop().table(Room::Table).to_owned()) 46 | .await 47 | } 48 | } 49 | 50 | /// Learn more at https://docs.rs/sea-query#iden 51 | #[derive(Iden)] 52 | #[iden = "rooms"] 53 | enum Room { 54 | Table, 55 | Id, 56 | 57 | #[iden = "player1_id"] 58 | Player1Id, 59 | 60 | #[iden = "player2_id"] 61 | Player2Id, 62 | 63 | #[iden = "game_id"] 64 | GameId, 65 | } 66 | -------------------------------------------------------------------------------- /server/libs/migration/src/m20230106_155413_histories.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::{ 2 | prelude::*, 3 | sea_orm::{DeriveActiveEnum, EnumIter}, 4 | }; 5 | use serde::{Deserialize, Serialize}; 6 | use spherical_go_game_lib::FieldType as GameLibFieldType; 7 | 8 | use crate::m20230103_213731_games::Game; 9 | 10 | #[derive(DeriveMigrationName)] 11 | pub struct Migration; 12 | 13 | #[async_trait::async_trait] 14 | impl MigrationTrait for Migration { 15 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 16 | manager 17 | .create_table( 18 | Table::create() 19 | .table(History::Table) 20 | .if_not_exists() 21 | .col(ColumnDef::new(History::Id).uuid().not_null().primary_key()) 22 | .col(ColumnDef::new(History::GameId).uuid().not_null()) 23 | .foreign_key( 24 | ForeignKey::create() 25 | .name("fk-history-game-id") 26 | .on_delete(ForeignKeyAction::Cascade) 27 | .from_col(History::GameId) 28 | .to(Game::Table, Game::Id), 29 | ) 30 | .col(ColumnDef::new(History::Size).small_integer().not_null()) 31 | .col( 32 | ColumnDef::new(History::FieldType) 33 | .small_integer() 34 | .not_null(), 35 | ) 36 | .to_owned(), 37 | ) 38 | .await 39 | } 40 | 41 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 42 | manager 43 | .drop_table(Table::drop().table(History::Table).to_owned()) 44 | .await 45 | } 46 | } 47 | 48 | /// Learn more at https://docs.rs/sea-query#iden 49 | #[derive(Iden)] 50 | #[iden = "histories"] 51 | pub enum History { 52 | Table, 53 | Id, 54 | #[iden = "game_id"] 55 | GameId, 56 | Size, 57 | #[iden = "field_type"] 58 | FieldType, 59 | } 60 | 61 | #[derive(Debug, Clone, PartialEq, EnumIter, DeriveActiveEnum, Eq, Serialize, Deserialize)] 62 | #[sea_orm(rs_type = "i16", db_type = "SmallInteger")] 63 | pub enum FieldType { 64 | #[sea_orm(num_value = 0)] 65 | Regular, 66 | #[sea_orm(num_value = 1)] 67 | GridSphere, 68 | } 69 | 70 | impl Into for FieldType { 71 | fn into(self) -> GameLibFieldType { 72 | match self { 73 | Self::Regular => GameLibFieldType::Regular, 74 | Self::GridSphere => GameLibFieldType::GridSphere, 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /server/libs/migration/src/m20230108_204629_history_records.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::prelude::*; 2 | 3 | use crate::m20230106_155413_histories::History; 4 | 5 | #[derive(DeriveMigrationName)] 6 | pub struct Migration; 7 | 8 | #[async_trait::async_trait] 9 | impl MigrationTrait for Migration { 10 | async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { 11 | manager 12 | .create_table( 13 | Table::create() 14 | .table(HistoryRecord::Table) 15 | .if_not_exists() 16 | .col( 17 | ColumnDef::new(HistoryRecord::Id) 18 | .uuid() 19 | .not_null() 20 | .primary_key(), 21 | ) 22 | .col(ColumnDef::new(HistoryRecord::HistoryID).uuid().not_null()) 23 | .foreign_key( 24 | ForeignKey::create() 25 | .name("fk-history_record-history-id") 26 | .on_delete(ForeignKeyAction::Cascade) 27 | .from_col(HistoryRecord::HistoryID) 28 | .to(History::Table, History::Id), 29 | ) 30 | .col( 31 | ColumnDef::new(HistoryRecord::MoveNumber) 32 | .integer() 33 | .not_null(), 34 | ) 35 | .col(ColumnDef::new(HistoryRecord::PointID).integer().not_null()) 36 | .col( 37 | ColumnDef::new(HistoryRecord::DiedPointsIds) 38 | .array(ColumnType::Integer(None)) 39 | .not_null(), 40 | ) 41 | .to_owned(), 42 | ) 43 | .await 44 | } 45 | 46 | async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { 47 | manager 48 | .drop_table(Table::drop().table(HistoryRecord::Table).to_owned()) 49 | .await 50 | } 51 | } 52 | 53 | /// Learn more at https://docs.rs/sea-query#iden 54 | #[derive(Iden)] 55 | #[iden = "history_records"] 56 | enum HistoryRecord { 57 | Table, 58 | Id, 59 | #[iden = "history_id"] 60 | HistoryID, 61 | #[iden = "move_number"] 62 | MoveNumber, 63 | #[iden = "point_id"] 64 | PointID, 65 | #[iden = "died_points_ids"] 66 | DiedPointsIds, 67 | } 68 | -------------------------------------------------------------------------------- /server/libs/migration/src/main.rs: -------------------------------------------------------------------------------- 1 | use sea_orm_migration::prelude::*; 2 | 3 | #[async_std::main] 4 | async fn main() { 5 | cli::run_cli(migration::Migrator).await; 6 | } 7 | -------------------------------------------------------------------------------- /server/src/app.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::Router; 4 | use tower_http::{ 5 | cors, 6 | trace::{self, TraceLayer}, 7 | }; 8 | use tracing::Level; 9 | 10 | use crate::{ 11 | apps::{games::routers::GamesRouter, rooms::routers::RoomsRouter, users::routers::UsersRouter}, 12 | common::{config::Mode, routing::app_state::AppState}, 13 | }; 14 | 15 | pub fn create_app(app_state: Arc) -> Router { 16 | let dgs_cors = cors::CorsLayer::new() 17 | .allow_methods(cors::Any) 18 | .allow_headers(cors::Any) 19 | .allow_origin( 20 | app_state 21 | .config 22 | .allowed_origins 23 | .iter() 24 | .map(|origin| origin.parse().unwrap()) 25 | .collect::>(), 26 | ); 27 | 28 | let mode = app_state.config.mode.clone(); 29 | 30 | let router = Router::new() 31 | .nest("/users", UsersRouter::get_router(app_state.clone())) 32 | .nest("/games", GamesRouter::get_router(app_state.clone())) 33 | .nest("/rooms", RoomsRouter::get_router(app_state)) 34 | .layer( 35 | TraceLayer::new_for_http() 36 | .make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO)) 37 | .on_response(trace::DefaultOnResponse::new().level(Level::INFO)), 38 | ); 39 | 40 | if matches!(mode, Mode::Dev) { 41 | router.layer(dgs_cors) 42 | } else { 43 | router 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /server/src/apps/games/mod.rs: -------------------------------------------------------------------------------- 1 | mod repositories; 2 | pub mod routers; 3 | pub mod schemas; 4 | mod services; 5 | -------------------------------------------------------------------------------- /server/src/apps/games/repositories.rs: -------------------------------------------------------------------------------- 1 | use entity::games; 2 | use sea_orm::{ActiveModelTrait, ActiveValue, DbConn, EntityTrait}; 3 | use uuid; 4 | 5 | use crate::common::errors::{DGSError, DGSResult}; 6 | 7 | pub struct GamesRepository<'a> { 8 | db: &'a DbConn, 9 | } 10 | 11 | impl<'a> GamesRepository<'a> { 12 | pub fn new(db: &'a DbConn) -> Self { 13 | Self { db } 14 | } 15 | 16 | pub async fn create(&self) -> DGSResult { 17 | let game = games::ActiveModel { 18 | id: ActiveValue::Set(uuid::Uuid::new_v4()), 19 | is_ended: ActiveValue::Set(false), 20 | }; 21 | let game = game.insert(self.db).await?; 22 | 23 | Ok(game) 24 | } 25 | 26 | pub async fn list(&self) -> DGSResult> { 27 | Ok(games::Entity::find().all(self.db).await?) 28 | } 29 | 30 | pub async fn get(&self, game_id: uuid::Uuid) -> DGSResult { 31 | Ok(games::Entity::find_by_id(game_id) 32 | .one(self.db) 33 | .await? 34 | .ok_or(DGSError::NotFound(format!("game with id {game_id}")))? 35 | .into()) 36 | } 37 | 38 | pub async fn delete(&self, game_id: uuid::Uuid) -> DGSResult<()> { 39 | let res = games::Entity::delete_by_id(game_id).exec(self.db).await?; 40 | 41 | if res.rows_affected == 1 { 42 | Ok(()) 43 | } else { 44 | Err(DGSError::NotFound(format!("game with id {game_id}"))) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /server/src/apps/games/schemas.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use entity::games::Model as Game; 4 | use migration::FieldType; 5 | use serde::{Deserialize, Serialize}; 6 | use spherical_go_game_lib::{PointID, SizeType}; 7 | use tokio::sync::broadcast; 8 | 9 | use crate::apps::{histories::schemas::HistoryWithRecords, users::schemas::OutUserSchema}; 10 | 11 | #[derive(Debug, Deserialize)] 12 | pub struct CreateGameSchema { 13 | pub room_id: uuid::Uuid, 14 | pub field_type: FieldType, 15 | pub size: SizeType, 16 | } 17 | 18 | #[derive(Debug, Serialize)] 19 | pub struct GameWithHistorySchema { 20 | pub game: Game, 21 | pub history: HistoryWithRecords, 22 | } 23 | 24 | #[derive(Debug, Serialize, Deserialize)] 25 | pub struct GameWithWSLink { 26 | pub game: Game, 27 | pub ws_link: String, 28 | } 29 | 30 | impl GameWithWSLink { 31 | pub fn get_ws_link(game: &Game) -> String { 32 | format!("ws://{}", game.id) 33 | } 34 | } 35 | 36 | impl From for GameWithWSLink { 37 | fn from(game: Game) -> Self { 38 | let ws_link = Self::get_ws_link(&game); 39 | Self { game, ws_link } 40 | } 41 | } 42 | 43 | #[derive(Debug, Serialize, Deserialize, Clone)] 44 | pub struct MoveSchema { 45 | pub game_id: uuid::Uuid, 46 | pub point_id: PointID, 47 | } 48 | 49 | /// Internal message between receiving and sending tasks 50 | /// 51 | /// It defines whether to send msg to all the room users or to a single one only 52 | #[derive(Debug, Clone)] 53 | pub struct InternalMsg { 54 | pub receiver_id: Option, 55 | msg: String, 56 | } 57 | 58 | impl InternalMsg { 59 | pub fn new(receiver_id: Option, msg: String) -> Self { 60 | Self { receiver_id, msg } 61 | } 62 | 63 | pub fn get_msg(&self) -> String { 64 | self.msg.clone() 65 | } 66 | } 67 | 68 | #[derive(Debug, Clone)] 69 | pub struct RoomPlayer { 70 | pub player: OutUserSchema, 71 | pub is_connected: bool, 72 | } 73 | 74 | impl RoomPlayer { 75 | pub fn new(player: OutUserSchema, is_connected: bool) -> Self { 76 | Self { 77 | player, 78 | is_connected, 79 | } 80 | } 81 | } 82 | 83 | #[derive(Debug, Clone)] 84 | pub struct RoomState { 85 | pub room_id: uuid::Uuid, 86 | pub black_player: RoomPlayer, 87 | pub white_player: RoomPlayer, 88 | pub tx: broadcast::Sender, 89 | } 90 | 91 | #[derive(Debug, Serialize, Deserialize)] 92 | pub struct MoveWithResult { 93 | pub point_id: PointID, 94 | pub died_stones_ids: HashSet, 95 | } 96 | 97 | impl MoveWithResult { 98 | pub fn new(point_id: PointID, died_stones_ids: HashSet) -> Self { 99 | Self { 100 | point_id, 101 | died_stones_ids, 102 | } 103 | } 104 | } 105 | 106 | #[derive(Debug, Serialize, Deserialize)] 107 | pub struct WSError { 108 | pub error: String, 109 | } 110 | 111 | impl WSError { 112 | pub fn new(e: String) -> Self { 113 | Self { error: e } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /server/src/apps/games/services.rs: -------------------------------------------------------------------------------- 1 | use sea_orm::DbConn; 2 | use spherical_go_game_lib::Game as Gamelib; 3 | use tokio::sync::broadcast; 4 | 5 | use super::schemas::{CreateGameSchema, GameWithHistorySchema, MoveSchema, RoomPlayer, RoomState}; 6 | use crate::{ 7 | apps::{ 8 | games::{repositories::GamesRepository, schemas::GameWithWSLink}, 9 | histories::{ 10 | repositories::HistoriesRepository, 11 | schemas::{CreateHistoryRecordSchema, CreateHistorySchema, MoveResult}, 12 | }, 13 | rooms::repositories::RoomsRepository, 14 | users::repositories::UsersRepository, 15 | }, 16 | common::{ 17 | errors::{DGSError, DGSResult}, 18 | routing::auth::AuthenticatedUser, 19 | }, 20 | }; 21 | 22 | /// Main game process service 23 | pub struct GameService<'a> { 24 | repo: GamesRepository<'a>, 25 | rooms_repo: RoomsRepository<'a>, 26 | histories_repo: HistoriesRepository<'a>, 27 | users_repo: UsersRepository<'a>, 28 | } 29 | 30 | impl<'a> GameService<'a> { 31 | pub fn new(db: &'a DbConn) -> Self { 32 | let repo = GamesRepository::new(db); 33 | let rooms_repo = RoomsRepository::new(db); 34 | let histories_repo = HistoriesRepository::new(db); 35 | let users_repo = UsersRepository::new(db); 36 | Self { 37 | repo, 38 | rooms_repo, 39 | histories_repo, 40 | users_repo, 41 | } 42 | } 43 | 44 | pub async fn start_game( 45 | &self, 46 | schema: CreateGameSchema, 47 | user: AuthenticatedUser, 48 | ) -> DGSResult { 49 | // TODO: turn this into atomic transaction 50 | 51 | let room = self.rooms_repo.get(schema.room_id).await?; 52 | 53 | // Validation 54 | { 55 | // Checking if a user is a first player so he has permissions to start game 56 | if user.user_id != room.player1_id { 57 | return Err(DGSError::UserIsNotPlayer1); 58 | } 59 | 60 | // Checking if there're all the players in a room 61 | if matches!(room.player2_id, None) { 62 | return Err(DGSError::Player2IsNone); 63 | } 64 | 65 | // Checking if game is already started 66 | if matches!(room.game_id, Some(_)) { 67 | return Err(DGSError::GameAlreadyStarted); 68 | } 69 | } 70 | 71 | // Creating a game 72 | let game = self.repo.create().await?; 73 | 74 | // Attaching it to the room 75 | self.rooms_repo.attach_game(room, game.id).await?; 76 | 77 | // Creating a game history 78 | { 79 | let history = CreateHistorySchema::new(game.id, schema.field_type, schema.size); 80 | self.histories_repo.create(&history).await?; 81 | } 82 | 83 | Ok(game.into()) 84 | } 85 | 86 | pub async fn make_move( 87 | &self, 88 | move_schema: &MoveSchema, 89 | user: AuthenticatedUser, 90 | ) -> DGSResult { 91 | // Getting room 92 | let room = self.rooms_repo.get_by_game_id(move_schema.game_id).await?; 93 | 94 | // Checking if user is one of room players 95 | if user.user_id != room.player1_id && Some(user.user_id) != room.player2_id { 96 | return Err(DGSError::UserIsNotRoomPlayer); 97 | } 98 | 99 | // Getting game and history 100 | let game = self.repo.get(move_schema.game_id).await?; 101 | let history = self 102 | .histories_repo 103 | .get_by_game_id(move_schema.game_id) 104 | .await?; 105 | let history_id = history.history.id; 106 | let history_len = history.records.len(); 107 | 108 | // Validating 109 | { 110 | // Checking if game is ended 111 | if game.is_ended { 112 | return Err(DGSError::GameEnded); 113 | } 114 | 115 | // Checking if player can make a move 116 | match history_len % 2 { 117 | 0 if user.user_id != room.player1_id => return Err(DGSError::NotPlayerTurn), 118 | 1 if user.user_id == room.player1_id => return Err(DGSError::NotPlayerTurn), 119 | _ => (), 120 | }; 121 | } 122 | 123 | // Finally making move 124 | let died_stones = { 125 | let mut game = Gamelib::new_from_history(history.into())?; 126 | game.make_move(&move_schema.point_id)? 127 | }; 128 | 129 | // Saving result as a history record 130 | { 131 | let move_number = history_len + 1; 132 | let record = &CreateHistoryRecordSchema::new( 133 | history_id, 134 | move_number, 135 | move_schema.point_id, 136 | died_stones.clone(), 137 | ); 138 | self.histories_repo.create_record(record).await? 139 | }; 140 | 141 | Ok(MoveResult::new(died_stones)) 142 | } 143 | 144 | pub async fn undo_move(&self, user: AuthenticatedUser) -> DGSResult<()> { 145 | unimplemented!("undo move is currently unemplemented") 146 | } 147 | 148 | pub async fn get_room_state(&self, room_id: uuid::Uuid) -> DGSResult { 149 | // Fetching from db 150 | let room = self.rooms_repo.get(room_id).await?; 151 | let black_user = self.users_repo.get_out_user(room.player1_id).await?; 152 | let white_user = self 153 | .users_repo 154 | .get_out_user(room.player2_id.ok_or(DGSError::Unknown)?) 155 | .await?; 156 | 157 | // Creating a channel 158 | let (tx, _rx) = broadcast::channel(2); 159 | 160 | let black_user = RoomPlayer::new(black_user, true); 161 | let white_user = RoomPlayer::new(white_user, false); 162 | 163 | let room_state = RoomState { 164 | room_id: room.id, 165 | black_player: black_user, 166 | white_player: white_user, 167 | tx, 168 | }; 169 | 170 | Ok(room_state) 171 | } 172 | 173 | pub async fn get_game_with_history( 174 | &self, 175 | game_id: uuid::Uuid, 176 | ) -> DGSResult { 177 | let game = self.repo.get(game_id).await?; 178 | let history = self.histories_repo.get_by_game_id(game_id).await?; 179 | Ok(GameWithHistorySchema { game, history }) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /server/src/apps/histories/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod repositories; 2 | pub mod schemas; 3 | -------------------------------------------------------------------------------- /server/src/apps/histories/repositories.rs: -------------------------------------------------------------------------------- 1 | use entity::{histories, history_records}; 2 | use sea_orm::{ 3 | ActiveModelTrait, ActiveValue, ColumnTrait, DbConn, EntityTrait, QueryFilter, QueryOrder, 4 | }; 5 | 6 | use super::schemas::{CreateHistoryRecordSchema, CreateHistorySchema, HistoryWithRecords}; 7 | use crate::common::errors::{DGSError, DGSResult}; 8 | 9 | pub struct HistoriesRepository<'a> { 10 | db: &'a DbConn, 11 | } 12 | 13 | impl<'a> HistoriesRepository<'a> { 14 | pub fn new(db: &'a DbConn) -> Self { 15 | Self { db } 16 | } 17 | 18 | pub async fn create(&self, history: &CreateHistorySchema) -> DGSResult { 19 | let history = histories::ActiveModel { 20 | id: ActiveValue::Set(uuid::Uuid::new_v4()), 21 | game_id: ActiveValue::Set(history.game_id), 22 | field_type: ActiveValue::Set(history.field_type.clone()), 23 | size: ActiveValue::Set(history.size.into()), 24 | }; 25 | 26 | let history = history.insert(self.db).await?; 27 | 28 | Ok(history) 29 | } 30 | 31 | pub async fn create_record( 32 | &self, 33 | record: &CreateHistoryRecordSchema, 34 | ) -> DGSResult { 35 | let record = history_records::ActiveModel { 36 | id: ActiveValue::Set(uuid::Uuid::new_v4()), 37 | history_id: ActiveValue::Set(record.history_id), 38 | move_number: ActiveValue::Set(record.move_number as i32), 39 | point_id: ActiveValue::Set(record.point_id as i32), 40 | died_points_ids: ActiveValue::Set( 41 | record 42 | .died_stones_ids 43 | .iter() 44 | .map(|rec| *rec as i32) 45 | .collect(), 46 | ), 47 | }; 48 | 49 | let record = record.insert(self.db).await?; 50 | 51 | Ok(record) 52 | } 53 | 54 | pub async fn get_by_game_id(&self, game_id: uuid::Uuid) -> DGSResult { 55 | // Getting a history itself 56 | let history = histories::Entity::find() 57 | .filter(histories::Column::GameId.eq(game_id)) 58 | .one(self.db) 59 | .await? 60 | .ok_or(DGSError::NotFound(format!( 61 | "history with game id {game_id}" 62 | )))?; 63 | 64 | // Getting its records 65 | let records = history_records::Entity::find() 66 | .filter(history_records::Column::HistoryId.eq(history.id)) 67 | .order_by_asc(history_records::Column::MoveNumber) 68 | .all(self.db) 69 | .await?; 70 | 71 | Ok(HistoryWithRecords { history, records }) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /server/src/apps/histories/schemas.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use entity::{histories, history_records}; 4 | use migration::FieldType; 5 | use serde::Serialize; 6 | use spherical_go_game_lib::{PointID, SizeType, StoredGame}; 7 | 8 | #[derive(Debug)] 9 | pub struct CreateHistorySchema { 10 | pub game_id: uuid::Uuid, 11 | pub field_type: FieldType, 12 | pub size: SizeType, 13 | } 14 | 15 | impl CreateHistorySchema { 16 | pub fn new(game_id: uuid::Uuid, field_type: FieldType, size: SizeType) -> Self { 17 | Self { 18 | game_id, 19 | field_type, 20 | size, 21 | } 22 | } 23 | } 24 | 25 | #[derive(Debug)] 26 | pub struct CreateHistoryRecordSchema { 27 | pub history_id: uuid::Uuid, 28 | pub move_number: usize, 29 | pub point_id: PointID, 30 | pub died_stones_ids: HashSet, 31 | } 32 | 33 | impl CreateHistoryRecordSchema { 34 | pub fn new( 35 | history_id: uuid::Uuid, 36 | move_number: usize, 37 | point_id: PointID, 38 | died_stones_ids: HashSet, 39 | ) -> Self { 40 | Self { 41 | history_id, 42 | move_number, 43 | point_id, 44 | died_stones_ids, 45 | } 46 | } 47 | } 48 | 49 | #[derive(Debug, Serialize)] 50 | pub struct HistoryWithRecords { 51 | pub history: histories::Model, 52 | pub records: Vec, 53 | } 54 | 55 | impl Into for HistoryWithRecords { 56 | fn into(self) -> StoredGame { 57 | StoredGame { 58 | meta: self.history.into(), 59 | moves: self.records.into_iter().map(|rec| rec.into()).collect(), 60 | } 61 | } 62 | } 63 | 64 | #[derive(Debug, Serialize)] 65 | pub struct MoveResult { 66 | pub died_stones_ids: HashSet, 67 | } 68 | 69 | impl MoveResult { 70 | pub fn new(died_stones_ids: HashSet) -> Self { 71 | Self { died_stones_ids } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /server/src/apps/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod games; 2 | pub mod histories; 3 | pub mod rooms; 4 | pub mod users; 5 | -------------------------------------------------------------------------------- /server/src/apps/rooms/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod repositories; 2 | pub mod routers; 3 | mod services; 4 | -------------------------------------------------------------------------------- /server/src/apps/rooms/repositories.rs: -------------------------------------------------------------------------------- 1 | use entity::rooms; 2 | use sea_orm::{ActiveModelTrait, ActiveValue, ColumnTrait, DbConn, EntityTrait, QueryFilter}; 3 | use uuid; 4 | 5 | use crate::common::{ 6 | errors::{DGSError, DGSResult}, 7 | routing::auth::AuthenticatedUser, 8 | }; 9 | 10 | pub struct RoomsRepository<'a> { 11 | db: &'a DbConn, 12 | } 13 | 14 | impl<'a> RoomsRepository<'a> { 15 | pub fn new(db: &'a DbConn) -> Self { 16 | Self { db } 17 | } 18 | 19 | pub async fn create(&self, user_id: uuid::Uuid) -> DGSResult { 20 | let room = rooms::ActiveModel { 21 | id: ActiveValue::Set(uuid::Uuid::new_v4()), 22 | player1_id: ActiveValue::Set(user_id), 23 | ..Default::default() 24 | }; 25 | let room = room.insert(self.db).await?; 26 | 27 | Ok(room) 28 | } 29 | 30 | pub async fn list(&self) -> DGSResult> { 31 | Ok(rooms::Entity::find().all(self.db).await?) 32 | } 33 | 34 | pub async fn get(&self, room_id: uuid::Uuid) -> DGSResult { 35 | Ok(rooms::Entity::find_by_id(room_id) 36 | .one(self.db) 37 | .await? 38 | .ok_or(DGSError::NotFound(format!("room with id {room_id}")))? 39 | .into()) 40 | } 41 | 42 | pub async fn get_by_game_id(&self, game_id: uuid::Uuid) -> DGSResult { 43 | Ok(rooms::Entity::find() 44 | .filter(rooms::Column::GameId.eq(game_id)) 45 | .one(self.db) 46 | .await? 47 | .ok_or(DGSError::NotFound(format!("room with game id {game_id}")))? 48 | .into()) 49 | } 50 | 51 | pub async fn delete(&self, room_id: uuid::Uuid) -> DGSResult<()> { 52 | let res = rooms::Entity::delete_by_id(room_id).exec(self.db).await?; 53 | 54 | if res.rows_affected == 1 { 55 | Ok(()) 56 | } else { 57 | Err(DGSError::NotFound(format!("room with id {room_id}"))) 58 | } 59 | } 60 | 61 | pub async fn add_player2( 62 | &self, 63 | room_id: uuid::Uuid, 64 | user: AuthenticatedUser, 65 | ) -> DGSResult { 66 | // Checking if the game already has a second player 67 | let game = self.get(room_id).await?; 68 | if matches!(game.player2_id, Some(_)) { 69 | return Err(DGSError::CannotAddPlayer); 70 | } 71 | 72 | // Adding a player 73 | let mut game: rooms::ActiveModel = game.into(); 74 | game.player2_id = ActiveValue::Set(Some(user.user_id)); 75 | 76 | // Updating it 77 | Ok(game.update(self.db).await?) 78 | } 79 | 80 | pub async fn attach_game( 81 | &self, 82 | room: rooms::Model, 83 | game_id: uuid::Uuid, 84 | ) -> DGSResult { 85 | let mut room: rooms::ActiveModel = room.into(); 86 | room.game_id = ActiveValue::Set(Some(game_id)); 87 | 88 | Ok(room.update(self.db).await?) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /server/src/apps/rooms/routers.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{ 4 | extract::{Path, State}, 5 | http::StatusCode, 6 | response::IntoResponse, 7 | routing::{get, patch, post}, 8 | Json, Router, 9 | }; 10 | 11 | use super::services::RoomService; 12 | use crate::common::routing::{app_state::AppState, auth::AuthenticatedUser}; 13 | 14 | pub struct RoomsRouter; 15 | 16 | impl RoomsRouter { 17 | pub fn get_router(state: Arc) -> Router { 18 | Router::new() 19 | .route("/", post(Self::create_room)) 20 | .route("/:room_id", get(Self::get_room)) 21 | .route("/:room_id/enter", patch(Self::accept_invitation)) 22 | .with_state(state) 23 | } 24 | 25 | pub async fn create_room( 26 | State(state): State>, 27 | user: AuthenticatedUser, 28 | ) -> impl IntoResponse { 29 | let room = RoomService::new(&state.db).create(user.user_id).await?; 30 | Ok::<_, (StatusCode, String)>((StatusCode::CREATED, Json(room))) 31 | } 32 | 33 | #[allow(dead_code)] 34 | pub async fn list_rooms(State(state): State>) -> impl IntoResponse { 35 | let rooms = RoomService::new(&state.db).list().await?; 36 | Ok::<_, (StatusCode, String)>((StatusCode::OK, Json(rooms))) 37 | } 38 | 39 | pub async fn get_room( 40 | State(state): State>, 41 | Path(room_id): Path, 42 | ) -> impl IntoResponse { 43 | let room = RoomService::new(&state.db).get(room_id).await?; 44 | Ok::<_, (StatusCode, String)>((StatusCode::OK, Json(room))) 45 | } 46 | 47 | pub async fn accept_invitation( 48 | State(state): State>, 49 | Path(room_id): Path, 50 | user: AuthenticatedUser, 51 | ) -> impl IntoResponse { 52 | let room = RoomService::new(&state.db) 53 | .accept_invitation(room_id, user) 54 | .await?; 55 | Ok::<_, (StatusCode, String)>((StatusCode::ACCEPTED, Json(room))) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /server/src/apps/rooms/services.rs: -------------------------------------------------------------------------------- 1 | use entity::rooms; 2 | use sea_orm::DbConn; 3 | 4 | use super::repositories::RoomsRepository; 5 | use crate::common::{errors::DGSResult, routing::auth::AuthenticatedUser}; 6 | 7 | pub struct RoomService<'a> { 8 | repo: RoomsRepository<'a>, 9 | } 10 | 11 | impl<'a> RoomService<'a> { 12 | pub fn new(db: &'a DbConn) -> Self { 13 | let repo = RoomsRepository::new(db); 14 | Self { repo } 15 | } 16 | 17 | pub async fn create(&self, user_id: uuid::Uuid) -> DGSResult { 18 | self.repo.create(user_id).await 19 | } 20 | 21 | pub async fn list(&self) -> DGSResult> { 22 | self.repo.list().await 23 | } 24 | 25 | pub async fn get(&self, room_id: uuid::Uuid) -> DGSResult { 26 | self.repo.get(room_id).await 27 | } 28 | 29 | #[allow(dead_code)] 30 | pub async fn delete(&self, room_id: uuid::Uuid) -> DGSResult<()> { 31 | self.repo.delete(room_id).await 32 | } 33 | 34 | pub async fn accept_invitation( 35 | &self, 36 | room_id: uuid::Uuid, 37 | user: AuthenticatedUser, 38 | ) -> DGSResult { 39 | self.repo.add_player2(room_id, user).await 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /server/src/apps/users/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod repositories; 2 | pub mod routers; 3 | pub mod schemas; 4 | pub mod services; 5 | -------------------------------------------------------------------------------- /server/src/apps/users/repositories.rs: -------------------------------------------------------------------------------- 1 | use entity::users; 2 | use sea_orm::{ActiveModelTrait, ActiveValue, ColumnTrait, DbConn, EntityTrait, QueryFilter}; 3 | use uuid; 4 | 5 | use super::schemas::OutUserSchema; 6 | use crate::apps::users::schemas::CreateUserSchema; 7 | use crate::common::errors::DGSError; 8 | use crate::common::errors::DGSResult; 9 | 10 | pub struct UsersRepository<'a> { 11 | db: &'a DbConn, 12 | } 13 | 14 | impl<'a> UsersRepository<'a> { 15 | pub fn new(db: &'a DbConn) -> Self { 16 | Self { db } 17 | } 18 | 19 | pub async fn create(&self, user: &CreateUserSchema) -> DGSResult { 20 | let user = users::ActiveModel { 21 | id: ActiveValue::Set(uuid::Uuid::new_v4()), 22 | username: ActiveValue::Set(user.username.clone()), 23 | secure_id: ActiveValue::Set(uuid::Uuid::new_v4()), 24 | }; 25 | 26 | let user_from_db = user.insert(self.db).await?; 27 | 28 | Ok(user_from_db) 29 | } 30 | 31 | pub async fn list(&self) -> DGSResult> { 32 | let db_users = users::Entity::find().all(self.db).await?; 33 | Ok(db_users.into_iter().map(|u| u.into()).collect()) 34 | } 35 | 36 | pub async fn get(&self, user_id: uuid::Uuid) -> DGSResult { 37 | users::Entity::find_by_id(user_id) 38 | .one(self.db) 39 | .await? 40 | .ok_or(DGSError::NotFound(format!("user with id {user_id}"))) 41 | } 42 | 43 | pub async fn get_out_user(&self, user_id: uuid::Uuid) -> DGSResult { 44 | self.get(user_id).await.map(|user| user.into()) 45 | } 46 | 47 | pub async fn delete(&self, user_id: uuid::Uuid, secure_id: uuid::Uuid) -> DGSResult<()> { 48 | let res = users::Entity::delete_by_id(user_id) 49 | .filter(users::Column::SecureId.eq(secure_id)) 50 | .exec(self.db) 51 | .await?; 52 | 53 | if res.rows_affected == 1 { 54 | Ok(()) 55 | } else { 56 | Err(DGSError::NotFound(format!( 57 | "user with id {user_id} and secure_id {secure_id}" 58 | ))) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /server/src/apps/users/routers.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{ 4 | extract::{Path, State}, 5 | http::StatusCode, 6 | response::IntoResponse, 7 | routing::get, 8 | Json, Router, 9 | }; 10 | 11 | use super::{ 12 | schemas::{CreateUserSchema, DeleteUserSchema}, 13 | services::UserService, 14 | }; 15 | use crate::common::routing::app_state::AppState; 16 | 17 | pub struct UsersRouter; 18 | 19 | impl UsersRouter { 20 | pub fn get_router(state: Arc) -> Router { 21 | Router::new() 22 | .route("/", get(Self::list_users).post(Self::create_user)) 23 | .route("/:user_id", get(Self::get_user).delete(Self::delete_user)) 24 | .with_state(state) 25 | } 26 | 27 | pub async fn create_user( 28 | State(state): State>, 29 | Json(user): Json, 30 | ) -> impl IntoResponse { 31 | let user = UserService::new(&state.db).create(&user).await?; 32 | Ok::<_, (StatusCode, String)>((StatusCode::CREATED, Json(user))) 33 | } 34 | 35 | pub async fn list_users(State(state): State>) -> impl IntoResponse { 36 | let users = UserService::new(&state.db).list().await?; 37 | Ok::<_, (StatusCode, String)>((StatusCode::OK, Json(users))) 38 | } 39 | 40 | pub async fn get_user( 41 | State(state): State>, 42 | Path(user_id): Path, 43 | ) -> impl IntoResponse { 44 | let user = UserService::new(&state.db).get(user_id).await?; 45 | Ok::<_, (StatusCode, String)>((StatusCode::OK, Json(user))) 46 | } 47 | 48 | pub async fn delete_user( 49 | State(state): State>, 50 | Path(user_id): Path, 51 | Json(delete_schema): Json, 52 | ) -> impl IntoResponse { 53 | UserService::new(&state.db) 54 | .delete(user_id, delete_schema.secure_id) 55 | .await?; 56 | Ok::<_, (StatusCode, String)>(StatusCode::NO_CONTENT) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /server/src/apps/users/schemas.rs: -------------------------------------------------------------------------------- 1 | use entity::users::Model as User; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Debug, Deserialize)] 5 | pub struct CreateUserSchema { 6 | pub username: String, 7 | } 8 | 9 | #[derive(Debug, Deserialize)] 10 | pub struct DeleteUserSchema { 11 | pub secure_id: uuid::Uuid, 12 | } 13 | 14 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 15 | pub struct OutUserSchema { 16 | pub id: uuid::Uuid, 17 | pub username: String, 18 | } 19 | 20 | impl From for OutUserSchema { 21 | fn from(value: User) -> Self { 22 | Self { 23 | id: value.id, 24 | username: value.username, 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /server/src/apps/users/services.rs: -------------------------------------------------------------------------------- 1 | use entity::users; 2 | use sea_orm::DbConn; 3 | 4 | use super::{ 5 | repositories::UsersRepository, 6 | schemas::{CreateUserSchema, OutUserSchema}, 7 | }; 8 | use crate::common::{ 9 | errors::{DGSError, DGSResult}, 10 | routing::auth::AuthenticatedUser, 11 | }; 12 | 13 | pub struct UserService<'a> { 14 | repo: UsersRepository<'a>, 15 | } 16 | 17 | impl<'a> UserService<'a> { 18 | pub fn new(db: &'a DbConn) -> Self { 19 | let repo = UsersRepository::new(db); 20 | Self { repo } 21 | } 22 | 23 | pub async fn create(&self, user: &CreateUserSchema) -> DGSResult { 24 | self.repo.create(user).await 25 | } 26 | 27 | pub async fn list(&self) -> DGSResult> { 28 | self.repo.list().await 29 | } 30 | 31 | pub async fn get(&self, user_id: uuid::Uuid) -> DGSResult { 32 | self.repo.get_out_user(user_id).await 33 | } 34 | 35 | pub async fn delete(&self, user_id: uuid::Uuid, secure_id: uuid::Uuid) -> DGSResult<()> { 36 | self.repo.delete(user_id, secure_id).await 37 | } 38 | 39 | pub async fn authenticate(&self, token: String) -> DGSResult { 40 | // Getting user token 41 | let token_user = AuthenticatedUser::decode_token(&token)?; 42 | 43 | // Getting db user 44 | let db_user = self.repo.get(token_user.user_id).await?; 45 | 46 | // Authenticating 47 | if token_user.secure_id == db_user.secure_id { 48 | Ok(token_user) 49 | } else { 50 | Err(DGSError::AuthenticationError) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /server/src/common/aliases.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /server/src/common/config.rs: -------------------------------------------------------------------------------- 1 | use std::{env, str::FromStr}; 2 | 3 | use super::errors::{DGSError, DGSResult}; 4 | #[derive(Debug, Clone)] 5 | pub struct Config { 6 | pub db_uri: String, 7 | pub port: u16, 8 | pub allowed_origins: Vec, 9 | pub mode: Mode, 10 | } 11 | 12 | impl Config { 13 | pub fn new() -> DGSResult { 14 | let db_uri = Self::get_env_var("DATABASE_URL")?; 15 | let port = Self::get_env_var("PORT")?; 16 | let allowed_origins = Self::get_env_var::("ALLOWED_ORIGINS")? 17 | .split(" ") 18 | .map(|origin| origin.to_string()) 19 | .collect(); 20 | let mode = Self::get_env_var::("MODE")?.as_str().parse()?; 21 | 22 | Ok(Self { 23 | db_uri, 24 | port, 25 | allowed_origins, 26 | mode, 27 | }) 28 | } 29 | 30 | #[inline] 31 | fn get_env_var(env_var: &str) -> DGSResult { 32 | env::var(env_var) 33 | .map_err(|_| DGSError::EnvConfigLoadingError(env_var.to_owned()))? 34 | .parse::() 35 | .map_err(|_| DGSError::EnvVarParsingError(env_var.to_owned())) 36 | } 37 | } 38 | 39 | #[derive(Debug, Clone)] 40 | pub enum Mode { 41 | Test, 42 | Dev, 43 | Prod, 44 | } 45 | 46 | impl FromStr for Mode { 47 | type Err = DGSError; 48 | 49 | fn from_str(s: &str) -> Result { 50 | match s.to_lowercase().as_str() { 51 | "test" => Ok(Self::Test), 52 | "dev" => Ok(Self::Dev), 53 | "prod" => Ok(Self::Prod), 54 | _ => Err(DGSError::EnvVarParsingError(s.to_string())), 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /server/src/common/db/connection.rs: -------------------------------------------------------------------------------- 1 | use sea_orm::{Database, DbConn}; 2 | 3 | use crate::common::errors::DGSResult; 4 | 5 | pub async fn get_db(db_uri: &str) -> DGSResult { 6 | Ok(Database::connect(db_uri).await?) 7 | } 8 | -------------------------------------------------------------------------------- /server/src/common/db/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod connection; 2 | -------------------------------------------------------------------------------- /server/src/common/errors.rs: -------------------------------------------------------------------------------- 1 | use axum::http::StatusCode; 2 | use sea_orm::error::DbErr; 3 | use serde::Serialize; 4 | use spherical_go_game_lib::errors::GameError; 5 | use thiserror::Error; 6 | 7 | #[derive(Debug, Error, Serialize)] 8 | pub enum DGSError { 9 | #[error("environment variable `{0}` is not set")] 10 | EnvConfigLoadingError(String), 11 | #[error("environment variable `{0}` cannot be parsed")] 12 | EnvVarParsingError(String), 13 | #[error("cannot establish connection with db")] 14 | DBConnectionError, 15 | #[error("not found: `{0}`")] 16 | NotFound(String), 17 | #[error("cannot decode token")] 18 | TokenDecodingError, 19 | #[error("wrong credentials")] 20 | AuthenticationError, 21 | #[error("game is already full of players")] 22 | CannotAddPlayer, 23 | #[error("only first player can start game")] 24 | UserIsNotPlayer1, 25 | #[error("you need to add a second player to start game")] 26 | Player2IsNone, 27 | #[error("game already started")] 28 | GameAlreadyStarted, 29 | #[error("user is not one of players")] 30 | UserIsNotRoomPlayer, 31 | #[error("it is not the player's turn now")] 32 | NotPlayerTurn, 33 | #[error("game is already ended")] 34 | GameEnded, 35 | 36 | #[error("{0}")] 37 | GameInternalError(String), 38 | 39 | #[error("unknown error")] 40 | Unknown, 41 | } 42 | 43 | impl From for DGSError { 44 | fn from(e: DbErr) -> Self { 45 | match e { 46 | DbErr::ConnectionAcquire => Self::DBConnectionError, 47 | DbErr::RecordNotFound(s) => Self::NotFound(s), 48 | _ => { 49 | println!("[DB Error] {e}"); 50 | Self::Unknown 51 | } 52 | } 53 | } 54 | } 55 | 56 | impl From for (StatusCode, String) { 57 | fn from(e: DGSError) -> Self { 58 | match &e { 59 | DGSError::NotFound(_) => (StatusCode::NOT_FOUND, e.to_string()), 60 | DGSError::TokenDecodingError => (StatusCode::UNAUTHORIZED, e.to_string()), 61 | DGSError::CannotAddPlayer => (StatusCode::CONFLICT, e.to_string()), 62 | DGSError::UserIsNotPlayer1 => (StatusCode::FORBIDDEN, e.to_string()), 63 | DGSError::GameAlreadyStarted => (StatusCode::CONFLICT, e.to_string()), 64 | DGSError::Player2IsNone => (StatusCode::CONFLICT, e.to_string()), 65 | _ => ( 66 | StatusCode::INTERNAL_SERVER_ERROR, 67 | "Something went wrong".to_owned(), 68 | ), 69 | } 70 | } 71 | } 72 | 73 | impl From for DGSError { 74 | fn from(e: GameError) -> Self { 75 | Self::GameInternalError(e.to_string()) 76 | } 77 | } 78 | 79 | pub type DGSResult = Result; 80 | -------------------------------------------------------------------------------- /server/src/common/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod aliases; 2 | pub mod config; 3 | pub mod db; 4 | pub mod errors; 5 | pub mod routing; 6 | -------------------------------------------------------------------------------- /server/src/common/routing/app_state.rs: -------------------------------------------------------------------------------- 1 | use sea_orm::DbConn; 2 | 3 | use crate::common::config::Config; 4 | 5 | #[derive(Debug, Clone)] 6 | pub struct AppState { 7 | pub db: DbConn, 8 | pub config: Config, 9 | } 10 | 11 | impl AppState { 12 | pub fn new(db: DbConn, config: Config) -> Self { 13 | Self { db, config } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /server/src/common/routing/auth.rs: -------------------------------------------------------------------------------- 1 | use axum::http::{request::Parts, StatusCode}; 2 | use axum::{async_trait, extract::FromRequestParts}; 3 | 4 | use crate::common::errors::{DGSError, DGSResult}; 5 | 6 | #[derive(Debug, Clone)] 7 | pub struct AuthenticatedUser { 8 | pub user_id: uuid::Uuid, 9 | pub secure_id: uuid::Uuid, 10 | } 11 | 12 | impl AuthenticatedUser { 13 | fn decode_request_parts(req: &mut Parts) -> DGSResult { 14 | // Get authorization header 15 | let authorization = Self::get_header(req)?; 16 | Self::decode_token(authorization) 17 | } 18 | 19 | pub fn decode_token(token: &str) -> DGSResult { 20 | let (user_id, secure_id) = token.split_once(':').ok_or(DGSError::TokenDecodingError)?; 21 | let user_id = uuid::Uuid::parse_str(user_id).map_err(|_| DGSError::TokenDecodingError)?; 22 | let secure_id = 23 | uuid::Uuid::parse_str(secure_id).map_err(|_| DGSError::TokenDecodingError)?; 24 | 25 | Ok(Self { user_id, secure_id }) 26 | } 27 | 28 | fn get_header(parts: &mut Parts) -> DGSResult<&str> { 29 | Ok(parts 30 | .headers 31 | .get("AUTHORIZATION") 32 | .ok_or(DGSError::TokenDecodingError)? 33 | .to_str() 34 | .map_err(|_| DGSError::TokenDecodingError)?) 35 | } 36 | } 37 | 38 | #[async_trait] 39 | impl FromRequestParts for AuthenticatedUser 40 | where 41 | S: Send + Sync, 42 | { 43 | type Rejection = (StatusCode, String); 44 | 45 | async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { 46 | Ok(Self::decode_request_parts(parts)?) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /server/src/common/routing/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod app_state; 2 | pub mod auth; 3 | -------------------------------------------------------------------------------- /server/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{net::SocketAddr, sync::Arc}; 2 | 3 | use migration::{Migrator, MigratorTrait}; 4 | 5 | use app::create_app; 6 | use common::{config::Config, db::connection::get_db, routing::app_state::AppState}; 7 | 8 | mod app; 9 | mod apps; 10 | mod common; 11 | 12 | #[tokio::main] 13 | async fn main() { 14 | tracing_subscriber::fmt() 15 | .with_target(false) 16 | .compact() 17 | .init(); 18 | 19 | // Creating config 20 | let config = Config::new().unwrap(); 21 | 22 | // Creating a db connection 23 | let db = get_db(&config.db_uri).await.unwrap(); 24 | 25 | // Running migrations 26 | Migrator::up(&db, None).await.unwrap(); 27 | 28 | // Getting url 29 | let addr = SocketAddr::from(([0, 0, 0, 0], config.port)); 30 | 31 | // Creating app_state 32 | let shared_state = { 33 | let app_state = AppState::new(db, config); 34 | Arc::new(app_state) 35 | }; 36 | 37 | // build our application with a single route 38 | let app = create_app(shared_state); 39 | 40 | // Logging about successful start 41 | tracing::info!("listening on {addr}"); 42 | 43 | // run it with hyper on localhost:3000 44 | axum::Server::bind(&addr) 45 | .serve(app.into_make_service()) 46 | .await 47 | .unwrap(); 48 | } 49 | 50 | #[cfg(test)] 51 | mod tests; 52 | -------------------------------------------------------------------------------- /server/src/tests/fixtures/create_room.rs: -------------------------------------------------------------------------------- 1 | use axum::http::StatusCode; 2 | use axum_test_helper::TestClient; 3 | use entity::rooms::Model as Room; 4 | 5 | pub async fn create_room( 6 | user_id: uuid::Uuid, 7 | user_secure_id: uuid::Uuid, 8 | client: &TestClient, 9 | ) -> Room { 10 | let auth_headers = format!("{user_id}:{user_secure_id}"); 11 | let res = client 12 | .post("/rooms") 13 | .header("AUTHORIZATION", auth_headers) 14 | .send() 15 | .await; 16 | assert_eq!(res.status(), StatusCode::CREATED); 17 | 18 | res.json().await 19 | } 20 | -------------------------------------------------------------------------------- /server/src/tests/fixtures/create_user.rs: -------------------------------------------------------------------------------- 1 | use axum::http::StatusCode; 2 | use axum_test_helper::TestClient; 3 | use entity::users::Model as User; 4 | use serde_json::json; 5 | 6 | pub async fn create_user(username: &str, client: &TestClient) -> User { 7 | let user_schema = json!({ 8 | "username": username, 9 | }); 10 | 11 | let res = client.post("/users").json(&user_schema).send().await; 12 | assert_eq!(res.status(), StatusCode::CREATED); 13 | 14 | // By deserializing response to a model that has a secure_id field 15 | // we ensure it has this field 16 | let user: User = res.json().await; 17 | assert_eq!(user.username, username); 18 | 19 | user 20 | } 21 | -------------------------------------------------------------------------------- /server/src/tests/fixtures/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod create_room; 2 | pub mod create_user; 3 | pub mod test_tools; 4 | -------------------------------------------------------------------------------- /server/src/tests/fixtures/test_tools.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum_test_helper::TestClient; 4 | use entity::{ 5 | games::Entity as Game, histories::Entity as History, history_records::Entity as HistoryRecord, 6 | rooms::Entity as Room, users::Entity as User, 7 | }; 8 | use migration::TableCreateStatement; 9 | use sea_orm::{ConnectionTrait, DbBackend, DbConn, Schema, Statement}; 10 | 11 | use crate::{ 12 | app::create_app, 13 | common::{config::Config, db::connection::get_db, routing::app_state::AppState}, 14 | }; 15 | 16 | const DBNAME: &'static str = "test"; 17 | 18 | pub struct TestTools { 19 | pub db: TestDB, 20 | pub client: TestClient, 21 | } 22 | 23 | impl TestTools { 24 | pub async fn new() -> Self { 25 | // Creating config 26 | let config = Config::new().unwrap(); 27 | 28 | // Creating test db 29 | let db = TestDB::new(&config.db_uri).await; 30 | 31 | // Running db schema setup 32 | setup_schema(&db.db).await; 33 | 34 | // Getting test client 35 | let client = create_test_client(db.db.clone(), config).await; 36 | 37 | Self { db, client } 38 | } 39 | } 40 | 41 | async fn create_test_client(db: DbConn, config: Config) -> TestClient { 42 | let app_state = AppState::new(db, config); 43 | 44 | let app = create_app(Arc::new(app_state)); 45 | TestClient::new(app) 46 | } 47 | 48 | async fn setup_schema(db: &DbConn) { 49 | // Setup Schema helper 50 | let schema = Schema::new(DbBackend::Postgres); 51 | 52 | // Creating tables 53 | let stmt: TableCreateStatement = schema.create_table_from_entity(User); 54 | db.execute(db.get_database_backend().build(&stmt)) 55 | .await 56 | .unwrap(); 57 | let stmt: TableCreateStatement = schema.create_table_from_entity(Game); 58 | db.execute(db.get_database_backend().build(&stmt)) 59 | .await 60 | .unwrap(); 61 | let stmt: TableCreateStatement = schema.create_table_from_entity(Room); 62 | db.execute(db.get_database_backend().build(&stmt)) 63 | .await 64 | .unwrap(); 65 | let stmt: TableCreateStatement = schema.create_table_from_entity(History); 66 | db.execute(db.get_database_backend().build(&stmt)) 67 | .await 68 | .unwrap(); 69 | let stmt: TableCreateStatement = schema.create_table_from_entity(HistoryRecord); 70 | db.execute(db.get_database_backend().build(&stmt)) 71 | .await 72 | .unwrap(); 73 | } 74 | 75 | // Creates DB on object cretion and deletes it on object drop 76 | pub struct TestDB { 77 | pub db_uri: String, 78 | pub db: DbConn, 79 | } 80 | 81 | impl TestDB { 82 | async fn new(db_uri: &str) -> Self { 83 | let db = get_db(db_uri).await.unwrap(); 84 | 85 | // Deleting and recreating test db 86 | for command in [ 87 | format!("DROP DATABASE IF EXISTS {DBNAME};"), 88 | format!("CREATE DATABASE {DBNAME};"), 89 | ] { 90 | db.execute(Statement::from_string(DbBackend::Postgres, command)) 91 | .await 92 | .unwrap(); 93 | } 94 | 95 | let new_db_uri = format!("{db_uri}/{DBNAME}"); 96 | 97 | // Getting new db_conn 98 | let db = get_db(&new_db_uri).await.unwrap(); 99 | 100 | Self { 101 | db_uri: new_db_uri, 102 | db, 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /server/src/tests/mod.rs: -------------------------------------------------------------------------------- 1 | mod fixtures; 2 | mod test_games_crud; 3 | mod test_rooms_crud; 4 | mod test_users_crud; 5 | -------------------------------------------------------------------------------- /server/src/tests/test_games_crud.rs: -------------------------------------------------------------------------------- 1 | use axum::http::StatusCode; 2 | use entity::rooms::Model as Room; 3 | use migration::FieldType; 4 | use serde_json::json; 5 | 6 | use crate::{ 7 | apps::{games::schemas::GameWithWSLink, histories::repositories::HistoriesRepository}, 8 | tests::fixtures::{create_room::create_room, create_user::create_user, test_tools::TestTools}, 9 | }; 10 | 11 | #[tokio::test] 12 | async fn test_rooms_crud() { 13 | let test_tools = TestTools::new().await; 14 | 15 | // Creating 2 players and room 16 | let user_1 = create_user("motherfucker", &test_tools.client).await; 17 | let user_2 = create_user("dumba", &test_tools.client).await; 18 | let room = create_room(user_1.id, user_1.secure_id, &test_tools.client).await; 19 | 20 | let starting_game_json = 21 | json!({"room_id": room.id, "field_type": FieldType::Regular, "size": 9}); 22 | 23 | // Trying to start game without 2 players 24 | { 25 | let auth_header = format!("{}:{}", user_1.id, user_1.secure_id); 26 | let res = test_tools 27 | .client 28 | .post("/games") 29 | .json(&starting_game_json) 30 | .header("AUTHORIZATION", auth_header) 31 | .send() 32 | .await; 33 | assert_eq!(res.status(), StatusCode::CONFLICT); 34 | } 35 | 36 | // Adding a second player 37 | { 38 | let url = format!("/rooms/{}/enter", room.id); 39 | let auth_header = format!("{}:{}", user_2.id, user_2.secure_id); 40 | let res = test_tools 41 | .client 42 | .patch(&url) 43 | .header("AUTHORIZATION", auth_header) 44 | .send() 45 | .await; 46 | assert_eq!(res.status(), StatusCode::ACCEPTED); 47 | 48 | let res_room: Room = res.json().await; 49 | assert_eq!(res_room.id, room.id); 50 | assert_eq!(res_room.player1_id, user_1.id); 51 | assert_eq!(res_room.player2_id, Some(user_2.id)); 52 | } 53 | 54 | // Trying to start a game by a player 2 55 | { 56 | let auth_header = format!("{}:{}", user_2.id, user_2.secure_id); 57 | let res = test_tools 58 | .client 59 | .post("/games") 60 | .json(&starting_game_json) 61 | .header("AUTHORIZATION", auth_header) 62 | .send() 63 | .await; 64 | assert_eq!(res.status(), StatusCode::FORBIDDEN); 65 | } 66 | 67 | // Trying to start a game by a player 1 68 | { 69 | let auth_header = format!("{}:{}", user_1.id, user_1.secure_id); 70 | let res = test_tools 71 | .client 72 | .post("/games") 73 | .json(&starting_game_json) 74 | .header("AUTHORIZATION", auth_header) 75 | .send() 76 | .await; 77 | assert_eq!(res.status(), StatusCode::CREATED); 78 | 79 | // Checking if a game history was created too 80 | let game: GameWithWSLink = res.json().await; 81 | let history = HistoriesRepository::new(&test_tools.db.db) 82 | .get_by_game_id(game.game.id) 83 | .await; 84 | assert!(matches!(history, Result::Ok(_))) 85 | } 86 | 87 | // Trying to start a game again 88 | { 89 | let auth_header = format!("{}:{}", user_1.id, user_1.secure_id); 90 | let res = test_tools 91 | .client 92 | .post("/games") 93 | .json(&starting_game_json) 94 | .header("AUTHORIZATION", auth_header) 95 | .send() 96 | .await; 97 | assert_eq!(res.status(), StatusCode::CONFLICT); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /server/src/tests/test_rooms_crud.rs: -------------------------------------------------------------------------------- 1 | use axum::http::StatusCode; 2 | use entity::rooms::Model as Room; 3 | 4 | use crate::tests::fixtures::{ 5 | create_room::create_room, create_user::create_user, test_tools::TestTools, 6 | }; 7 | 8 | #[tokio::test] 9 | async fn test_rooms_crud() { 10 | let test_tools = TestTools::new().await; 11 | 12 | // Creating 2 users and 2 rooms to each one 13 | let room_1 = { 14 | let user = create_user("motherfucker", &test_tools.client).await; 15 | 16 | let room = create_room(user.id, user.secure_id, &test_tools.client).await; 17 | assert_eq!(room.player1_id, user.id); 18 | assert_eq!(room.player2_id, None); 19 | room 20 | }; 21 | { 22 | let user = create_user("lmao", &test_tools.client).await; 23 | 24 | let room = create_room(user.id, user.secure_id, &test_tools.client).await; 25 | assert_eq!(room.player1_id, user.id); 26 | assert_eq!(room.player2_id, None); 27 | }; 28 | 29 | // Getting one of them 30 | { 31 | let url = format!("/rooms/{}", room_1.id); 32 | let res = test_tools.client.get(&url).send().await; 33 | assert_eq!(res.status(), StatusCode::OK); 34 | 35 | let room: Room = res.json().await; 36 | assert_eq!(room.id, room_1.id) 37 | } 38 | } 39 | 40 | #[tokio::test] 41 | async fn test_invite() { 42 | let test_tools = TestTools::new().await; 43 | 44 | // Creating 2 users and 1 room 45 | let user_1 = create_user("motherfucker", &test_tools.client).await; 46 | let user_2 = create_user("motherfucker", &test_tools.client).await; 47 | 48 | let room = create_room(user_1.id, user_1.secure_id, &test_tools.client).await; 49 | 50 | // Accepting invitation 51 | { 52 | let url = format!("/rooms/{}/enter", room.id); 53 | let auth_header = format!("{}:{}", user_2.id, user_2.secure_id); 54 | let res = test_tools 55 | .client 56 | .patch(&url) 57 | .header("AUTHORIZATION", auth_header) 58 | .send() 59 | .await; 60 | assert_eq!(res.status(), StatusCode::ACCEPTED); 61 | 62 | let res_room: Room = res.json().await; 63 | assert_eq!(res_room.id, room.id); 64 | assert_eq!(res_room.player1_id, user_1.id); 65 | assert_eq!(res_room.player2_id, Some(user_2.id)); 66 | } 67 | 68 | // Trying to do it again and we must get an error 69 | { 70 | let url = format!("/rooms/{}/enter", room.id); 71 | let auth_header = format!("{}:{}", user_2.id, user_2.secure_id); 72 | let res = test_tools 73 | .client 74 | .patch(&url) 75 | .header("AUTHORIZATION", auth_header) 76 | .send() 77 | .await; 78 | assert_eq!(res.status(), StatusCode::CONFLICT); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /server/src/tests/test_users_crud.rs: -------------------------------------------------------------------------------- 1 | use axum::http::StatusCode; 2 | use serde_json::json; 3 | 4 | use super::fixtures::{create_user::create_user, test_tools::TestTools}; 5 | use crate::apps::users::schemas::OutUserSchema; 6 | 7 | #[tokio::test] 8 | async fn test_create_and_get_user() { 9 | let test_tools = TestTools::new().await; 10 | 11 | // Creating a user 12 | let user = create_user("test_user", &test_tools.client).await; 13 | 14 | // Getting him 15 | { 16 | let url = format!("/users/{}", &user.id); 17 | let res = test_tools.client.get(&url).send().await; 18 | assert_eq!(res.status(), StatusCode::OK); 19 | 20 | // By deserializing response to a schema that doesn't have a secure_id field 21 | // we ensure it doesn't have it 22 | let out_user: OutUserSchema = res.json().await; 23 | assert_eq!(out_user.id, user.id); 24 | assert_eq!(out_user.username, user.username); 25 | } 26 | } 27 | 28 | #[tokio::test] 29 | async fn test_get_and_list_users() { 30 | let test_tools = TestTools::new().await; 31 | 32 | // Creating users 33 | let user_1 = create_user("test_user_1", &test_tools.client).await; 34 | let user_2 = create_user("test_user_2", &test_tools.client).await; 35 | 36 | // Getting 1 user 37 | { 38 | let url = format!("/users/{}", &user_1.id); 39 | let res = test_tools.client.get(&url).send().await; 40 | assert_eq!(res.status(), StatusCode::OK); 41 | 42 | // By deserializing response to a schema that doesn't have a secure_id field 43 | // we ensure it doesn't have it 44 | let out_user: OutUserSchema = res.json().await; 45 | assert_eq!(out_user.id, user_1.id); 46 | assert_eq!(out_user.username, user_1.username); 47 | } 48 | 49 | // Getting both 50 | { 51 | let res = test_tools.client.get("/users").send().await; 52 | assert_eq!(res.status(), StatusCode::OK); 53 | 54 | // By deserializing response to a schema that doesn't have a secure_id field 55 | // we ensure it doesn't have it 56 | let out_users: [OutUserSchema; 2] = res.json().await; 57 | let real_users = [user_1, user_2].map(|u| u.into()); 58 | assert_eq!(out_users, real_users); 59 | } 60 | 61 | // Trying to get a user that doesn't exist 62 | { 63 | let doesnt_exist_user_id = uuid::Uuid::new_v4(); 64 | let url = format!("/users/{}", &doesnt_exist_user_id); 65 | let res = test_tools.client.get(&url).send().await; 66 | assert_eq!(res.status(), StatusCode::NOT_FOUND); 67 | } 68 | } 69 | 70 | #[tokio::test] 71 | async fn test_delete_user() { 72 | let test_tools = TestTools::new().await; 73 | 74 | // Creating users 75 | let user_1 = create_user("test_user_1", &test_tools.client).await; 76 | let user_2 = create_user("test_user_2", &test_tools.client).await; 77 | 78 | // Deleting 1 user 79 | { 80 | // Deleting the user 81 | let delete_schema = json!({"secure_id": user_1.secure_id}); 82 | let url = format!("/users/{}", &user_1.id); 83 | let res = test_tools 84 | .client 85 | .delete(&url) 86 | .json(&delete_schema) 87 | .send() 88 | .await; 89 | assert_eq!(res.status(), StatusCode::NO_CONTENT); 90 | 91 | // Ensuring we don't have the user anymore 92 | let res = test_tools.client.get("/users").send().await; 93 | assert_eq!(res.status(), StatusCode::OK); 94 | 95 | let out_users: [OutUserSchema; 1] = res.json().await; 96 | let real_users = [user_2].map(|u| u.into()); 97 | assert_eq!(out_users, real_users); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /wasm/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "bumpalo" 7 | version = "3.10.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "37ccbd214614c6783386c1af30caf03192f17891059cecc394b4fb119e363de3" 10 | 11 | [[package]] 12 | name = "cfg-if" 13 | version = "1.0.0" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 16 | 17 | [[package]] 18 | name = "either" 19 | version = "1.7.0" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "3f107b87b6afc2a64fd13cac55fe06d6c8859f12d4b14cbcdd2c67d0976781be" 22 | 23 | [[package]] 24 | name = "itertools" 25 | version = "0.10.3" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "a9a9d19fa1e79b6215ff29b9d6880b706147f16e9b1dbb1e4e5947b5b02bc5e3" 28 | dependencies = [ 29 | "either", 30 | ] 31 | 32 | [[package]] 33 | name = "itoa" 34 | version = "1.0.4" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "4217ad341ebadf8d8e724e264f13e593e0648f5b3e94b3896a5df283be015ecc" 37 | 38 | [[package]] 39 | name = "js-sys" 40 | version = "0.3.59" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "258451ab10b34f8af53416d1fdab72c22e805f0c92a1136d59470ec0b11138b2" 43 | dependencies = [ 44 | "wasm-bindgen", 45 | ] 46 | 47 | [[package]] 48 | name = "log" 49 | version = "0.4.17" 50 | source = "registry+https://github.com/rust-lang/crates.io-index" 51 | checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" 52 | dependencies = [ 53 | "cfg-if", 54 | ] 55 | 56 | [[package]] 57 | name = "once_cell" 58 | version = "1.13.0" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "18a6dbe30758c9f83eb00cbea4ac95966305f5a7772f3f42ebfc7fc7eddbd8e1" 61 | 62 | [[package]] 63 | name = "proc-macro2" 64 | version = "1.0.47" 65 | source = "registry+https://github.com/rust-lang/crates.io-index" 66 | checksum = "5ea3d908b0e36316caf9e9e2c4625cdde190a7e6f440d794667ed17a1855e725" 67 | dependencies = [ 68 | "unicode-ident", 69 | ] 70 | 71 | [[package]] 72 | name = "quote" 73 | version = "1.0.20" 74 | source = "registry+https://github.com/rust-lang/crates.io-index" 75 | checksum = "3bcdf212e9776fbcb2d23ab029360416bb1706b1aea2d1a5ba002727cbcab804" 76 | dependencies = [ 77 | "proc-macro2", 78 | ] 79 | 80 | [[package]] 81 | name = "ryu" 82 | version = "1.0.11" 83 | source = "registry+https://github.com/rust-lang/crates.io-index" 84 | checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09" 85 | 86 | [[package]] 87 | name = "serde" 88 | version = "1.0.150" 89 | source = "registry+https://github.com/rust-lang/crates.io-index" 90 | checksum = "e326c9ec8042f1b5da33252c8a37e9ffbd2c9bef0155215b6e6c80c790e05f91" 91 | dependencies = [ 92 | "serde_derive", 93 | ] 94 | 95 | [[package]] 96 | name = "serde_derive" 97 | version = "1.0.150" 98 | source = "registry+https://github.com/rust-lang/crates.io-index" 99 | checksum = "42a3df25b0713732468deadad63ab9da1f1fd75a48a15024b50363f128db627e" 100 | dependencies = [ 101 | "proc-macro2", 102 | "quote", 103 | "syn", 104 | ] 105 | 106 | [[package]] 107 | name = "serde_json" 108 | version = "1.0.89" 109 | source = "registry+https://github.com/rust-lang/crates.io-index" 110 | checksum = "020ff22c755c2ed3f8cf162dbb41a7268d934702f3ed3631656ea597e08fc3db" 111 | dependencies = [ 112 | "itoa", 113 | "ryu", 114 | "serde", 115 | ] 116 | 117 | [[package]] 118 | name = "spherical_go_game_lib" 119 | version = "0.1.0" 120 | dependencies = [ 121 | "itertools", 122 | "serde", 123 | "serde_json", 124 | "thiserror", 125 | ] 126 | 127 | [[package]] 128 | name = "syn" 129 | version = "1.0.105" 130 | source = "registry+https://github.com/rust-lang/crates.io-index" 131 | checksum = "60b9b43d45702de4c839cb9b51d9f529c5dd26a4aff255b42b1ebc03e88ee908" 132 | dependencies = [ 133 | "proc-macro2", 134 | "quote", 135 | "unicode-ident", 136 | ] 137 | 138 | [[package]] 139 | name = "thiserror" 140 | version = "1.0.31" 141 | source = "registry+https://github.com/rust-lang/crates.io-index" 142 | checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a" 143 | dependencies = [ 144 | "thiserror-impl", 145 | ] 146 | 147 | [[package]] 148 | name = "thiserror-impl" 149 | version = "1.0.31" 150 | source = "registry+https://github.com/rust-lang/crates.io-index" 151 | checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a" 152 | dependencies = [ 153 | "proc-macro2", 154 | "quote", 155 | "syn", 156 | ] 157 | 158 | [[package]] 159 | name = "unicode-ident" 160 | version = "1.0.2" 161 | source = "registry+https://github.com/rust-lang/crates.io-index" 162 | checksum = "15c61ba63f9235225a22310255a29b806b907c9b8c964bcbd0a2c70f3f2deea7" 163 | 164 | [[package]] 165 | name = "wasm-bindgen" 166 | version = "0.2.82" 167 | source = "registry+https://github.com/rust-lang/crates.io-index" 168 | checksum = "fc7652e3f6c4706c8d9cd54832c4a4ccb9b5336e2c3bd154d5cccfbf1c1f5f7d" 169 | dependencies = [ 170 | "cfg-if", 171 | "wasm-bindgen-macro", 172 | ] 173 | 174 | [[package]] 175 | name = "wasm-bindgen-backend" 176 | version = "0.2.82" 177 | source = "registry+https://github.com/rust-lang/crates.io-index" 178 | checksum = "662cd44805586bd52971b9586b1df85cdbbd9112e4ef4d8f41559c334dc6ac3f" 179 | dependencies = [ 180 | "bumpalo", 181 | "log", 182 | "once_cell", 183 | "proc-macro2", 184 | "quote", 185 | "syn", 186 | "wasm-bindgen-shared", 187 | ] 188 | 189 | [[package]] 190 | name = "wasm-bindgen-macro" 191 | version = "0.2.82" 192 | source = "registry+https://github.com/rust-lang/crates.io-index" 193 | checksum = "b260f13d3012071dfb1512849c033b1925038373aea48ced3012c09df952c602" 194 | dependencies = [ 195 | "quote", 196 | "wasm-bindgen-macro-support", 197 | ] 198 | 199 | [[package]] 200 | name = "wasm-bindgen-macro-support" 201 | version = "0.2.82" 202 | source = "registry+https://github.com/rust-lang/crates.io-index" 203 | checksum = "5be8e654bdd9b79216c2929ab90721aa82faf65c48cdf08bdc4e7f51357b80da" 204 | dependencies = [ 205 | "proc-macro2", 206 | "quote", 207 | "syn", 208 | "wasm-bindgen-backend", 209 | "wasm-bindgen-shared", 210 | ] 211 | 212 | [[package]] 213 | name = "wasm-bindgen-shared" 214 | version = "0.2.82" 215 | source = "registry+https://github.com/rust-lang/crates.io-index" 216 | checksum = "6598dd0bd3c7d51095ff6531a5b23e02acdc81804e30d8f07afb77b7215a140a" 217 | 218 | [[package]] 219 | name = "wasm_gamelib" 220 | version = "0.1.0" 221 | dependencies = [ 222 | "js-sys", 223 | "spherical_go_game_lib", 224 | "wasm-bindgen", 225 | ] 226 | -------------------------------------------------------------------------------- /wasm/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "wasm_gamelib" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["cdylib"] 8 | 9 | [dependencies] 10 | wasm-bindgen = "0.2.82" 11 | js-sys = "0.3.59" 12 | 13 | spherical_go_game_lib = {path = "../gamelib"} 14 | -------------------------------------------------------------------------------- /wasm/rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "nightly" 3 | components = [] 4 | targets = [] 5 | profile = "default" 6 | -------------------------------------------------------------------------------- /wasm/src/lib.rs: -------------------------------------------------------------------------------- 1 | use wasm_bindgen::prelude::*; 2 | 3 | use spherical_go_game_lib::{ 4 | FieldType as InnerFieldType, Game as InnerGame, PlayerColor, PointID, SizeType, 5 | }; 6 | 7 | type GameResult = Result; 8 | 9 | #[wasm_bindgen] 10 | #[derive(Debug, Clone, Copy)] 11 | pub enum Player { 12 | Black = "Black", 13 | White = "White", 14 | } 15 | 16 | impl From for Player { 17 | fn from(p: PlayerColor) -> Self { 18 | match p { 19 | PlayerColor::Black => Self::Black, 20 | PlayerColor::White => Self::White, 21 | } 22 | } 23 | } 24 | 25 | #[wasm_bindgen] 26 | #[derive(Debug, Clone, Copy)] 27 | pub enum FieldType { 28 | Regular = "Regular", 29 | CubicSphere = "CubicSphere ", 30 | GridSphere = "GridSphere", 31 | } 32 | 33 | impl From for InnerFieldType { 34 | fn from(f: FieldType) -> Self { 35 | match f { 36 | FieldType::Regular => Self::Regular, 37 | FieldType::GridSphere => Self::GridSphere, 38 | FieldType::CubicSphere => Self::CubicSphere, 39 | _ => panic!("Wrong variant for a field"), 40 | } 41 | } 42 | } 43 | 44 | impl From for FieldType { 45 | fn from(f: InnerFieldType) -> Self { 46 | match f { 47 | InnerFieldType::Regular => Self::Regular, 48 | InnerFieldType::GridSphere => Self::GridSphere, 49 | InnerFieldType::CubicSphere => Self::CubicSphere, 50 | } 51 | } 52 | } 53 | 54 | #[wasm_bindgen] 55 | pub struct Game { 56 | inner: InnerGame, 57 | } 58 | 59 | #[wasm_bindgen] 60 | impl Game { 61 | /// Create the game 62 | #[wasm_bindgen(constructor)] 63 | pub fn new(field_type: FieldType, size: SizeType, use_history: bool) -> GameResult { 64 | let inner = InnerGame::new(field_type.into(), &size, use_history)?; 65 | Ok(Self { inner }) 66 | } 67 | 68 | /// Make a move 69 | pub fn make_move(&mut self, point_id: PointID) -> GameResult> { 70 | let deadlist = self.inner.make_move(&point_id)?; 71 | Ok(deadlist.into_iter().collect()) 72 | } 73 | 74 | // Undo previous move 75 | pub fn undo_move(&mut self) -> GameResult<()> { 76 | self.inner.undo_move()?; 77 | Ok(()) 78 | } 79 | 80 | /// Start game 81 | pub fn start(&mut self) -> GameResult<()> { 82 | self.inner.start()?; 83 | Ok(()) 84 | } 85 | 86 | /// End game 87 | pub fn end(&mut self) -> GameResult<()> { 88 | self.inner.end()?; 89 | Ok(()) 90 | } 91 | 92 | pub fn is_not_started(&self) -> bool { 93 | self.inner.is_not_started() 94 | } 95 | 96 | pub fn is_started(&self) -> bool { 97 | self.inner.is_started() 98 | } 99 | 100 | pub fn is_ended(&self) -> bool { 101 | self.inner.is_ended() 102 | } 103 | 104 | pub fn get_black_stones(&self) -> Vec { 105 | self.inner.get_black_stones() 106 | } 107 | 108 | pub fn get_white_stones(&self) -> Vec { 109 | self.inner.get_white_stones() 110 | } 111 | 112 | pub fn get_black_score(&self) -> Option { 113 | self.inner.get_black_score() 114 | } 115 | 116 | pub fn get_white_score(&self) -> Option { 117 | self.inner.get_white_score() 118 | } 119 | 120 | pub fn player_turn(&self) -> Option { 121 | self.inner.player_turn().map(|p| Player::from(p)) 122 | } 123 | 124 | pub fn field_type(&self) -> FieldType { 125 | self.inner.field_type().into() 126 | } 127 | 128 | pub fn get_move_number(&self) -> Option { 129 | self.inner.get_move_number() 130 | } 131 | } 132 | --------------------------------------------------------------------------------