├── rustfmt.toml ├── types_2048 ├── .gitignore ├── src │ ├── lib.rs │ ├── blue.rs │ ├── com.rs │ ├── com │ │ ├── atproto.rs │ │ └── atproto │ │ │ ├── repo.rs │ │ │ └── repo │ │ │ └── strong_ref.rs │ ├── blue │ │ ├── _2048 │ │ │ ├── key │ │ │ │ ├── player.rs │ │ │ │ ├── game.rs │ │ │ │ ├── player │ │ │ │ │ └── stats.rs │ │ │ │ └── defs.rs │ │ │ ├── key.rs │ │ │ ├── player.rs │ │ │ ├── verification.rs │ │ │ ├── player │ │ │ │ ├── profile.rs │ │ │ │ └── stats.rs │ │ │ ├── defs.rs │ │ │ ├── game.rs │ │ │ └── verification │ │ │ │ ├── game.rs │ │ │ │ ├── stats.rs │ │ │ │ └── defs.rs │ │ └── _2048.rs │ └── record.rs ├── Cargo.toml ├── lexicons │ ├── com │ │ └── atproto │ │ │ └── repo │ │ │ └── strongRef.json │ └── blue │ │ └── 2048 │ │ ├── key │ │ ├── game.json │ │ ├── stats.json │ │ └── defs.json │ │ ├── verification │ │ ├── game.json │ │ ├── stats.json │ │ └── defs.json │ │ ├── player │ │ ├── profile.json │ │ └── stats.json │ │ ├── defs.json │ │ └── game.json └── lexicon_docs_draft.yml ├── appview_2048 ├── .gitignore ├── Dev.toml ├── Cargo.toml └── src │ └── main.rs ├── client_2048 ├── .gitignore ├── src │ ├── components │ │ ├── mod.rs │ │ └── theme_picker.rs │ ├── pages │ │ ├── mod.rs │ │ ├── callback.rs │ │ ├── seed.rs │ │ ├── login.rs │ │ ├── stats.rs │ │ └── history.rs │ ├── bin │ │ ├── worker.rs │ │ └── app.rs │ ├── store.rs │ ├── resolver.rs │ ├── tailwind.css │ ├── oauth_client.rs │ ├── atrium_stores.rs │ ├── lib.rs │ ├── idb.rs │ └── agent.rs ├── .cargo │ └── config.toml ├── icon.png ├── bacon.toml ├── favicons │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ └── android-chrome-512x512.png ├── assets │ └── imgs │ │ └── banner.png ├── Trunk.toml ├── package.json ├── manifest.json ├── LICENSE-MIT ├── Cargo.toml ├── index.html ├── service_worker.js ├── README.md └── LICENSE-APACHE ├── .dockerignore ├── .gitignore ├── .vscode └── settings.json ├── rust-toolchain.toml ├── production_configs ├── Caddyfile └── client_metadata.json ├── .envrc ├── dockerfiles ├── AppView.Dockerfile └── Caddy.Dockerfile ├── README.md ├── Cargo.toml ├── admin_2048 ├── Cargo.toml └── src │ └── main.rs ├── prod-docker-compose.yml ├── justfile ├── roadmap.md ├── LICENSE ├── flake.nix ├── flake.lock └── CONTRIBUTING.md /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2024" -------------------------------------------------------------------------------- /types_2048/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /appview_2048/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /client_2048/.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | /target/ 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | target 2 | **/target 3 | **/.idea 4 | .idea -------------------------------------------------------------------------------- /client_2048/src/components/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod theme_picker; 2 | -------------------------------------------------------------------------------- /appview_2048/Dev.toml: -------------------------------------------------------------------------------- 1 | [http_api_server] 2 | bind_address = "127.0.0.1:8081" 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .idea 3 | /client_2048/node_modules 4 | /testcerts 5 | .direnv 6 | 7 | -------------------------------------------------------------------------------- /client_2048/.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | target = ["wasm32-unknown-unknown"] 3 | 4 | [env] 5 | -------------------------------------------------------------------------------- /client_2048/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fatfingers23/at_2048/HEAD/client_2048/icon.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "emmet.includeLanguages": { 3 | "rust": "html", 4 | } 5 | } -------------------------------------------------------------------------------- /client_2048/bacon.toml: -------------------------------------------------------------------------------- 1 | [jobs.check-wasm] 2 | command = ["cargo", "check", "--target", "wasm32-unknown-unknown"] -------------------------------------------------------------------------------- /client_2048/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fatfingers23/at_2048/HEAD/client_2048/favicons/favicon.ico -------------------------------------------------------------------------------- /client_2048/assets/imgs/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fatfingers23/at_2048/HEAD/client_2048/assets/imgs/banner.png -------------------------------------------------------------------------------- /client_2048/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fatfingers23/at_2048/HEAD/client_2048/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /client_2048/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fatfingers23/at_2048/HEAD/client_2048/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /client_2048/favicons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fatfingers23/at_2048/HEAD/client_2048/favicons/apple-touch-icon.png -------------------------------------------------------------------------------- /client_2048/src/pages/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod callback; 2 | pub mod game; 3 | pub mod history; 4 | pub mod login; 5 | pub mod seed; 6 | pub mod stats; 7 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.86" 3 | components = [] 4 | targets = ["wasm32-unknown-unknown"] 5 | profile = "default" 6 | 7 | -------------------------------------------------------------------------------- /client_2048/favicons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fatfingers23/at_2048/HEAD/client_2048/favicons/android-chrome-192x192.png -------------------------------------------------------------------------------- /client_2048/favicons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fatfingers23/at_2048/HEAD/client_2048/favicons/android-chrome-512x512.png -------------------------------------------------------------------------------- /types_2048/src/lib.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | pub mod record; 3 | pub mod blue; 4 | pub mod com; 5 | -------------------------------------------------------------------------------- /types_2048/src/blue.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!Definitions for the `blue` namespace. 3 | pub mod _2048; 4 | -------------------------------------------------------------------------------- /types_2048/src/com.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!Definitions for the `com` namespace. 3 | pub mod atproto; 4 | -------------------------------------------------------------------------------- /client_2048/Trunk.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | target = "index.html" 3 | dist = "dist" 4 | #public_url = "./" 5 | #filehash = true 6 | #no_sri = true 7 | 8 | 9 | [tools] 10 | tailwindcss = "4.1.3" -------------------------------------------------------------------------------- /types_2048/src/com/atproto.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!Definitions for the `com.atproto` namespace. 3 | pub mod repo; 4 | -------------------------------------------------------------------------------- /client_2048/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@tailwindcss/cli": "^4.1.3", 4 | "tailwindcss": "^4.1.5" 5 | }, 6 | "devDependencies": { 7 | "daisyui": "^5.0.35" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /production_configs/Caddyfile: -------------------------------------------------------------------------------- 1 | 2048.blue { 2 | handle { 3 | try_files {path} /index.html 4 | file_server 5 | } 6 | handle /api/* { 7 | reverse_proxy host.docker.internal:8081 8 | } 9 | } -------------------------------------------------------------------------------- /types_2048/src/com/atproto/repo.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!Definitions for the `com.atproto.repo` namespace. 3 | pub mod strong_ref; 4 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | if ! has nix_direnv_version || ! nix_direnv_version 3.0.6; then 2 | source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.6/direnvrc" "sha256-RYcUJaRMf8oF5LznDrlCXbkOQrywm0HDv1VjYGaJGdM=" 3 | fi 4 | use flake 5 | -------------------------------------------------------------------------------- /types_2048/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "types-2048" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | atrium-api.workspace = true 8 | serde.workspace = true 9 | 10 | 11 | [features] 12 | skip_serializing = [] -------------------------------------------------------------------------------- /client_2048/src/bin/worker.rs: -------------------------------------------------------------------------------- 1 | use client_2048::agent::{Postcard, StorageTask}; 2 | use yew_agent::Registrable; 3 | 4 | fn main() { 5 | wasm_logger::init(wasm_logger::Config::new(log::Level::Info)); 6 | std::panic::set_hook(Box::new(console_error_panic_hook::hook)); 7 | 8 | StorageTask::registrar().encoding::().register(); 9 | } 10 | -------------------------------------------------------------------------------- /dockerfiles/AppView.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:1.86.0-bookworm AS api-builder 2 | WORKDIR /app 3 | COPY ../ /app 4 | RUN cargo build --bin appview_2048 --release 5 | # 6 | FROM rust:1.86-slim-bookworm AS api 7 | COPY --from=api-builder /app/target/release/appview_2048 /usr/local/bin/apview_2048 8 | COPY --from=api-builder /app/appview_2048/Dev.toml Dev.toml 9 | CMD ["appview_2048"] -------------------------------------------------------------------------------- /appview_2048/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "appview_2048" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | #atrium-crypto = "0.1.2" 8 | #rand = "0.8.5" 9 | dropshot = "0.16.0" 10 | http = "1.3.1" 11 | serde = { version = "1.0.219", features = ["derive"] } 12 | tokio.workspace = true 13 | schemars = { version = "0.8.22", features = ["uuid1"] } 14 | toml = "0.8.22" 15 | -------------------------------------------------------------------------------- /types_2048/src/blue/_2048/key/player.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!Definitions for the `blue.2048.key.player` namespace. 3 | pub mod stats; 4 | #[derive(Debug)] 5 | pub struct Stats; 6 | impl atrium_api::types::Collection for Stats { 7 | const NSID: &'static str = "blue.2048.key.player.stats"; 8 | type Record = stats::Record; 9 | } 10 | -------------------------------------------------------------------------------- /types_2048/src/blue/_2048/key.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!Definitions for the `blue.2048.key` namespace. 3 | pub mod defs; 4 | pub mod game; 5 | pub mod player; 6 | #[derive(Debug)] 7 | pub struct Game; 8 | impl atrium_api::types::Collection for Game { 9 | const NSID: &'static str = "blue.2048.key.game"; 10 | type Record = game::Record; 11 | } 12 | -------------------------------------------------------------------------------- /types_2048/src/blue/_2048.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!Definitions for the `blue.2048` namespace. 3 | pub mod defs; 4 | pub mod game; 5 | pub mod key; 6 | pub mod player; 7 | pub mod verification; 8 | #[derive(Debug)] 9 | pub struct Game; 10 | impl atrium_api::types::Collection for Game { 11 | const NSID: &'static str = "blue.2048.game"; 12 | type Record = game::Record; 13 | } 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # at://2048 2 | 3 | Heavily WIP, almost zero documentation or setup. Focusing on new features right now. 4 | Read at your own risk. 5 | 6 | Contribution guidelines and how to setup the project: [CONTRIBUTING.md](CONTRIBUTING.md) 7 | 8 | ## Special thanks to these because there would be no at://2048 without them 9 | 10 | * The 2048 game logic - [hallabois/twothousand-forty-eight](https://github.com/hallabois/twothousand-forty-eight) 11 | * Theme - [hesic73/2048](https://github.com/hesic73/2048) 12 | -------------------------------------------------------------------------------- /types_2048/src/com/atproto/repo/strong_ref.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!Definitions for the `com.atproto.repo.strongRef` namespace. 3 | //!A URI with a content-hash fingerprint. 4 | #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 5 | #[serde(rename_all = "camelCase")] 6 | pub struct MainData { 7 | pub cid: atrium_api::types::string::Cid, 8 | pub uri: String, 9 | } 10 | pub type Main = atrium_api::types::Object; 11 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["admin_2048", "appview_2048", "client_2048", "types_2048"] 3 | resolver = "2" 4 | 5 | [workspace.dependencies] 6 | atrium-api = "0.25.3" 7 | atrium-common = "0.1.2" 8 | atrium-identity = "0.1.4" 9 | atrium-oauth = "0.1.2" 10 | atrium-xrpc = "0.12.3" 11 | atrium-xrpc-client = "0.5.14" 12 | twothousand-forty-eight = { version = "0.22.1", features = [ 13 | "wasm", 14 | "wasm-bindgen", 15 | ] } 16 | serde = { version = "1.0.219", features = ["derive"] } 17 | tokio = { version = "1.44.1", features = ["full"] } 18 | -------------------------------------------------------------------------------- /admin_2048/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "admin_2048" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | types-2048 = { path = "../types_2048" } 8 | clap = { version = "4.5.37", features = ["derive"] } 9 | tokio.workspace = true 10 | anyhow = "1.0.97" 11 | atrium-api.workspace = true 12 | atrium-common.workspace = true 13 | atrium-identity.workspace = true 14 | atrium-xrpc-client.workspace = true 15 | atrium-oauth.workspace = true 16 | env_logger = "0.11.8" 17 | log = "0.4.27" 18 | hickory-resolver = "0.24.1" 19 | twothousand-forty-eight.workspace = true -------------------------------------------------------------------------------- /production_configs/client_metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "client_id": "https://2048.blue/client_metadata.json", 3 | "client_uri": "https://2048.blue", 4 | "redirect_uris": [ 5 | "https://2048.blue/oauth/callback" 6 | ], 7 | "dpop_bound_access_tokens": true, 8 | "token_endpoint_auth_method": "none", 9 | "grant_types": [ 10 | "authorization_code", 11 | "refresh_token" 12 | ], 13 | "scope": "atproto transition:generic", 14 | "response_types": [ 15 | "code" 16 | ], 17 | "client_name": "at://2048", 18 | "logo_uri": "https://2048.blue/icon.png" 19 | } -------------------------------------------------------------------------------- /types_2048/lexicons/com/atproto/repo/strongRef.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "com.atproto.repo.strongRef", 4 | "description": "A URI with a content-hash fingerprint.", 5 | "defs": { 6 | "main": { 7 | "type": "object", 8 | "required": [ 9 | "uri", 10 | "cid" 11 | ], 12 | "properties": { 13 | "uri": { 14 | "type": "string", 15 | "format": "at-uri" 16 | }, 17 | "cid": { 18 | "type": "string", 19 | "format": "cid" 20 | } 21 | } 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /client_2048/src/bin/app.rs: -------------------------------------------------------------------------------- 1 | // use idb::{Database, Error}; 2 | use yew::Renderer; 3 | use yew::platform::spawn_local; 4 | 5 | fn main() { 6 | wasm_logger::init(wasm_logger::Config::default()); 7 | std::panic::set_hook(Box::new(console_error_panic_hook::hook)); 8 | spawn_local(async move { 9 | let db_create_result = client_2048::idb::create_database().await; 10 | match db_create_result { 11 | Ok(_) => {} 12 | Err(err) => { 13 | log::error!("{:?}", err); 14 | } 15 | } 16 | }); 17 | Renderer::::new().render(); 18 | } 19 | -------------------------------------------------------------------------------- /client_2048/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "at://2048", 3 | "short_name": "2048", 4 | "icons": [ 5 | { 6 | "src": "./icon.png", 7 | "sizes": "256x256", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "./android-chrome-192x192.png", 12 | "sizes": "192x192", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "./android-chrome-512x512.png", 17 | "sizes": "512x512", 18 | "type": "image/png" 19 | } 20 | ], 21 | "lang": "en-US", 22 | "start_url": "/", 23 | "display": "standalone", 24 | "background_color": "white", 25 | "theme_color": "white" 26 | } -------------------------------------------------------------------------------- /prod-docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | caddy: 3 | image: fatfingers23/at_2048_web_server:latest 4 | restart: unless-stopped 5 | ports: 6 | - "80:80" 7 | - "443:443" 8 | - "443:443/udp" 9 | volumes: 10 | - caddy_data:/data 11 | - caddy_config:/config 12 | networks: 13 | - 2048-network 14 | api: 15 | image: fatfingers23/at_2048_appview:latest 16 | restart: unless-stopped 17 | ports: 18 | - "8081:8081" 19 | networks: 20 | - 2048-network 21 | 22 | volumes: 23 | caddy_data: 24 | caddy_config: 25 | networks: 26 | 2048-network: 27 | driver: bridge -------------------------------------------------------------------------------- /types_2048/src/blue/_2048/player.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!Definitions for the `blue.2048.player` namespace. 3 | pub mod profile; 4 | pub mod stats; 5 | #[derive(Debug)] 6 | pub struct Profile; 7 | impl atrium_api::types::Collection for Profile { 8 | const NSID: &'static str = "blue.2048.player.profile"; 9 | type Record = profile::Record; 10 | } 11 | #[derive(Debug)] 12 | pub struct Stats; 13 | impl atrium_api::types::Collection for Stats { 14 | const NSID: &'static str = "blue.2048.player.stats"; 15 | type Record = stats::Record; 16 | } 17 | -------------------------------------------------------------------------------- /types_2048/src/blue/_2048/verification.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!Definitions for the `blue.2048.verification` namespace. 3 | pub mod defs; 4 | pub mod game; 5 | pub mod stats; 6 | #[derive(Debug)] 7 | pub struct Game; 8 | impl atrium_api::types::Collection for Game { 9 | const NSID: &'static str = "blue.2048.verification.game"; 10 | type Record = game::Record; 11 | } 12 | #[derive(Debug)] 13 | pub struct Stats; 14 | impl atrium_api::types::Collection for Stats { 15 | const NSID: &'static str = "blue.2048.verification.stats"; 16 | type Record = stats::Record; 17 | } 18 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | lint: 2 | yew-fmt ./client_2048/src/*.rs 3 | 4 | release-build: 5 | APP_ORIGIN=https://2048.blue trunk build --config ./client_2048/Trunk.toml --release 6 | 7 | release: 8 | #trunk build --config ./client_2048/Trunk.toml --release 9 | docker buildx build \ 10 | --platform linux/arm64 \ 11 | -t fatfingers23/at_2048_appview:latest \ 12 | -f dockerfiles/AppView.Dockerfile \ 13 | --push . 14 | docker buildx build \ 15 | --platform linux/arm64 \ 16 | -t fatfingers23/at_2048_web_server:latest \ 17 | -f dockerfiles/Caddy.Dockerfile \ 18 | --push . 19 | -------------------------------------------------------------------------------- /client_2048/src/store.rs: -------------------------------------------------------------------------------- 1 | use atrium_api::types::string::{Did, Handle}; 2 | use serde::{Deserialize, Serialize}; 3 | use yewdux::prelude::*; 4 | 5 | #[derive(Default, PartialEq, Serialize, Deserialize, Store)] 6 | #[store(storage = "local")] 7 | #[derive(Clone)] 8 | pub struct UserStore { 9 | pub did: Option, 10 | pub handle: Option, 11 | } 12 | 13 | //Incase I need a debug listener later 14 | // #[store(storage = "local", listener(LogListener))] 15 | // struct LogListener; 16 | // impl Listener for LogListener { 17 | // type Store = UserStore; 18 | // 19 | // fn on_change(&self, _cx: &Context, state: Rc) { 20 | // log::info!("Theres a did {}", state.did.is_some()); 21 | // } 22 | // } 23 | -------------------------------------------------------------------------------- /roadmap.md: -------------------------------------------------------------------------------- 1 | # Release and can play the thing 2 | 3 | ## Player lexicon 4 | 5 | - [ ] local save in index db and updates to users pds. 6 | - [ ] small subset of stats 7 | 8 | ## Game lexicon 9 | 10 | - [ ] local save in index db and updates to users pds. 11 | - [ ] History locally and in your pds as well as history 12 | 13 | ## Start of API 14 | 15 | - [ ] simple api mostly for jetstream, maybe not acutally a true api yet besiders host the PWA 16 | - [ ] jet stream to capture dids of players playing so I know if they are for future. look for profile/game lexicon. can 17 | use this to find history stuff later 18 | - [ ] 19 | 20 | # After release 21 | 22 | - [ ] leaderboard 23 | - [ ] more stats 24 | - [ ] global stats 25 | - [ ] view friends stats 26 | - [ ] timeline? 27 | -------------------------------------------------------------------------------- /dockerfiles/Caddy.Dockerfile: -------------------------------------------------------------------------------- 1 | ##Builds the assets for the WASM app 2 | #FROM rust:1.86.0-bookworm AS wasm-builder 3 | #WORKDIR /app 4 | #COPY ../ /app 5 | #RUN rustup target add wasm32-unknown-unknown 6 | #RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash 7 | #RUN cargo binstall trunk 8 | #RUN cargo binstall wasm-bindgen-cli 9 | #WORKDIR /app/client_2048 10 | #RUN trunk build --release 11 | # 12 | 13 | 14 | FROM caddy:2.1.0-alpine AS caddy 15 | EXPOSE 80 16 | EXPOSE 443 17 | EXPOSE 443/udp 18 | COPY ../production_configs/Caddyfile /etc/caddy/Caddyfile 19 | COPY ../client_2048/dist /srv 20 | #COPY --from=wasm-builder /app/client_2048/dist /srv 21 | COPY ../production_configs/client_metadata.json /srv/client_metadata.json -------------------------------------------------------------------------------- /types_2048/src/blue/_2048/key/game.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!Definitions for the `blue.2048.key.game` namespace. 3 | use atrium_api::types::TryFromUnknown; 4 | #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 5 | #[serde(rename_all = "camelCase")] 6 | pub struct RecordData { 7 | pub created_at: atrium_api::types::string::Datetime, 8 | ///A did:key that is used to verify an at://2048 authority has verified this game to a certain degree 9 | pub key: crate::blue::_2048::key::defs::Key, 10 | } 11 | pub type Record = atrium_api::types::Object; 12 | impl From for RecordData { 13 | fn from(value: atrium_api::types::Unknown) -> Self { 14 | Self::try_from_unknown(value).unwrap() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /types_2048/src/blue/_2048/key/player/stats.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!Definitions for the `blue.2048.key.player.stats` namespace. 3 | use atrium_api::types::TryFromUnknown; 4 | #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 5 | #[serde(rename_all = "camelCase")] 6 | pub struct RecordData { 7 | pub created_at: atrium_api::types::string::Datetime, 8 | ///A did:key that is used to verify an at://2048 authority has verified this players stats to a certain degree 9 | pub key: crate::blue::_2048::key::defs::Key, 10 | } 11 | pub type Record = atrium_api::types::Object; 12 | impl From for RecordData { 13 | fn from(value: atrium_api::types::Unknown) -> Self { 14 | Self::try_from_unknown(value).unwrap() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /types_2048/lexicons/blue/2048/key/game.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "blue.2048.key.game", 4 | "defs": { 5 | "main": { 6 | "type": "record", 7 | "description": "A record that holds a did:key for verifying a players game. This is intended to be written at a verification authorities repo", 8 | "key": "literal:self", 9 | "record": { 10 | "type": "object", 11 | "required": [ 12 | "key", 13 | "createdAt" 14 | ], 15 | "properties": { 16 | "key": { 17 | "description": "A did:key that is used to verify an at://2048 authority has verified this game to a certain degree", 18 | "type": "ref", 19 | "ref": "blue.2048.key.defs#key" 20 | }, 21 | "createdAt": { 22 | "type": "string", 23 | "format": "datetime" 24 | } 25 | } 26 | } 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /types_2048/src/blue/_2048/player/profile.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!Definitions for the `blue.2048.player.profile` namespace. 3 | use atrium_api::types::TryFromUnknown; 4 | #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 5 | #[serde(rename_all = "camelCase")] 6 | pub struct RecordData { 7 | pub created_at: atrium_api::types::string::Datetime, 8 | ///Does not want to show up anywhere. Keep stats to your PDS. 9 | pub solo_play: bool, 10 | ///The sync status of this record with the users AT Protocol repo. 11 | pub sync_status: crate::blue::_2048::defs::SyncStatus, 12 | } 13 | pub type Record = atrium_api::types::Object; 14 | impl From for RecordData { 15 | fn from(value: atrium_api::types::Unknown) -> Self { 16 | Self::try_from_unknown(value).unwrap() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /types_2048/lexicons/blue/2048/key/stats.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "blue.2048.key.player.stats", 4 | "defs": { 5 | "main": { 6 | "type": "record", 7 | "description": "A record that holds a did:key for verifying a players stats. This is intended to be written at a verification authorities repo", 8 | "key": "literal:self", 9 | "record": { 10 | "type": "object", 11 | "required": [ 12 | "key", 13 | "createdAt" 14 | ], 15 | "properties": { 16 | "key": { 17 | "description": "A did:key that is used to verify an at://2048 authority has verified this players stats to a certain degree", 18 | "type": "ref", 19 | "ref": "blue.2048.key.defs#key" 20 | }, 21 | "createdAt": { 22 | "type": "string", 23 | "format": "datetime" 24 | } 25 | } 26 | } 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /types_2048/lexicons/blue/2048/verification/game.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "blue.2048.verification.game", 4 | "defs": { 5 | "main": { 6 | "type": "record", 7 | "description": "A record that holds a verification of a game record saying the owner of the repo has verified that it is a valid game played.", 8 | "key": "tid", 9 | "record": { 10 | "type": "object", 11 | "verifiedRef": [ 12 | "verifiedRef", 13 | "createdAt" 14 | ], 15 | "properties": { 16 | "verifiedRef": { 17 | "description": "This is the record that holds the publicly verifiable signature of a game record", 18 | "type": "ref", 19 | "ref": "blue.2048.verification.defs#verificationRef" 20 | }, 21 | "createdAt": { 22 | "type": "string", 23 | "format": "datetime" 24 | } 25 | } 26 | } 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /types_2048/src/blue/_2048/defs.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!Definitions for the `blue.2048.defs` namespace. 3 | //!Reusable types for blue.2048 lexicons 4 | ///The sync status for a record used to help sync between your ATProto record and local record. 5 | #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 6 | #[serde(rename_all = "camelCase")] 7 | pub struct SyncStatusData { 8 | pub created_at: atrium_api::types::string::Datetime, 9 | ///A XXH3 hash of the record to tell if anything has changed 10 | pub hash: String, 11 | ///A flag to know if it has been synced with the AT repo. Used mostly client side to filter what records need syncing 12 | pub synced_with_at_repo: bool, 13 | pub updated_at: atrium_api::types::string::Datetime, 14 | } 15 | 16 | //TODO flatten strikes again. 17 | pub type SyncStatus = atrium_api::types::Object; 18 | -------------------------------------------------------------------------------- /types_2048/lexicons/blue/2048/verification/stats.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "blue.2048.verification.stats", 4 | "defs": { 5 | "main": { 6 | "type": "record", 7 | "description": "A record that holds a verification of a stats record saying the owner of the repo has verified that it is a valid and most likely not tampered with.", 8 | "key": "tid", 9 | "record": { 10 | "type": "object", 11 | "verifiedRef": [ 12 | "verifiedRef", 13 | "createdAt" 14 | ], 15 | "properties": { 16 | "verifiedRef": { 17 | "description": "This is the record that holds the publicly verifiable signature of a stats record", 18 | "type": "ref", 19 | "ref": "blue.2048.verification.defs#verificationRef" 20 | }, 21 | "createdAt": { 22 | "type": "string", 23 | "format": "datetime" 24 | } 25 | } 26 | } 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /types_2048/lexicons/blue/2048/player/profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "blue.2048.player.profile", 4 | "defs": { 5 | "main": { 6 | "type": "record", 7 | "description": "A declaration of a at://2048 player's profile", 8 | "key": "literal:self", 9 | "record": { 10 | "type": "object", 11 | "required": [ 12 | "soloPlay", 13 | "syncStatus", 14 | "createdAt" 15 | ], 16 | "properties": { 17 | "soloPlay": { 18 | "description": "Does not want to show up anywhere. Keep stats to your PDS.", 19 | "type": "boolean", 20 | "default": false 21 | }, 22 | "syncStatus": { 23 | "description": "The sync status of this record with the users AT Protocol repo.", 24 | "type": "ref", 25 | "ref": "blue.2048.defs#syncStatus" 26 | }, 27 | "createdAt": { 28 | "type": "string", 29 | "format": "datetime" 30 | } 31 | } 32 | } 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Bailey Townsend 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 | -------------------------------------------------------------------------------- /types_2048/lexicons/blue/2048/defs.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "blue.2048.defs", 4 | "description": "Reusable types for blue.2048 lexicons", 5 | "defs": { 6 | "syncStatus": { 7 | "type": "object", 8 | "description": "The sync status for a record used to help sync between your ATProto record and local record.", 9 | "required": [ 10 | "hash", 11 | "updatedAt", 12 | "createdAt", 13 | "syncedWithATRepo" 14 | ], 15 | "properties": { 16 | "hash": { 17 | "description": "A XXH3 hash of the record to tell if anything has changed", 18 | "type": "string" 19 | }, 20 | "syncedWithATRepo": { 21 | "description": "A flag to know if it has been synced with the AT repo. Used mostly client side to filter what records need syncing", 22 | "type": "boolean", 23 | "default": false 24 | }, 25 | "updatedAt": { 26 | "type": "string", 27 | "format": "datetime" 28 | }, 29 | "createdAt": { 30 | "type": "string", 31 | "format": "datetime" 32 | } 33 | } 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /types_2048/src/blue/_2048/game.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!Definitions for the `blue.2048.game` namespace. 3 | use atrium_api::types::TryFromUnknown; 4 | #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 5 | #[serde(rename_all = "camelCase")] 6 | pub struct RecordData { 7 | ///The player no longer has any moves left 8 | pub completed: bool, 9 | pub created_at: atrium_api::types::string::Datetime, 10 | ///The game's current score 11 | pub current_score: i64, 12 | ///This is the recording of the game. Like chess notation, but for 2048 13 | pub seeded_recording: String, 14 | ///The sync status of this record with the users AT Protocol repo. 15 | pub sync_status: crate::blue::_2048::defs::SyncStatus, 16 | ///The player has found a 2048 tile (they have won) 17 | pub won: bool, 18 | } 19 | pub type Record = atrium_api::types::Object; 20 | impl From for RecordData { 21 | fn from(value: atrium_api::types::Unknown) -> Self { 22 | Self::try_from_unknown(value).unwrap() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /client_2048/LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) Bailey Townsend 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /types_2048/src/blue/_2048/verification/game.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!Definitions for the `blue.2048.verification.game` namespace. 3 | use atrium_api::types::TryFromUnknown; 4 | #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 5 | #[serde(rename_all = "camelCase")] 6 | pub struct RecordData { 7 | #[cfg_attr( 8 | feature = "skip_serializing", 9 | serde(skip_serializing_if = "core::option::Option::is_none") 10 | )] 11 | pub created_at: core::option::Option, 12 | ///This is the record that holds the publicly verifiable signature of a game record 13 | #[cfg_attr( 14 | feature = "skip_serializing", 15 | serde(skip_serializing_if = "core::option::Option::is_none") 16 | )] 17 | pub verified_ref: core::option::Option< 18 | crate::blue::_2048::verification::defs::VerificationRef, 19 | >, 20 | } 21 | pub type Record = atrium_api::types::Object; 22 | impl From for RecordData { 23 | fn from(value: atrium_api::types::Unknown) -> Self { 24 | Self::try_from_unknown(value).unwrap() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /types_2048/src/blue/_2048/verification/stats.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!Definitions for the `blue.2048.verification.stats` namespace. 3 | use atrium_api::types::TryFromUnknown; 4 | #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 5 | #[serde(rename_all = "camelCase")] 6 | pub struct RecordData { 7 | #[cfg_attr( 8 | feature = "skip_serializing", 9 | serde(skip_serializing_if = "core::option::Option::is_none") 10 | )] 11 | pub created_at: core::option::Option, 12 | ///This is the record that holds the publicly verifiable signature of a stats record 13 | #[cfg_attr( 14 | feature = "skip_serializing", 15 | serde(skip_serializing_if = "core::option::Option::is_none") 16 | )] 17 | pub verified_ref: core::option::Option< 18 | crate::blue::_2048::verification::defs::VerificationRef, 19 | >, 20 | } 21 | pub type Record = atrium_api::types::Object; 22 | impl From for RecordData { 23 | fn from(value: atrium_api::types::Unknown) -> Self { 24 | Self::try_from_unknown(value).unwrap() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /types_2048/src/blue/_2048/verification/defs.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!Definitions for the `blue.2048.verification.defs` namespace. 3 | //!Reusable types for an at://2048 authority to prove that it has verified a record 4 | ///Holds the signature for another record showing it has verified it to the best of it's ability and it should be trusted if the signatures match. 5 | #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 6 | #[serde(rename_all = "camelCase")] 7 | pub struct VerificationRefData { 8 | pub created_at: atrium_api::types::string::Datetime, 9 | ///The at://uri for the public did:key to verify the remote record. This also counts as the authority of the verification (example @2048.blue). As well as the type of verification by the collection name (blue.2048.key.game). 10 | pub key_ref: String, 11 | ///The at://uri for the record that is being verified. 12 | pub record_ref: String, 13 | ///The public verifiable signature of the record. Serialization of the records valued 14 | pub signature: String, 15 | ///DID of the subject the verification applies to. 16 | pub subject: atrium_api::types::string::Did, 17 | } 18 | pub type VerificationRef = atrium_api::types::Object; 19 | -------------------------------------------------------------------------------- /types_2048/src/blue/_2048/player/stats.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!Definitions for the `blue.2048.player.stats` namespace. 3 | use atrium_api::types::TryFromUnknown; 4 | #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 5 | #[serde(rename_all = "camelCase")] 6 | pub struct RecordData { 7 | ///Average score across all games 8 | pub average_score: i64, 9 | pub created_at: atrium_api::types::string::Datetime, 10 | ///Total numbers of games the user has played 11 | pub games_played: i64, 12 | ///The highest number block the player has fround. example 128, 256, etc 13 | pub highest_number_block: i64, 14 | ///The highest score the user has gotten in a game 15 | pub highest_score: i64, 16 | ///The smallest number of moves to get the 2048 block 17 | pub least_moves_to_find_twenty_forty_eight: i64, 18 | ///The sync status of this record with the users AT Protocol repo. 19 | pub sync_status: crate::blue::_2048::defs::SyncStatus, 20 | ///Times the 2048 block has been found also count as wins 21 | pub times_twenty_forty_eight_been_found: i64, 22 | ///Total score across all games 23 | pub total_score: i64, 24 | } 25 | pub type Record = atrium_api::types::Object; 26 | impl From for RecordData { 27 | fn from(value: atrium_api::types::Unknown) -> Self { 28 | Self::try_from_unknown(value).unwrap() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /types_2048/src/blue/_2048/key/defs.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!Definitions for the `blue.2048.key.defs` namespace. 3 | //!Reusable types for an at://2048 authority to provide public did:keys and signatures for verification 4 | ///A record that holds a did:key used to verify records. Use the collection to know the type of verification. Example blue.2048.key.game is for blue.2048.game records 5 | #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 6 | #[serde(rename_all = "camelCase")] 7 | pub struct KeyData { 8 | pub created_at: atrium_api::types::string::Datetime, 9 | ///A did:key used to verify records came from an at://2048 authority 10 | pub key: String, 11 | } 12 | pub type Key = atrium_api::types::Object; 13 | ///a signature for an at://2048 record meaning it has been verified by a service. Most likely @2048.blue 14 | #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 15 | #[serde(rename_all = "camelCase")] 16 | pub struct SignatureRefData { 17 | ///The at://uri for the public did:key to verify this record. This also counts as the authority of the verification (example @2048.blue). As well as the type of verification by the collection name (blue.2048.key.game). 18 | pub at_uri: String, 19 | pub created_at: atrium_api::types::string::Datetime, 20 | ///The public verifiable signature of the record. Serialization of the records value minus the signature field 21 | pub signature: String, 22 | } 23 | pub type SignatureRef = atrium_api::types::Object; 24 | -------------------------------------------------------------------------------- /types_2048/lexicons/blue/2048/game.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "blue.2048.game", 4 | "defs": { 5 | "main": { 6 | "type": "record", 7 | "description": "A declaration of an instance of a at://2048 game", 8 | "key": "tid", 9 | "record": { 10 | "type": "object", 11 | "required": [ 12 | "currentScore", 13 | "won", 14 | "completed", 15 | "seededRecording", 16 | "syncStatus", 17 | "createdAt" 18 | ], 19 | "properties": { 20 | "currentScore": { 21 | "description": "The game's current score", 22 | "type": "integer", 23 | "default": 0 24 | }, 25 | "won": { 26 | "description": "The player has found a 2048 tile (they have won)", 27 | "type": "boolean", 28 | "default": false 29 | }, 30 | "completed": { 31 | "description": "The player no longer has any moves left", 32 | "type": "boolean", 33 | "default": false 34 | }, 35 | "seededRecording": { 36 | "description": "This is the recording of the game. Like chess notation, but for 2048", 37 | "type": "string" 38 | }, 39 | "syncStatus": { 40 | "description": "The sync status of this record with the users AT Protocol repo.", 41 | "type": "ref", 42 | "ref": "blue.2048.defs#syncStatus" 43 | }, 44 | "createdAt": { 45 | "type": "string", 46 | "format": "datetime" 47 | } 48 | } 49 | } 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /types_2048/lexicons/blue/2048/verification/defs.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "blue.2048.verification.defs", 4 | "description": "Reusable types for an at://2048 authority to prove that it has verified a record ", 5 | "defs": { 6 | "verificationRef": { 7 | "type": "object", 8 | "description": "Holds the signature for another record showing it has verified it to the best of it's ability and it should be trusted if the signatures match.", 9 | "required": [ 10 | "keyRef", 11 | "recordRef", 12 | "subject", 13 | "signature", 14 | "createdAt" 15 | ], 16 | "properties": { 17 | "keyRef": { 18 | "type": "string", 19 | "format": "at-uri", 20 | "description": "The at://uri for the public did:key to verify the remote record. This also counts as the authority of the verification (example @2048.blue). As well as the type of verification by the collection name (blue.2048.key.game)." 21 | }, 22 | "recordRef": { 23 | "type": "string", 24 | "format": "at-uri", 25 | "description": "The at://uri for the record that is being verified." 26 | }, 27 | "subject": { 28 | "description": "DID of the subject the verification applies to.", 29 | "type": "string", 30 | "format": "did" 31 | }, 32 | "signature": { 33 | "type": "string", 34 | "description": "The public verifiable signature of the record. Serialization of the records valued" 35 | }, 36 | "createdAt": { 37 | "type": "string", 38 | "format": "datetime" 39 | } 40 | } 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 4 | flake-utils.url = "github:numtide/flake-utils"; 5 | rust-overlay = { 6 | url = "github:oxalica/rust-overlay"; 7 | inputs = { 8 | nixpkgs.follows = "nixpkgs"; 9 | }; 10 | }; 11 | crane = { 12 | url = "github:ipetkov/crane"; 13 | }; 14 | }; 15 | outputs = { self, nixpkgs, flake-utils, rust-overlay, crane }: 16 | flake-utils.lib.eachSystem [ flake-utils.lib.system.x86_64-linux flake-utils.lib.system.aarch64-linux ] (system: 17 | let 18 | overlays = [ (import rust-overlay) ]; 19 | pkgs = import nixpkgs { 20 | inherit system overlays; 21 | }; 22 | rustToolchain = pkgs.pkgsBuildHost.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; 23 | craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain; 24 | 25 | src = craneLib.cleanCargoSource ./.; 26 | nativeBuildInputs = with pkgs; [ rustToolchain rust-analyzer-unwrapped ]; 27 | buildInputs = with pkgs; [ trunk nodejs_22 wasm-bindgen-cli tailwindcss_4 ]; 28 | commonArgs = { 29 | inherit src buildInputs nativeBuildInputs; 30 | }; 31 | cargoArtifacts = craneLib.buildDepsOnly commonArgs; 32 | bin = craneLib.buildPackage (commonArgs // { inherit cargoArtifacts; }); 33 | in 34 | with pkgs; 35 | { 36 | packages = { 37 | inherit bin; 38 | default = bin; 39 | }; 40 | devShells.default = mkShell { 41 | buildInputs = buildInputs; 42 | nativeBuildInputs = nativeBuildInputs ++ (with pkgs; [ 43 | gh 44 | neovim 45 | lazygit 46 | ripgrep 47 | ]); 48 | }; 49 | } 50 | ); 51 | } 52 | 53 | -------------------------------------------------------------------------------- /types_2048/lexicons/blue/2048/key/defs.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "blue.2048.key.defs", 4 | "description": "Reusable types for an at://2048 authority to provide public did:keys and signatures for verification", 5 | "defs": { 6 | "signatureRef": { 7 | "type": "object", 8 | "description": "a signature for an at://2048 record meaning it has been verified by a service. Most likely @2048.blue", 9 | "required": [ 10 | "atURI", 11 | "signature", 12 | "createdAt" 13 | ], 14 | "properties": { 15 | "atURI": { 16 | "type": "string", 17 | "description": "The at://uri for the public did:key to verify this record. This also counts as the authority of the verification (example @2048.blue). As well as the type of verification by the collection name (blue.2048.key.game)." 18 | }, 19 | "signature": { 20 | "type": "string", 21 | "description": "The public verifiable signature of the record. Serialization of the records value minus the signature field" 22 | }, 23 | "createdAt": { 24 | "type": "string", 25 | "format": "datetime" 26 | } 27 | } 28 | }, 29 | "key": { 30 | "type": "object", 31 | "description": "A record that holds a did:key used to verify records. Use the collection to know the type of verification. Example blue.2048.key.game is for blue.2048.game records", 32 | "required": [ 33 | "key", 34 | "createdAt" 35 | ], 36 | "properties": { 37 | "key": { 38 | "type": "string", 39 | "description": "A did:key used to verify records came from an at://2048 authority" 40 | }, 41 | "createdAt": { 42 | "type": "string", 43 | "format": "datetime" 44 | } 45 | } 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /types_2048/lexicon_docs_draft.yml: -------------------------------------------------------------------------------- 1 | LexiconDocs: 2 | blue.2048.player: 3 | details: "Player schemas for the user.Things like stats" 4 | write-location: "player's repo (the user playings repo)" 5 | # maybe split between profile and stats? 6 | schemas: 7 | blue.2048.player.profile: 8 | key: self 9 | - optional - current_game strong ref to blue.2048.game 10 | - ref to blue.2048.player.stats 11 | - solo play - Do not post to leaderboards 12 | - default social to check. Followers, following, mutuals 13 | - title? Like a chess title? may be a later thing https://2048masters.com/accreditations/ 14 | blue.2048.player.stats: 15 | key: self 16 | fields: 17 | - best score 18 | - games played 19 | - total score 20 | - highest number block 21 | - times 2048 has been found 22 | - least moves for a 2048 found 23 | - avg score 24 | - median score 25 | - [ Later ] 26 | - times placed in a leaderboard? 27 | - consecutive days played? 28 | - Time stats Looks like it's document.focus() with set interval 29 | - Fastest 2048 30 | - Total time playing 31 | - total swipes 32 | - direction counts (Can get this from when validating the game) 33 | blue.2048.game: 34 | details: "Schemas for games" 35 | write-location: "player's repo (the user playings repo)" 36 | schemas: 37 | blue.2048.game.instance: 38 | fields: 39 | - no_more_moves 40 | - completed? 41 | - 2048s found 42 | - key hash (the hash that the game has been signed 43 | #Schemas for the 2048.blue account 44 | blue.2048.leaderboard: 45 | details: "Schemas for global consumption" 46 | write-location: "2048.blue's repo for global consumption" 47 | # Global stats same as stats maybe? or a global stats? 48 | 49 | -------------------------------------------------------------------------------- /client_2048/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "client_2048" 3 | version = "0.1.0" 4 | edition = "2024" 5 | description = "A 2048 game that syncs to your AT Protocol repo" 6 | readme = "README.md" 7 | repository = "https://github.com/fatfingers23/at_2048" 8 | license = "MIT" 9 | keywords = ["ATprotocol", "Bluesky", "game", "wasm"] 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | [dependencies] 13 | atrium-api.workspace = true 14 | atrium-common.workspace = true 15 | atrium-identity.workspace = true 16 | atrium-oauth.workspace = true 17 | atrium-xrpc.workspace = true 18 | twothousand-forty-eight.workspace = true 19 | yew-agent = "0.3.0" 20 | yew = { version = "0.21", features = ["csr"] } 21 | yew-router = "0.18.0" 22 | web-sys = { version = "0.3.77", features = ["default", "HtmlElement", "HtmlSelectElement", "HtmlHtmlElement", "TouchList", "TouchEvent", "Touch", "MediaQueryList", "HtmlCollection"] } 23 | rand = "0.8.5" 24 | log = "0.4.27" 25 | wasm-logger = "0.2.0" 26 | gloo = "0.11.0" 27 | serde = { version = "1.0.219", features = ["derive"] } 28 | numfmt = "1.1.1" 29 | gloo-utils = "0.2.0" 30 | js-sys = "0.3.77" 31 | wasm-bindgen = "0.2.100" 32 | postcard = { version = "1.1.1", features = ["alloc"] } 33 | types-2048 = { path = "../types_2048" } 34 | console_error_panic_hook = "0.1.7" 35 | serde_json = "1.0.140" 36 | serde_html_form = "0.2.7" 37 | indexed_db_futures = { version = "0.6.1", features = ["serde", "cursors", "version-change", "async-upgrade", "tx-done", "indices"] } 38 | yew-hooks = "0.3.3" 39 | yewdux = "0.11.0" 40 | xxhash-rust = { version = "0.8.15", features = ["const_xxh3"] } 41 | 42 | [profile.release] 43 | # less code to include into binary 44 | panic = 'abort' 45 | # optimization over all codebase ( better optimization, slower build ) 46 | codegen-units = 1 47 | # optimization for size ( more aggressive ) 48 | opt-level = 'z' 49 | # optimization for size 50 | # opt-level = 's' 51 | # link time optimization using using whole-program analysis 52 | lto = true -------------------------------------------------------------------------------- /client_2048/src/resolver.rs: -------------------------------------------------------------------------------- 1 | #[allow(dead_code)] 2 | use atrium_identity::handle::DnsTxtResolver; 3 | use gloo::net::http::Request; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | /// Setup for dns resolver for the handle resolver 7 | pub struct ApiDNSTxtResolver; 8 | 9 | impl Default for ApiDNSTxtResolver { 10 | fn default() -> Self { 11 | Self {} 12 | } 13 | } 14 | 15 | // curl --http2 --header "accept: application/dns-json" "https://one.one.one.one/dns-query?name=_atproto.baileytownsend.dev&type=TXT" 16 | impl DnsTxtResolver for ApiDNSTxtResolver { 17 | async fn resolve( 18 | &self, 19 | query: &str, 20 | ) -> core::result::Result, Box> { 21 | let request_url = format!( 22 | "https://one.one.one.one/dns-query?name={}&type=TXT", 23 | query.to_lowercase() 24 | ); 25 | let resp = Request::get(request_url.as_str()) 26 | .header("accept", "application/dns-json") 27 | .send() 28 | .await; 29 | 30 | let resp = resp?.json::().await?; 31 | 32 | let response_data = resp 33 | .Answer 34 | .iter() 35 | .map(|a| a.data.clone().replace("\"", "")) 36 | .collect::>(); 37 | Ok(response_data) 38 | } 39 | } 40 | 41 | #[derive(Debug, Serialize, Deserialize)] 42 | #[allow(non_snake_case)] 43 | pub struct DnsResponse { 44 | pub Status: i32, 45 | pub TC: bool, 46 | pub RD: bool, 47 | pub RA: bool, 48 | pub AD: bool, 49 | pub CD: bool, 50 | pub Question: Vec, 51 | pub Answer: Vec, 52 | } 53 | 54 | #[derive(Debug, Serialize, Deserialize)] 55 | pub struct Question { 56 | pub name: String, 57 | #[serde(rename = "type")] 58 | pub type_: i32, 59 | } 60 | 61 | #[derive(Debug, Serialize, Deserialize)] 62 | #[allow(non_snake_case)] 63 | pub struct Answer { 64 | pub name: String, 65 | #[serde(rename = "type")] 66 | pub type_: i32, 67 | pub TTL: i32, 68 | pub data: String, 69 | } 70 | -------------------------------------------------------------------------------- /client_2048/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | at://2048 10 | 11 | 12 | 13 | 15 | 16 | 17 | 18 | 19 | 21 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /types_2048/lexicons/blue/2048/player/stats.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "blue.2048.player.stats", 4 | "defs": { 5 | "main": { 6 | "type": "record", 7 | "description": "A declaration of a at://2048 player's stats over the course of their playtime", 8 | "key": "literal:self", 9 | "record": { 10 | "type": "object", 11 | "required": [ 12 | "highestScore", 13 | "gamesPlayed", 14 | "totalScore", 15 | "highestNumberBlock", 16 | "timesTwentyFortyEightBeenFound", 17 | "leastMovesToFindTwentyFortyEight", 18 | "averageScore", 19 | "syncStatus", 20 | "createdAt" 21 | ], 22 | "properties": { 23 | "highestScore": { 24 | "description": "The highest score the user has gotten in a game", 25 | "type": "integer", 26 | "default": 0 27 | }, 28 | "gamesPlayed": { 29 | "description": "Total numbers of games the user has played", 30 | "type": "integer", 31 | "default": 0 32 | }, 33 | "totalScore": { 34 | "description": "Total score across all games", 35 | "type": "integer", 36 | "default": 0 37 | }, 38 | "highestNumberBlock": { 39 | "description": "The highest number block the player has fround. example 128, 256, etc", 40 | "type": "integer", 41 | "default": 0 42 | }, 43 | "timesTwentyFortyEightBeenFound": { 44 | "description": "Times the 2048 block has been found also count as wins", 45 | "type": "integer", 46 | "default": 0 47 | }, 48 | "leastMovesToFindTwentyFortyEight": { 49 | "description": "The smallest number of moves to get the 2048 block", 50 | "type": "integer", 51 | "default": 0 52 | }, 53 | "averageScore": { 54 | "description": "Average score across all games", 55 | "type": "integer", 56 | "default": 0 57 | }, 58 | "syncStatus": { 59 | "description": "The sync status of this record with the users AT Protocol repo.", 60 | "type": "ref", 61 | "ref": "blue.2048.defs#syncStatus" 62 | }, 63 | "createdAt": { 64 | "type": "string", 65 | "format": "datetime" 66 | } 67 | } 68 | } 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /client_2048/src/components/theme_picker.rs: -------------------------------------------------------------------------------- 1 | use gloo::storage::{LocalStorage, Storage}; 2 | use web_sys::wasm_bindgen::JsCast; 3 | use web_sys::{Event, HtmlElement, HtmlSelectElement}; 4 | use yew::prelude::*; 5 | use yew::{Callback, Html, function_component, html, use_state}; 6 | 7 | #[function_component(ThemePicker)] 8 | pub fn theme_picker() -> Html { 9 | let themes = vec!["light", "dark", "eink"]; 10 | //Detect browser preferred theme 11 | let browser_default = match gloo_utils::window().match_media("(prefers-color-scheme: dark)") { 12 | Ok(result) => match result { 13 | Some(_) => "dark", 14 | None => "light", 15 | }, 16 | Err(_) => { 17 | log::error!("Error getting browser theme"); 18 | "light" 19 | } 20 | }; 21 | let saved_theme = LocalStorage::get("theme").unwrap_or_else(|_| browser_default.to_string()); 22 | let selected_theme = use_state(|| saved_theme); 23 | 24 | let on_change_selected_theme = selected_theme.clone(); 25 | let onchange = Callback::from(move |event: Event| { 26 | let input: HtmlSelectElement = event.target_unchecked_into(); 27 | let _ = if let Some(window) = web_sys::window() { 28 | if let Some(document) = window.document() { 29 | let html_root_element = document.get_elements_by_tag_name("html").item(0).unwrap(); 30 | let html_root_element: HtmlElement = html_root_element.dyn_into().unwrap(); 31 | let theme = input.value(); 32 | on_change_selected_theme.set(theme.clone()); 33 | LocalStorage::set("theme", theme.clone().as_str()).unwrap(); 34 | html_root_element 35 | .set_attribute("data-theme", theme.as_str()) 36 | .unwrap(); 37 | } 38 | }; 39 | }); 40 | let current_theme = selected_theme.clone(); 41 | html! { 42 |
43 |
44 | { "Theme" } 45 | 54 |
55 |
56 | } 57 | } 58 | -------------------------------------------------------------------------------- /client_2048/service_worker.js: -------------------------------------------------------------------------------- 1 | const oldCacheName = 'at_2048_cache_v1' 2 | const currentCacheName = 'at_2048_cache_v0.1' 3 | const cacheAbleHosts = [ 4 | '2048.blue', 5 | // '127.0.0.1' 6 | ]; 7 | 8 | /* Start the service worker and cache all of the app's content */ 9 | self.addEventListener('install', function (e) { 10 | //TODO add the acutal file names to pre cache since they dont have hashes now 11 | // e.waitUntil(precache()); 12 | }); 13 | 14 | 15 | async function clearOldCache() { 16 | const cache = await caches.open(oldCacheName); 17 | cache.keys().then(function (keys) { 18 | keys.forEach(function (key) { 19 | cache.delete(key); 20 | }); 21 | }); 22 | } 23 | 24 | self.addEventListener('activate', function (e) { 25 | e.waitUntil(clearOldCache()); 26 | }); 27 | 28 | 29 | async function networkFirst(request) { 30 | try { 31 | const networkResponse = await fetch(request); 32 | if (networkResponse.ok) { 33 | const cache = await caches.open(currentCacheName); 34 | cache.put(request, networkResponse.clone()); 35 | } 36 | return networkResponse; 37 | } catch (error) { 38 | const cachedResponse = await caches.match(request); 39 | return cachedResponse || Response.error(); 40 | } 41 | } 42 | 43 | async function cacheFirstWithRefresh(request) { 44 | const fetchResponsePromise = fetch(request).then(async (networkResponse) => { 45 | if (networkResponse.ok) { 46 | const cache = await caches.open(currentCacheName); 47 | cache.put(request, networkResponse.clone()); 48 | } 49 | return networkResponse; 50 | }); 51 | 52 | return (await caches.match(request)) || (await fetchResponsePromise); 53 | } 54 | 55 | 56 | async function cacheFirst(request) { 57 | const cachedResponse = await caches.match(request); 58 | if (cachedResponse) { 59 | return cachedResponse; 60 | } 61 | try { 62 | const networkResponse = await fetch(request); 63 | if (networkResponse.ok) { 64 | const cache = await caches.open(currentCacheName); 65 | cache.put(request, networkResponse.clone()); 66 | } 67 | return networkResponse; 68 | } catch (error) { 69 | return Response.error(); 70 | } 71 | } 72 | 73 | 74 | /* Serve cached content when offline */ 75 | self.addEventListener('fetch', function (e) { 76 | const url = new URL(e.request.url); 77 | 78 | if (cacheAbleHosts.includes(url.hostname) && !url.pathname.startsWith('/api')) { 79 | e.respondWith(cacheFirst(e.request)); 80 | } 81 | 82 | }); -------------------------------------------------------------------------------- /client_2048/README.md: -------------------------------------------------------------------------------- 1 | # Yew Trunk Template 2 | 3 | This is a fairly minimal template for a Yew app that's built with [Trunk]. 4 | 5 | ## Usage 6 | 7 | For a more thorough explanation of Trunk and its features, please head over to the [repository][trunk]. 8 | 9 | ### Installation 10 | 11 | If you don't already have it installed, it's time to install Rust: . 12 | The rest of this guide assumes a typical Rust installation which contains both `rustup` and Cargo. 13 | 14 | To compile Rust to WASM, we need to have the `wasm32-unknown-unknown` target installed. 15 | If you don't already have it, install it with the following command: 16 | 17 | ```bash 18 | rustup target add wasm32-unknown-unknown 19 | ``` 20 | 21 | Now that we have our basics covered, it's time to install the star of the show: [Trunk]. 22 | Simply run the following command to install it: 23 | 24 | ```bash 25 | cargo install trunk wasm-bindgen-cli 26 | ``` 27 | 28 | That's it, we're done! 29 | 30 | ### Running 31 | 32 | ```bash 33 | trunk serve 34 | ``` 35 | 36 | Rebuilds the app whenever a change is detected and runs a local server to host it. 37 | 38 | There's also the `trunk watch` command which does the same thing but without hosting it. 39 | 40 | ### Release 41 | 42 | ```bash 43 | trunk build --release 44 | ``` 45 | 46 | This builds the app in release mode similar to `cargo build --release`. 47 | You can also pass the `--release` flag to `trunk serve` if you need to get every last drop of performance. 48 | 49 | Unless overwritten, the output will be located in the `dist` directory. 50 | 51 | ## Using this template 52 | 53 | There are a few things you have to adjust when adopting this template. 54 | 55 | ### Remove example code 56 | 57 | The code in [src/main.rs](src/bin/app.rs) specific to the example is limited to only the `view` method. 58 | There is, however, a fair bit of Sass in [index.scss](index.scss) you can remove. 59 | 60 | ### Update metadata 61 | 62 | Update the `version`, `description` and `repository` fields in the [Cargo.toml](Cargo.toml) file. 63 | The [index.html](index.html) file also contains a `` tag that needs updating. 64 | 65 | Finally, you should update this very `README` file to be about your app. 66 | 67 | ### License 68 | 69 | The template ships with both the Apache and MIT license. 70 | If you don't want to have your app dual licensed, just remove one (or both) of the files and update the `license` field 71 | in `Cargo.toml`. 72 | 73 | There are two empty spaces in the MIT license you need to fill out: `` and 74 | `Bailey Townsend <baileytownsend2323@gmail.com>`. 75 | 76 | [trunk]: https://github.com/thedodd/trunk -------------------------------------------------------------------------------- /appview_2048/src/main.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Oxide Computer Company 2 | 3 | //! Example using Dropshot to serve files 4 | 5 | use dropshot::ConfigLogging; 6 | use dropshot::ConfigLoggingLevel; 7 | use dropshot::HttpError; 8 | use dropshot::RequestContext; 9 | use dropshot::ServerBuilder; 10 | use dropshot::{ApiDescription, ConfigDropshot}; 11 | use dropshot::{Body, HttpResponseOk}; 12 | use dropshot::{Path, endpoint}; 13 | use http::{Response, StatusCode}; 14 | use schemars::JsonSchema; 15 | use serde::Deserialize; 16 | use std::fs; 17 | use std::path::PathBuf; 18 | 19 | /// Our context is simply the root of the directory we want to serve. 20 | struct FileServerContext { 21 | base: PathBuf, 22 | } 23 | 24 | #[derive(Deserialize)] 25 | struct MyAppConfig { 26 | http_api_server: ConfigDropshot, 27 | } 28 | 29 | #[tokio::main] 30 | async fn main() -> Result<(), String> { 31 | let config: MyAppConfig = match fs::read_to_string("Dev.toml") { 32 | //TODO bad unwrap 33 | Ok(config) => toml::from_str(&config).unwrap_or_else(|e| { 34 | println!("Error parsing config file: {}", e); 35 | MyAppConfig { 36 | http_api_server: ConfigDropshot::default(), 37 | } 38 | }), 39 | Err(_) => { 40 | println!("Error reading config file"); 41 | MyAppConfig { 42 | http_api_server: ConfigDropshot::default(), 43 | } 44 | } 45 | }; 46 | // See dropshot/examples/basic.rs for more details on most of these pieces. 47 | let config_logging = ConfigLogging::StderrTerminal { 48 | level: ConfigLoggingLevel::Info, 49 | }; 50 | let log = config_logging 51 | .to_logger("example-basic") 52 | .map_err(|error| format!("failed to create logger: {}", error))?; 53 | 54 | let mut api = ApiDescription::new(); 55 | api.register(example_api_get_counter).unwrap(); 56 | // api.register(static_content).unwrap(); 57 | 58 | let context = FileServerContext { 59 | base: PathBuf::from("../../client_2048/dist/"), 60 | }; 61 | 62 | let server = ServerBuilder::new(api, context, log) 63 | .config(config.http_api_server) 64 | .start() 65 | .map_err(|error| format!("failed to create server: {}", error))?; 66 | 67 | server.await 68 | } 69 | 70 | /// Fetch the current value of the counter. 71 | #[endpoint { 72 | method = GET, 73 | path = "/api/test", 74 | }] 75 | async fn example_api_get_counter( 76 | request_context: RequestContext<FileServerContext>, 77 | ) -> Result<HttpResponseOk<String>, HttpError> { 78 | let api_context = request_context.context(); 79 | 80 | Ok(HttpResponseOk("Nice".to_string())) 81 | } 82 | 83 | /// Dropshot deserializes the input path into this Vec. 84 | #[derive(Deserialize, JsonSchema)] 85 | struct AllPath { 86 | path: Vec<String>, 87 | } 88 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "crane": { 4 | "locked": { 5 | "lastModified": 1746291859, 6 | "narHash": "sha256-DdWJLA+D5tcmrRSg5Y7tp/qWaD05ATI4Z7h22gd1h7Q=", 7 | "owner": "ipetkov", 8 | "repo": "crane", 9 | "rev": "dfd9a8dfd09db9aad544c4d3b6c47b12562544a5", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "ipetkov", 14 | "repo": "crane", 15 | "type": "github" 16 | } 17 | }, 18 | "flake-utils": { 19 | "inputs": { 20 | "systems": "systems" 21 | }, 22 | "locked": { 23 | "lastModified": 1731533236, 24 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 25 | "owner": "numtide", 26 | "repo": "flake-utils", 27 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "numtide", 32 | "repo": "flake-utils", 33 | "type": "github" 34 | } 35 | }, 36 | "nixpkgs": { 37 | "locked": { 38 | "lastModified": 1746328495, 39 | "narHash": "sha256-uKCfuDs7ZM3QpCE/jnfubTg459CnKnJG/LwqEVEdEiw=", 40 | "owner": "NixOS", 41 | "repo": "nixpkgs", 42 | "rev": "979daf34c8cacebcd917d540070b52a3c2b9b16e", 43 | "type": "github" 44 | }, 45 | "original": { 46 | "owner": "NixOS", 47 | "ref": "nixos-unstable", 48 | "repo": "nixpkgs", 49 | "type": "github" 50 | } 51 | }, 52 | "root": { 53 | "inputs": { 54 | "crane": "crane", 55 | "flake-utils": "flake-utils", 56 | "nixpkgs": "nixpkgs", 57 | "rust-overlay": "rust-overlay" 58 | } 59 | }, 60 | "rust-overlay": { 61 | "inputs": { 62 | "nixpkgs": [ 63 | "nixpkgs" 64 | ] 65 | }, 66 | "locked": { 67 | "lastModified": 1746498961, 68 | "narHash": "sha256-rp+oh/N88JKHu7ySPuGiA3lBUVIsrOtHbN2eWJdYCgk=", 69 | "owner": "oxalica", 70 | "repo": "rust-overlay", 71 | "rev": "24b00064cdd1d7ba25200c4a8565dc455dc732ba", 72 | "type": "github" 73 | }, 74 | "original": { 75 | "owner": "oxalica", 76 | "repo": "rust-overlay", 77 | "type": "github" 78 | } 79 | }, 80 | "systems": { 81 | "locked": { 82 | "lastModified": 1681028828, 83 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 84 | "owner": "nix-systems", 85 | "repo": "default", 86 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 87 | "type": "github" 88 | }, 89 | "original": { 90 | "owner": "nix-systems", 91 | "repo": "default", 92 | "type": "github" 93 | } 94 | } 95 | }, 96 | "root": "root", 97 | "version": 7 98 | } 99 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to at://2048 2 | 3 | Want to implement a feature or help solving that nasty bug? You are at the 4 | right page - this document will guide you through setting up everything you may 5 | need to get this project up and running. 6 | 7 | ## So, how do I run it locally? 8 | 9 | - Clone this project using your preferred tools: 10 | 11 | ```bash 12 | # Regular git clone 13 | git clone https://github.com/fatfingers23/at_2048.git 14 | 15 | # Clone using SSH (the most secure way) 16 | git clone git@github.com:fatfingers23/at_2048.git 17 | 18 | # Clone using GitHub CLI (the most convenient way) 19 | gh repo clone fatfingers23/at_2048 20 | 21 | ``` 22 | 23 | Now, you have two choices: 24 | 25 | - [Setup](#setup) 26 | - [Set up with Nix](#setup-with-nix-optional) 27 | 28 | ### Setup 29 | 30 | - [Install Rust](https://www.rust-lang.org/tools/install), if you haven't already 31 | - Add Wasm target: `rustup target add wasm32-unknown-unknown` 32 | - [Install npm](https://nodejs.org/en/download) 33 | - Install project dependencies using `npm` and `cargo`: 34 | 35 | ```bash 36 | cargo install trunk wasm-bindgen-cli 37 | ``` 38 | 39 | - Go to the `client_2048` directory: `cd at_2048/client_2048` 40 | - Run `npm install` 41 | 42 | ### Setup with Nix (Optional) 43 | 44 | - Install [Determinate Nix](https://github.com/DeterminateSystems/nix-installer) (skip if you are on NixOS 45 | with [flakes](https://nixos.wiki/wiki/Flakes) enabled) 46 | - `cd at_2048` 47 | - `direnv allow` 48 | - Make a cup of tea while dependencies are being downloaded 49 | - Once it's done, (dependencies loading, not tea) `cd client_2048` 50 | - `npm install` 51 | 52 | After that, it might be a good idea to run `nix flake update` and see if 53 | `flake.lock` file at the project root directory is changed. This way, you'll 54 | ensure you have the latest packages installed. 55 | 56 | ### Running the project 57 | 58 | Open the `client_2048` directory and run `trunk serve`. You should see something 59 | like this: 60 | 61 | ``` 62 | Done in 151ms 63 | 2025-05-05T18:05:39.718398Z INFO applying new distribution 64 | 2025-05-05T18:05:39.720532Z INFO ✅ success 65 | 2025-05-05T18:05:39.720588Z INFO 📡 serving static assets at -> / 66 | 2025-05-05T18:05:39.720771Z INFO 📡 server listening at: 67 | 2025-05-05T18:05:39.720777Z INFO 🏠 http://127.0.0.1:8080/ 68 | 2025-05-05T18:05:39.720782Z INFO 🏠 http://[::1]:8080/ 69 | 2025-05-05T18:05:39.720864Z INFO 🏠 http://localhost.:8080/ 70 | ``` 71 | 72 | Visit http://localhost:8080, traverse the project files, and go on, do some 73 | hacking! 74 | 75 | ## I've done something cool and would like to share. How can I? 76 | 77 | Go to this repository's 78 | page, [fork it](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo?tool=webui&platform=linux#forking-a-repository), 79 | save your changes using `git commit`, then push it: 80 | 81 | ```bash 82 | git remote add github git@github.com:<YOUR_USERNAME>/at_2048.git # only needed once 83 | git push github <YOUR_BRANCH_NAME> 84 | ``` 85 | 86 | After that, come back to this repository and GitHub will propose you to open 87 | the pull request. Do it! 88 | 89 | ## How do I update the project files? I want to have the latest codebase! 90 | 91 | ```bash 92 | git checkout main 93 | git pull 94 | ``` 95 | 96 | Boom! Your codebase is now straight from the oven again. 97 | 98 | ## I have unanswered questions. Where can I ask them? 99 | 100 | Feel free to DM or @ me on [Bluesky](https://bsky.app/profile/2048.blue), or 101 | open a new issue if you encountered a problem. 102 | 103 | -------------------------------------------------------------------------------- /client_2048/src/tailwind.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | @plugin "daisyui"; 4 | 5 | 6 | @theme theme { 7 | --transition-postion: 'top, left'; 8 | --color-light-background-color: rgba(238, 228, 218, 0.5); 9 | --color-light-board-background: #bbada0; 10 | --color-light-grid-cell-0: rgba(238, 228, 218, 0.35); 11 | --color-light-grid-cell-2: #eee4da; 12 | --color-light-grid-cell-4: #eee1c9; 13 | --color-light-grid-cell-8: #f3b27a; 14 | --color-light-grid-cell-16: #f69664; 15 | --color-light-grid-cell-32: #f77c5f; 16 | --color-light-grid-cell-64: #f75f3b; 17 | --color-light-grid-cell-128: #edd073; 18 | --color-light-grid-cell-256: #edcc62; 19 | --color-light-grid-cell-512: #edc950; 20 | --color-light-grid-cell-1024: #edc53f; 21 | --color-light-grid-cell-2048: #edc22e; 22 | --color-light-grid-cell-text-2: #776e65; 23 | --color-light-grid-cell-text-4: #776e65; 24 | --color-light-grid-cell-text-8: #f9f6f2; 25 | --color-light-grid-cell-text-16: #f9f6f2; 26 | --color-light-grid-cell-text-32: #f9f6f2; 27 | --color-light-grid-cell-text-64: #f9f6f2; 28 | --color-light-grid-cell-text-128: #f9f6f2; 29 | --color-light-grid-cell-text-256: #f9f6f2; 30 | --color-light-grid-cell-text-512: #f9f6f2; 31 | --color-light-grid-cell-text-1024: #f9f6f2; 32 | --color-light-grid-cell-text-2048: #f9f6f2; 33 | --color-light-score-addition: rgba(119, 110, 101, 0.9); 34 | 35 | /* new tile spawn animation*/ 36 | --animate-spawn: spawn 0.5s ease-out; 37 | 38 | @keyframes spawn { 39 | 0% { 40 | transform: scale(0); 41 | opacity: 0; 42 | } 43 | 100% { 44 | transform: scale(1); 45 | opacity: 1; 46 | } 47 | } 48 | 49 | /* move animation */ 50 | --animate-moveup: moveup 2s ease-out; 51 | @keyframes moveup { 52 | 0% { 53 | transform: translateY(0); 54 | opacity: 1; 55 | } 56 | 100% { 57 | transform: translateY(-150px); 58 | opacity: 0; 59 | } 60 | } 61 | 62 | /*transition-property {*/ 63 | /*}*/ 64 | 65 | } 66 | 67 | 68 | @plugin "daisyui/theme" { 69 | themes: light --prefers light 70 | } 71 | 72 | @plugin "daisyui/theme" { 73 | /*Same as light just needed for the eink colors*/ 74 | name: eink; 75 | default: false; /* set as default */ 76 | prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */ 77 | color-scheme: light; /* color of browser-provided UI */ 78 | --color-base-100: oklch(100% 0 0); 79 | --color-base-200: oklch(98% 0 0); 80 | --color-base-300: oklch(95% 0 0); 81 | --color-base-content: oklch(21% 0.006 285.885); 82 | --color-primary: oklch(45% 0.24 277.023); 83 | --color-primary-content: oklch(93% 0.034 272.788); 84 | --color-secondary: oklch(65% 0.241 354.308); 85 | --color-secondary-content: oklch(94% 0.028 342.258); 86 | --color-accent: oklch(77% 0.152 181.912); 87 | --color-accent-content: oklch(38% 0.063 188.416); 88 | --color-neutral: oklch(14% 0.005 285.823); 89 | --color-neutral-content: oklch(92% 0.004 286.32); 90 | --color-info: oklch(74% 0.16 232.661); 91 | --color-info-content: oklch(29% 0.066 243.157); 92 | --color-success: oklch(76% 0.177 163.223); 93 | --color-success-content: oklch(37% 0.077 168.94); 94 | --color-warning: oklch(82% 0.189 84.429); 95 | --color-warning-content: oklch(41% 0.112 45.904); 96 | --color-error: oklch(71% 0.194 13.428); 97 | --color-error-content: oklch(27% 0.105 12.094); 98 | --radius-selector: 0.5rem; 99 | --radius-field: 0.25rem; 100 | --radius-box: 0.5rem; 101 | --size-selector: 0.25rem; 102 | --size-field: 0.25rem; 103 | --border: 1px; 104 | --depth: 1; 105 | --noise: 0; 106 | } 107 | 108 | @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); 109 | 110 | @custom-variant eink { 111 | &:where([data-theme="eink"] *) { 112 | @slot; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /client_2048/src/oauth_client.rs: -------------------------------------------------------------------------------- 1 | use crate::atrium_stores::{IndexDBSessionStore, IndexDBStateStore}; 2 | use crate::resolver::ApiDNSTxtResolver; 3 | use atrium_api::types::string::Did; 4 | use atrium_common::resolver::Resolver; 5 | use atrium_identity::{ 6 | did::{CommonDidResolver, CommonDidResolverConfig, DEFAULT_PLC_DIRECTORY_URL}, 7 | handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig}, 8 | }; 9 | use atrium_oauth::{ 10 | AtprotoClientMetadata, AtprotoLocalhostClientMetadata, AuthMethod, DefaultHttpClient, 11 | GrantType, KnownScope, OAuthClient, OAuthClientConfig, OAuthResolverConfig, Scope, 12 | }; 13 | use std::sync::Arc; 14 | 15 | pub type OAuthClientType = Arc< 16 | OAuthClient< 17 | IndexDBStateStore, 18 | IndexDBSessionStore, 19 | CommonDidResolver<DefaultHttpClient>, 20 | AtprotoHandleResolver<ApiDNSTxtResolver, DefaultHttpClient>, 21 | >, 22 | >; 23 | 24 | pub async fn handle_resolve_from_did(did: Did) -> Option<String> { 25 | let http_client = Arc::new(DefaultHttpClient::default()); 26 | let did_resolver = CommonDidResolver::new(CommonDidResolverConfig { 27 | plc_directory_url: DEFAULT_PLC_DIRECTORY_URL.to_string(), 28 | http_client: http_client.clone(), 29 | }); 30 | 31 | let resolved_did = did_resolver.resolve(&did).await; 32 | match resolved_did { 33 | Ok(doc) => match doc.also_known_as { 34 | None => None, 35 | Some(known_as) => { 36 | if known_as.is_empty() { 37 | return None; 38 | } 39 | Some(known_as[0].clone().replace("at://", "")) 40 | } 41 | }, 42 | Err(err) => { 43 | log::error!("Error resolving did: {}", err); 44 | None 45 | } 46 | } 47 | } 48 | 49 | pub fn oauth_client() -> OAuthClientType { 50 | // Create a new OAuth client 51 | let http_client = Arc::new(DefaultHttpClient::default()); 52 | let session_store = IndexDBSessionStore::new(); 53 | let state_store = IndexDBStateStore::new(); 54 | let resolver = OAuthResolverConfig { 55 | did_resolver: CommonDidResolver::new(CommonDidResolverConfig { 56 | plc_directory_url: DEFAULT_PLC_DIRECTORY_URL.to_string(), 57 | http_client: http_client.clone(), 58 | }), 59 | handle_resolver: AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 60 | dns_txt_resolver: ApiDNSTxtResolver::default(), 61 | http_client: http_client.clone(), 62 | }), 63 | authorization_server_metadata: Default::default(), 64 | protected_resource_metadata: Default::default(), 65 | }; 66 | 67 | let origin = std::option_env!("APP_ORIGIN") 68 | .unwrap_or("http://127.0.0.1:8080") 69 | .to_string(); 70 | 71 | match origin.contains("127.0.0.1") { 72 | true => { 73 | let config = OAuthClientConfig { 74 | client_metadata: AtprotoLocalhostClientMetadata { 75 | redirect_uris: Some(vec![format!("{}/oauth/callback", origin)]), 76 | scopes: Some(vec![ 77 | Scope::Known(KnownScope::Atproto), 78 | Scope::Known(KnownScope::TransitionGeneric), 79 | ]), 80 | }, 81 | keys: None, 82 | state_store, 83 | session_store, 84 | resolver, 85 | }; 86 | Arc::new(OAuthClient::new(config).expect("failed to create OAuth client")) 87 | } 88 | false => { 89 | let client_metadata = AtprotoClientMetadata { 90 | client_id: format!("{}/client_metadata.json", origin), 91 | client_uri: Some(origin.clone()), 92 | redirect_uris: vec![format!("{}/oauth/callback", origin)], 93 | token_endpoint_auth_method: AuthMethod::None, 94 | grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], 95 | scopes: vec![ 96 | Scope::Known(KnownScope::Atproto), 97 | Scope::Known(KnownScope::TransitionGeneric), 98 | ], 99 | jwks_uri: None, 100 | token_endpoint_auth_signing_alg: None, 101 | }; 102 | let config = OAuthClientConfig { 103 | client_metadata, 104 | keys: None, 105 | state_store, 106 | session_store, 107 | resolver, 108 | }; 109 | Arc::new(OAuthClient::new(config).expect("failed to create OAuth client")) 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /types_2048/src/record.rs: -------------------------------------------------------------------------------- 1 | // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 | //!A collection of known record types. 3 | #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 4 | #[serde(tag = "$type")] 5 | pub enum KnownRecord { 6 | #[serde(rename = "blue.2048.game")] 7 | Blue2048Game(Box<crate::blue::_2048::game::Record>), 8 | #[serde(rename = "blue.2048.key.game")] 9 | Blue2048KeyGame(Box<crate::blue::_2048::key::game::Record>), 10 | #[serde(rename = "blue.2048.key.player.stats")] 11 | Blue2048KeyPlayerStats(Box<crate::blue::_2048::key::player::stats::Record>), 12 | #[serde(rename = "blue.2048.player.profile")] 13 | Blue2048PlayerProfile(Box<crate::blue::_2048::player::profile::Record>), 14 | #[serde(rename = "blue.2048.player.stats")] 15 | Blue2048PlayerStats(Box<crate::blue::_2048::player::stats::Record>), 16 | #[serde(rename = "blue.2048.verification.game")] 17 | Blue2048VerificationGame(Box<crate::blue::_2048::verification::game::Record>), 18 | #[serde(rename = "blue.2048.verification.stats")] 19 | Blue2048VerificationStats(Box<crate::blue::_2048::verification::stats::Record>), 20 | } 21 | impl From<crate::blue::_2048::game::Record> for KnownRecord { 22 | fn from(record: crate::blue::_2048::game::Record) -> Self { 23 | KnownRecord::Blue2048Game(Box::new(record)) 24 | } 25 | } 26 | impl From<crate::blue::_2048::game::RecordData> for KnownRecord { 27 | fn from(record_data: crate::blue::_2048::game::RecordData) -> Self { 28 | KnownRecord::Blue2048Game(Box::new(record_data.into())) 29 | } 30 | } 31 | impl From<crate::blue::_2048::key::game::Record> for KnownRecord { 32 | fn from(record: crate::blue::_2048::key::game::Record) -> Self { 33 | KnownRecord::Blue2048KeyGame(Box::new(record)) 34 | } 35 | } 36 | impl From<crate::blue::_2048::key::game::RecordData> for KnownRecord { 37 | fn from(record_data: crate::blue::_2048::key::game::RecordData) -> Self { 38 | KnownRecord::Blue2048KeyGame(Box::new(record_data.into())) 39 | } 40 | } 41 | impl From<crate::blue::_2048::key::player::stats::Record> for KnownRecord { 42 | fn from(record: crate::blue::_2048::key::player::stats::Record) -> Self { 43 | KnownRecord::Blue2048KeyPlayerStats(Box::new(record)) 44 | } 45 | } 46 | impl From<crate::blue::_2048::key::player::stats::RecordData> for KnownRecord { 47 | fn from(record_data: crate::blue::_2048::key::player::stats::RecordData) -> Self { 48 | KnownRecord::Blue2048KeyPlayerStats(Box::new(record_data.into())) 49 | } 50 | } 51 | impl From<crate::blue::_2048::player::profile::Record> for KnownRecord { 52 | fn from(record: crate::blue::_2048::player::profile::Record) -> Self { 53 | KnownRecord::Blue2048PlayerProfile(Box::new(record)) 54 | } 55 | } 56 | impl From<crate::blue::_2048::player::profile::RecordData> for KnownRecord { 57 | fn from(record_data: crate::blue::_2048::player::profile::RecordData) -> Self { 58 | KnownRecord::Blue2048PlayerProfile(Box::new(record_data.into())) 59 | } 60 | } 61 | impl From<crate::blue::_2048::player::stats::Record> for KnownRecord { 62 | fn from(record: crate::blue::_2048::player::stats::Record) -> Self { 63 | KnownRecord::Blue2048PlayerStats(Box::new(record)) 64 | } 65 | } 66 | impl From<crate::blue::_2048::player::stats::RecordData> for KnownRecord { 67 | fn from(record_data: crate::blue::_2048::player::stats::RecordData) -> Self { 68 | KnownRecord::Blue2048PlayerStats(Box::new(record_data.into())) 69 | } 70 | } 71 | impl From<crate::blue::_2048::verification::game::Record> for KnownRecord { 72 | fn from(record: crate::blue::_2048::verification::game::Record) -> Self { 73 | KnownRecord::Blue2048VerificationGame(Box::new(record)) 74 | } 75 | } 76 | impl From<crate::blue::_2048::verification::game::RecordData> for KnownRecord { 77 | fn from(record_data: crate::blue::_2048::verification::game::RecordData) -> Self { 78 | KnownRecord::Blue2048VerificationGame(Box::new(record_data.into())) 79 | } 80 | } 81 | impl From<crate::blue::_2048::verification::stats::Record> for KnownRecord { 82 | fn from(record: crate::blue::_2048::verification::stats::Record) -> Self { 83 | KnownRecord::Blue2048VerificationStats(Box::new(record)) 84 | } 85 | } 86 | impl From<crate::blue::_2048::verification::stats::RecordData> for KnownRecord { 87 | fn from(record_data: crate::blue::_2048::verification::stats::RecordData) -> Self { 88 | KnownRecord::Blue2048VerificationStats(Box::new(record_data.into())) 89 | } 90 | } 91 | impl Into<atrium_api::types::Unknown> for KnownRecord { 92 | fn into(self) -> atrium_api::types::Unknown { 93 | atrium_api::types::TryIntoUnknown::try_into_unknown(&self).unwrap() 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /client_2048/src/atrium_stores.rs: -------------------------------------------------------------------------------- 1 | use crate::idb::{ 2 | DB_NAME, SESSIONS_STORE, STATE_STORE, clear_store, object_delete, object_get, transaction_put, 3 | }; 4 | /// Storage impls to persis OAuth sessions if you are not using the memory stores 5 | /// https://github.com/bluesky-social/statusphere-example-app/blob/main/src/auth/storage.ts 6 | use atrium_api::types::string::Did; 7 | use atrium_common::store::Store; 8 | use atrium_oauth::store::session::SessionStore; 9 | use atrium_oauth::store::state::StateStore; 10 | use indexed_db_futures::database::Database; 11 | use serde::Serialize; 12 | use serde::de::DeserializeOwned; 13 | use std::error::Error as StdError; 14 | use std::fmt::{Debug, Display}; 15 | use std::hash::Hash; 16 | 17 | #[derive(Debug)] 18 | pub enum AuthStoreError { 19 | InvalidSession, 20 | NoSessionFound, 21 | DatabaseError(String), 22 | } 23 | 24 | impl Display for AuthStoreError { 25 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 26 | match self { 27 | Self::InvalidSession => write!(f, "Invalid session"), 28 | Self::NoSessionFound => write!(f, "No session found"), 29 | Self::DatabaseError(err) => write!(f, "Database error: {}", err), 30 | } 31 | } 32 | } 33 | 34 | impl StdError for AuthStoreError {} 35 | 36 | ///Persistent session store in sqlite 37 | impl SessionStore for IndexDBSessionStore {} 38 | 39 | pub struct IndexDBSessionStore { 40 | // db: Database, 41 | } 42 | 43 | impl IndexDBSessionStore { 44 | pub fn new() -> Self { 45 | Self {} 46 | } 47 | } 48 | 49 | impl<K, V> Store<K, V> for IndexDBSessionStore 50 | where 51 | K: Debug + Eq + Hash + Send + Sync + 'static + From<Did> + AsRef<str>, 52 | V: Debug + Clone + Send + Sync + 'static + Serialize + DeserializeOwned, 53 | { 54 | type Error = AuthStoreError; 55 | async fn get(&self, key: &K) -> Result<Option<V>, Self::Error> { 56 | let did = key.as_ref().to_string(); 57 | let db = Database::open(DB_NAME).await.unwrap(); 58 | match object_get::<V>(db.clone(), SESSIONS_STORE, &*did).await { 59 | Ok(Some(session)) => Ok(Some(session)), 60 | Ok(None) => Err(AuthStoreError::NoSessionFound), 61 | Err(e) => { 62 | log::error!("Database error: {}", e.to_string()); 63 | Err(AuthStoreError::DatabaseError(e.to_string())) 64 | } 65 | } 66 | } 67 | 68 | async fn set(&self, key: K, value: V) -> Result<(), Self::Error> { 69 | let did = key.as_ref().to_string(); 70 | let db = Database::open(DB_NAME).await.unwrap(); 71 | transaction_put(db.clone(), &value, SESSIONS_STORE, Some(did)) 72 | .await 73 | .map_err(|e| AuthStoreError::DatabaseError(e.to_string())) 74 | } 75 | 76 | async fn del(&self, _key: &K) -> Result<(), Self::Error> { 77 | let key = _key.as_ref().to_string(); 78 | let db = Database::open(DB_NAME).await.unwrap(); 79 | object_delete(db.clone(), SESSIONS_STORE, &*key) 80 | .await 81 | .map_err(|e| AuthStoreError::DatabaseError(e.to_string())) 82 | } 83 | 84 | async fn clear(&self) -> Result<(), Self::Error> { 85 | let db = Database::open(DB_NAME).await.unwrap(); 86 | clear_store(db.clone(), SESSIONS_STORE) 87 | .await 88 | .map_err(|e| AuthStoreError::DatabaseError(e.to_string())) 89 | } 90 | } 91 | 92 | impl StateStore for IndexDBStateStore {} 93 | 94 | pub struct IndexDBStateStore {} 95 | 96 | impl IndexDBStateStore { 97 | pub fn new() -> Self { 98 | Self {} 99 | } 100 | } 101 | 102 | impl<K, V> Store<K, V> for IndexDBStateStore 103 | where 104 | K: Debug + Eq + Hash + Send + Sync + 'static + From<Did> + AsRef<str>, 105 | V: Debug + Clone + Send + Sync + 'static + Serialize + DeserializeOwned, 106 | { 107 | type Error = AuthStoreError; 108 | async fn get(&self, key: &K) -> Result<Option<V>, Self::Error> { 109 | let key = key.as_ref().to_string(); 110 | let db = Database::open(DB_NAME).await.unwrap(); 111 | match object_get::<V>(db.clone(), STATE_STORE, &*key).await { 112 | Ok(Some(session)) => Ok(Some(session)), 113 | Ok(None) => Err(AuthStoreError::NoSessionFound), 114 | Err(e) => { 115 | log::error!("Database error: {}", e.to_string()); 116 | Err(AuthStoreError::DatabaseError(e.to_string())) 117 | } 118 | } 119 | } 120 | 121 | async fn set(&self, key: K, value: V) -> Result<(), Self::Error> { 122 | let did = key.as_ref().to_string(); 123 | let db = Database::open(DB_NAME).await.unwrap(); 124 | match transaction_put(db.clone(), &value, STATE_STORE, Some(did)) 125 | .await 126 | .map_err(|e| AuthStoreError::DatabaseError(e.to_string())) 127 | { 128 | Ok(_) => Ok(()), 129 | Err(e) => Err(e), 130 | } 131 | } 132 | 133 | async fn del(&self, _key: &K) -> Result<(), Self::Error> { 134 | let key = _key.as_ref().to_string(); 135 | let db = Database::open(DB_NAME).await.unwrap(); 136 | object_delete(db.clone(), STATE_STORE, &*key) 137 | .await 138 | .map_err(|e| AuthStoreError::DatabaseError(e.to_string())) 139 | } 140 | 141 | async fn clear(&self) -> Result<(), Self::Error> { 142 | let db = Database::open(DB_NAME).await.unwrap(); 143 | clear_store(db.clone(), STATE_STORE) 144 | .await 145 | .map_err(|e| AuthStoreError::DatabaseError(e.to_string())) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /client_2048/src/pages/callback.rs: -------------------------------------------------------------------------------- 1 | use crate::Route; 2 | use crate::at_repo_sync::AtRepoSync; 3 | use crate::oauth_client::oauth_client; 4 | use crate::store::UserStore; 5 | use atrium_api::agent::Agent; 6 | use atrium_oauth::CallbackParams; 7 | use yew::platform::spawn_local; 8 | use yew::{Html, function_component, html, use_state_eq}; 9 | use yew_hooks::use_effect_once; 10 | use yew_router::hooks::use_location; 11 | use yew_router::prelude::use_navigator; 12 | use yewdux::prelude::*; 13 | 14 | #[function_component(CallbackPage)] 15 | pub fn callback() -> Html { 16 | log::info!("Callback rendered"); 17 | let location = use_location(); 18 | let oauth_client = oauth_client(); 19 | let (user_store, dispatch) = use_store::<UserStore>(); 20 | let error = use_state_eq(|| None); 21 | let navigator = use_navigator().unwrap(); 22 | let error_view_clone = error.clone(); 23 | 24 | use_effect_once(move || { 25 | match location { 26 | None => {} 27 | Some(location) => { 28 | spawn_local(async move { 29 | log::info!("Callback effect called"); 30 | match serde_html_form::from_str::<CallbackParams>( 31 | &*location.query_str().replace("?", ""), 32 | ) { 33 | Ok(params) => { 34 | match oauth_client.callback(params).await { 35 | Ok((session, _)) => { 36 | let agent = Agent::new(session); 37 | let did = agent.did().await.unwrap(); 38 | 39 | let profile = agent 40 | .api 41 | .app 42 | .bsky 43 | .actor 44 | .get_profile( 45 | atrium_api::app::bsky::actor::get_profile::ParametersData { 46 | actor: atrium_api::types::string::AtIdentifier::Did(did.clone()), 47 | } 48 | .into(), 49 | ) 50 | .await; 51 | //HACK 52 | dispatch.set(UserStore { 53 | did: Some(did.clone()), 54 | handle: match profile { 55 | Ok(profile) => Some(profile.handle.clone()), 56 | Err(_) => None, 57 | }, 58 | }); 59 | let at_repo_sync = AtRepoSync::new_logged_in_repo(agent, did); 60 | match at_repo_sync.sync_profiles().await { 61 | Ok(_) => {} 62 | Err(err) => { 63 | log::error!("Error: {:?}", err.to_string()); 64 | error_view_clone.set(Some("There was an error with your login and syncing your profiles. Try again or can check the console for more details.")); 65 | } 66 | } 67 | 68 | match at_repo_sync.sync_stats().await { 69 | Ok(_) => {} 70 | Err(err) => { 71 | log::error!("Error: {:?}", err.to_string()); 72 | error_view_clone.set(Some("There was an error with your login and syncing your stats. Try again or can check the console for more details.")); 73 | } 74 | } 75 | 76 | navigator.push(&Route::GamePage) 77 | } // None => { 78 | // error_view_clone.set(Some("There was an error with your login. Try again or can check the console for more details.")); 79 | // } 80 | Err(err) => { 81 | log::error!("Error: {:?}", err); 82 | error_view_clone.set(Some("There was an error with your login. Try again or can check the console for more details.")); 83 | } 84 | } 85 | } 86 | Err(_) => { 87 | error_view_clone.set(Some("No call back parameters found in the URL.")); 88 | } 89 | } 90 | }); 91 | } 92 | }; 93 | || () 94 | }); 95 | html! { 96 | <div class="container mx-auto flex flex-col items-center md:mt-6 mt-4 min-h-screen p-4"> 97 | if user_store.did.is_none() && error.as_ref().is_none() { 98 | <div class="flex items-center justify-center"> 99 | <span class="loading loading-spinner loading-lg" /> 100 | <h1 class="ml-4 text-3xl font-bold">{ "Loading..." }</h1> 101 | </div> 102 | } 103 | if let Some(error) = error.as_ref() { 104 | <h1 class="text-4xl">{ error }</h1> 105 | } 106 | </div> 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /client_2048/src/pages/seed.rs: -------------------------------------------------------------------------------- 1 | use crate::Route; 2 | use crate::idb::{CURRENT_GAME_STORE, DB_NAME, SELF_KEY, transaction_put}; 3 | use atrium_api::types::string::Datetime; 4 | use indexed_db_futures::database::Database; 5 | use twothousand_forty_eight::v2::recording::SeededRecording; 6 | use types_2048::blue; 7 | use types_2048::blue::_2048::defs::SyncStatusData; 8 | use web_sys::{HtmlInputElement, InputEvent, SubmitEvent}; 9 | use yew::platform::spawn_local; 10 | use yew::{ 11 | Callback, Html, Properties, TargetCast, classes, function_component, html, use_state_eq, 12 | }; 13 | use yew_router::hooks::use_navigator; 14 | 15 | #[derive(Properties, Clone, PartialEq)] 16 | pub struct SeedProps { 17 | pub starting_seed: Option<u32>, 18 | } 19 | 20 | #[function_component(SeedPage)] 21 | pub fn seed(props: &SeedProps) -> Html { 22 | let seed_input = use_state_eq(|| props.starting_seed.unwrap_or(0)); 23 | let error = use_state_eq(|| None); 24 | let navigator = use_navigator().unwrap(); 25 | let on_input_handle = seed_input.clone(); 26 | let error_input = error.clone(); 27 | 28 | let oninput = Callback::from(move |input_event: InputEvent| { 29 | let target: HtmlInputElement = input_event.target_unchecked_into(); 30 | match target.value().parse::<u32>() { 31 | Ok(seed) => { 32 | on_input_handle.set(seed); 33 | } 34 | Err(_) => { 35 | error_input.set(Some("Seed must be a number")); 36 | } 37 | } 38 | }); 39 | let error_view_clone = error.clone(); 40 | let onsubmit = { 41 | let seed_input = seed_input.clone(); 42 | let error_input = error.clone(); 43 | let navigator = navigator.clone(); 44 | Callback::from(move |event: SubmitEvent| { 45 | let error_callback_clone = error_input.clone(); 46 | error_callback_clone.set(None); 47 | event.prevent_default(); 48 | let seed_value = *seed_input; 49 | let error_spawn = error_input.clone(); 50 | let nav = navigator.clone(); 51 | spawn_local(async move { 52 | let history = SeededRecording::empty(seed_value, 4, 4); 53 | let history_string: String = (&history).into(); 54 | 55 | let db = match Database::open(DB_NAME).await { 56 | Ok(db) => db, 57 | Err(err) => { 58 | panic!("Error opening database: {:?}", err); 59 | } 60 | }; 61 | let current_game = blue::_2048::game::RecordData { 62 | completed: false, 63 | created_at: Datetime::now(), 64 | current_score: 0, 65 | seeded_recording: history_string, 66 | sync_status: SyncStatusData { 67 | created_at: Datetime::now(), 68 | hash: "".to_string(), 69 | synced_with_at_repo: false, 70 | updated_at: Datetime::now(), 71 | } 72 | .into(), 73 | won: false, 74 | }; 75 | let result = transaction_put( 76 | db.clone(), 77 | current_game.clone(), 78 | CURRENT_GAME_STORE, 79 | Some(SELF_KEY.to_string()), 80 | ) 81 | .await; 82 | match result { 83 | Ok(_) => nav.push(&Route::GamePage), 84 | Err(e) => { 85 | log::info!("{:?}", current_game); 86 | log::error!("{:?}", e.to_string()); 87 | error_spawn.set(Some("Error creating a new game from that seed")); 88 | } 89 | }; 90 | }); 91 | }) 92 | }; 93 | 94 | html! { 95 | <div class="container mx-auto flex flex-col items-center md:mt-6 mt-4 min-h-screen p-4"> 96 | <h1 97 | class="md:text-5xl text-4xl font-bold mb-8 bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent" 98 | > 99 | { "at://2048" } 100 | </h1> 101 | <div 102 | class="backdrop-blur-md bg-base-200/50 p-6 rounded-lg shadow-lg mb-8 max-w-md w-full" 103 | > 104 | <p class="text-lg mb-4"> 105 | { "Someone share a starting seed with you? Type it here to replace your current game with that seed and see if you can do better than your friends!" } 106 | </p> 107 | <form {onsubmit} class="w-full flex flex-col items-center pt-1"> 108 | <div class="join w-full"> 109 | <div class="w-full"> 110 | <label 111 | class={classes!("w-full", "input", "join-item", error_view_clone.is_none().then(|| Some("dark:input-primary eink:input-neutral")), error_view_clone.is_some().then(|| Some("input-error")))} 112 | > 113 | <input 114 | {oninput} 115 | value={(*seed_input).to_string()} 116 | type="number" 117 | class="w-full" 118 | placeholder="Enter the game's starting seed here" 119 | /> 120 | </label> 121 | if let Some(error_message) = error_view_clone.as_ref() { 122 | <div class="text-error">{ error_message }</div> 123 | } 124 | </div> 125 | <button 126 | type="submit" 127 | class="btn btn-neutral eink:btn-outline dark:btn-primary join-item" 128 | > 129 | { "New Game" } 130 | </button> 131 | </div> 132 | </form> 133 | </div> 134 | <div class="container mx-auto p-4" /> 135 | </div> 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /client_2048/src/pages/login.rs: -------------------------------------------------------------------------------- 1 | use crate::oauth_client::{handle_resolve_from_did, oauth_client}; 2 | use crate::store::UserStore; 3 | use atrium_api::types::string::Did; 4 | use atrium_oauth::{AuthorizeOptions, KnownScope, Scope}; 5 | use std::str::FromStr; 6 | use web_sys::{HtmlInputElement, InputEvent, SubmitEvent}; 7 | use yew::platform::spawn_local; 8 | use yew::{ 9 | Callback, Html, Properties, TargetCast, classes, function_component, html, use_state_eq, 10 | }; 11 | use yew_hooks::use_effect_once; 12 | use yewdux::use_store; 13 | 14 | pub async fn redirect_to_auth(handle: String) -> Result<(), String> { 15 | let client = oauth_client(); 16 | let oauth_client = client.clone(); 17 | 18 | let url = oauth_client 19 | .authorize( 20 | handle.to_string().to_lowercase(), 21 | AuthorizeOptions { 22 | scopes: vec![ 23 | Scope::Known(KnownScope::Atproto), 24 | Scope::Known(KnownScope::TransitionGeneric), 25 | ], 26 | ..Default::default() 27 | }, 28 | ) 29 | .await; 30 | match url { 31 | Ok(url) => { 32 | let window = gloo_utils::window(); 33 | 34 | match window.location().set_href(&url) { 35 | Ok(_) => Ok(()), 36 | Err(err) => { 37 | log::error!("login error: {:?}", err); 38 | Err(String::from("Error redirecting to the login page")) 39 | } 40 | } 41 | } 42 | Err(err) => { 43 | log::error!("login error: {}", err); 44 | let error_str = format!("login error: {}", err); 45 | Err(error_str) 46 | } 47 | } 48 | } 49 | 50 | #[derive(Properties, Clone, PartialEq)] 51 | pub struct LoginProps { 52 | pub did: Option<String>, 53 | } 54 | 55 | #[function_component(LoginPage)] 56 | pub fn login(props: &LoginProps) -> Html { 57 | let props = props.clone(); 58 | let handle = use_state_eq(|| String::new()); 59 | let has_redirect_did = props.did.is_some(); 60 | 61 | let starting_error = if has_redirect_did { 62 | Some( 63 | "Your login session has expired. Attempting to redirect you to the login page." 64 | .to_string(), 65 | ) 66 | } else { 67 | None 68 | }; 69 | let error_state = use_state_eq(|| starting_error); 70 | let error_state_effect = error_state.clone(); 71 | let (_, dispatch) = use_store::<UserStore>(); 72 | 73 | use_effect_once(move || { 74 | if let Some(users_did) = props.did.clone() { 75 | spawn_local(async move { 76 | let error = match Did::from_str(&users_did) { 77 | Ok(did) => match handle_resolve_from_did(did).await { 78 | Some(handle) => match redirect_to_auth(handle).await { 79 | Ok(_) => None, 80 | Err(err) => Some(err), 81 | }, 82 | None => None, 83 | }, 84 | Err(err) => Some(err.to_string()), 85 | }; 86 | if let Some(error) = error { 87 | dispatch.set(UserStore::default()); 88 | error_state_effect.set(Some(format!("Error from redirect: {}", error))); 89 | } 90 | }); 91 | } 92 | || () 93 | }); 94 | 95 | let on_input_handle = handle.clone(); 96 | let oninput = Callback::from(move |input_event: InputEvent| { 97 | let target: HtmlInputElement = input_event.target_unchecked_into(); 98 | on_input_handle.set(target.value()); 99 | }); 100 | let error_view_clone = error_state.clone(); 101 | let onsubmit = { 102 | move |event: SubmitEvent| { 103 | let error_callback_clone = error_state.clone(); 104 | error_callback_clone.set(None); 105 | event.prevent_default(); 106 | let handle = handle.clone(); 107 | spawn_local(async move { 108 | match redirect_to_auth((*handle).clone()).await { 109 | Ok(_) => return, 110 | Err(err) => { 111 | error_callback_clone.set(Some(err)); 112 | return; 113 | } 114 | } 115 | }); 116 | } 117 | }; 118 | 119 | html! { 120 | <div class="container mx-auto flex flex-col items-center md:mt-6 mt-4 min-h-screen p-4"> 121 | <h1 122 | class="md:text-5xl text-4xl font-bold mb-8 bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent" 123 | > 124 | { "at://2048" } 125 | </h1> 126 | <div 127 | class="backdrop-blur-md bg-base-200/50 p-6 rounded-lg shadow-lg mb-8 max-w-md w-full" 128 | > 129 | <p class="text-lg mb-4"> 130 | { "You can use at://2048 without a login. But if you do login with your ATProto account you can:" } 131 | </p> 132 | <ul class="list-disc list-inside space-y-2 mb-4"> 133 | <li>{ "Save your progress across multiple devices" }</li> 134 | <li>{ "Track your statistics across multiple devices" }</li> 135 | <li>{ "Compete on global leaderboards (future)" }</li> 136 | <li>{ "See friends scores (future)" }</li> 137 | <li>{ "The data is 100% yours stored in your PDS" }</li> 138 | </ul> 139 | <form {onsubmit} class="w-full flex flex-col items-center pt-1"> 140 | <div class="join w-full"> 141 | <div class="w-full"> 142 | <label 143 | class={classes!("w-full", "input", "join-item", error_view_clone.is_none().then(|| Some("dark:input-primary eink:input-neutral")), error_view_clone.is_some().then(|| Some("input-error")))} 144 | > 145 | <input 146 | {oninput} 147 | type="text" 148 | class="w-full" 149 | placeholder="Enter your handle (eg 2048.bsky.social)" 150 | /> 151 | </label> 152 | if let Some(error_message) = error_view_clone.as_ref() { 153 | <div class="text-error">{ error_message }</div> 154 | } 155 | </div> 156 | <button 157 | type="submit" 158 | class="btn btn-neutral eink:btn-outline dark:btn-primary join-item" 159 | > 160 | { "Login" } 161 | </button> 162 | </div> 163 | </form> 164 | </div> 165 | <div class="container mx-auto p-4" /> 166 | </div> 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /client_2048/LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | -------------------------------------------------------------------------------- /client_2048/src/lib.rs: -------------------------------------------------------------------------------- 1 | use crate::agent::{Postcard, StorageTask}; 2 | use crate::at_repo_sync::AtRepoSync; 3 | use crate::components::theme_picker::ThemePicker; 4 | use crate::idb::{DB_NAME, SESSIONS_STORE, object_delete}; 5 | use crate::oauth_client::oauth_client; 6 | use crate::pages::callback::CallbackPage; 7 | use crate::pages::game::GamePage; 8 | use crate::pages::history::HistoryPage; 9 | use crate::pages::login::LoginPage; 10 | use crate::pages::seed::SeedPage; 11 | use crate::pages::stats::StatsPage; 12 | use crate::store::UserStore; 13 | use atrium_api::agent::Agent; 14 | use gloo_utils::document; 15 | use indexed_db_futures::database::Database; 16 | use wasm_bindgen::JsCast; 17 | use web_sys::{HtmlElement, HtmlInputElement}; 18 | use yew::platform::spawn_local; 19 | use yew::prelude::*; 20 | use yew_agent::oneshot::OneshotProvider; 21 | use yew_hooks::use_effect_once; 22 | use yew_router::prelude::*; 23 | use yewdux::use_store; 24 | 25 | pub mod agent; 26 | pub mod at_repo_sync; 27 | mod atrium_stores; 28 | mod components; 29 | pub mod idb; 30 | pub mod oauth_client; 31 | mod pages; 32 | mod resolver; 33 | pub mod store; 34 | 35 | #[derive(Clone, Routable, PartialEq)] 36 | enum Route { 37 | #[at("/")] 38 | GamePage, 39 | #[at("/login")] 40 | LoginPage, 41 | #[at("/login/:did")] 42 | LoginPageWithDid { did: String }, 43 | #[at("/oauth/callback")] 44 | CallbackPage, 45 | #[at("/stats")] 46 | StatsPage, 47 | #[at("/seed/:seed")] 48 | SeedPage { seed: u32 }, 49 | #[at("/seed")] 50 | SeedPageNoSeed, 51 | #[at("/history")] 52 | HistoryPage, 53 | #[not_found] 54 | #[at("/404")] 55 | NotFound, 56 | } 57 | 58 | fn switch(routes: Route) -> Html { 59 | match routes { 60 | Route::GamePage => html! { <GamePage /> }, 61 | Route::LoginPage => html! { <LoginPage did={None::<String>} /> }, 62 | Route::LoginPageWithDid { did } => html! { <LoginPage did={Some(did)} /> }, 63 | Route::CallbackPage => html! { <CallbackPage /> }, 64 | Route::StatsPage => html! { <StatsPage /> }, 65 | Route::SeedPage { seed } => html! { <SeedPage starting_seed={seed} /> }, 66 | Route::SeedPageNoSeed => html! { <SeedPage starting_seed={None} /> }, 67 | Route::HistoryPage => { 68 | html! { <HistoryPage /> } 69 | } 70 | Route::NotFound => html! { <h1>{ "404" }</h1> }, 71 | } 72 | } 73 | 74 | fn check_drawer_open() { 75 | match document().get_element_by_id("my-drawer-3") { 76 | None => {} 77 | Some(element) => { 78 | let checkbox: HtmlInputElement = element.unchecked_into(); 79 | checkbox.set_checked(false); 80 | } 81 | } 82 | } 83 | 84 | #[function_component] 85 | fn Main() -> Html { 86 | let (user_store, dispatch) = use_store::<UserStore>(); 87 | 88 | let menu_entry_onclick = Callback::from(move |_: MouseEvent| { 89 | check_drawer_open(); 90 | }); 91 | 92 | let _submenu_entry_onclick = Callback::from(move |_: MouseEvent| { 93 | let collection = document().get_elements_by_class_name("lg:dropdown"); 94 | for i in 0..collection.length() { 95 | if let Some(element) = collection.item(i) { 96 | let element: HtmlElement = element.unchecked_into(); 97 | let _ = element.remove_attribute("open"); 98 | } 99 | } 100 | check_drawer_open(); 101 | }); 102 | 103 | let user_store_clone = user_store.clone(); 104 | let dispatch_clone = dispatch.clone(); 105 | //logout callback 106 | let onclick = Callback::from(move |_: MouseEvent| { 107 | check_drawer_open(); 108 | 109 | let user_store = user_store_clone.clone(); 110 | let dispatch = dispatch_clone.clone(); 111 | spawn_local(async move { 112 | let db = match Database::open(DB_NAME).await { 113 | Ok(db) => db, 114 | Err(err) => { 115 | log::error!("{:?}", err); 116 | return; 117 | } 118 | }; 119 | 120 | if let Some(did) = user_store.did.clone() { 121 | dispatch.set(UserStore::default()); 122 | 123 | object_delete(db, SESSIONS_STORE, &did) 124 | .await 125 | .unwrap_or_else(|err| { 126 | log::error!("{:?}", err); 127 | }) 128 | } 129 | }); 130 | }); 131 | 132 | let user_store_clone = user_store.clone(); 133 | use_effect_once(move || { 134 | if user_store_clone.did.is_some() { 135 | // Effect logic here 136 | spawn_local(async move { 137 | match user_store_clone.did.clone() { 138 | None => {} 139 | Some(did) => { 140 | let oauth_client = oauth_client(); 141 | let session = match oauth_client.restore(&did).await { 142 | Ok(session) => session, 143 | Err(err) => { 144 | log::error!("{:?}", err); 145 | return; 146 | } 147 | }; 148 | 149 | let agent = Agent::new(session); 150 | let at_repo_sync = AtRepoSync::new_logged_in_repo(agent, did); 151 | match at_repo_sync.sync_profiles().await { 152 | Ok(_) => {} 153 | Err(err) => { 154 | log::error!("Error syncing your profile: {:?}", err.to_string()); 155 | } 156 | } 157 | match at_repo_sync.sync_stats().await { 158 | Ok(_) => {} 159 | Err(err) => { 160 | log::error!("Error syncing stats: {:?}", err.to_string()); 161 | } 162 | } 163 | } 164 | } 165 | }); 166 | } 167 | 168 | || () 169 | }); 170 | 171 | let mut links: Vec<Html> = vec![ 172 | html! {<li key=1 onclick={menu_entry_onclick.clone()}><Link<Route> to={Route::GamePage}>{ "Play" }</Link<Route>></li>}, 173 | html! {<li key=2 onclick={menu_entry_onclick.clone()}><Link<Route> to={Route::StatsPage}>{ "Stats" }</Link<Route>></li>}, 174 | html! {<li key=3 onclick={menu_entry_onclick.clone()}><Link<Route> to={Route::HistoryPage}>{ "History" }</Link<Route>></li>}, 175 | //WIll come back to this, just buggy and clicks wrong entry sometimes 176 | // html! { 177 | // <li key={2}> 178 | // <details class="lg:dropdown invisible md:visible"> 179 | // <summary>{ 180 | // match &user_store.handle { 181 | // Some(handle) => handle.to_string(), 182 | // None => "Profile".to_string() 183 | // } 184 | // }</summary> 185 | // <ul class="lg:menu lg:dropdown-content lg:bg-base-100 lg:rounded-box z-1 lg:w-52 p-2 lg:shadow-sm"> 186 | // <li onclick={submenu_entry_onclick.clone()}><Link<Route> to={Route::StatsPage}>{ "Stats" }</Link<Route>></li> 187 | // <li onclick={submenu_entry_onclick.clone()}><Link<Route> to={Route::HistoryPage}>{ "History" }</Link<Route>></li> 188 | // </ul> 189 | // </details> 190 | // </li> 191 | // }, 192 | ]; 193 | 194 | links.push(html! { 195 | <li key=4> 196 | <a href="https://github.com/fatfingers23/at_2048">{ "GitHub" }</a> 197 | </li> 198 | }); 199 | 200 | if user_store.did.is_some() { 201 | links.push(html! { 202 | <li key=5> 203 | <a class="cursor-pointer" {onclick}>{ "Logout" }</a> 204 | </li> 205 | }); 206 | } else { 207 | links.push(html! { 208 | <li key=5 {onclick}> 209 | <Link<Route> to={Route::LoginPage}>{ "Login" }</Link<Route>> 210 | </li> 211 | }); 212 | } 213 | 214 | html! { 215 | <div class="drawer"> 216 | <input id="my-drawer-3" type="checkbox" class="drawer-toggle" /> 217 | <div class="drawer-content flex flex-col"> 218 | // <!-- Navbar --> 219 | <div class="navbar bg-base-300 w-full"> 220 | <div class="flex-none lg:hidden"> 221 | <label 222 | for="my-drawer-3" 223 | aria-label="open sidebar" 224 | class="btn btn-square btn-ghost" 225 | > 226 | <svg 227 | xmlns="http://www.w3.org/2000/svg" 228 | fill="none" 229 | viewBox="0 0 24 24" 230 | class="inline-block h-6 w-6 stroke-current" 231 | > 232 | <path 233 | stroke-linecap="round" 234 | stroke-linejoin="round" 235 | stroke-width="2" 236 | d="M4 6h16M4 12h16M4 18h16" 237 | /> 238 | </svg> 239 | </label> 240 | </div> 241 | <div class="text-xl mx-2 flex-1 px-2">{ "at://2048 (alpha)" }</div> 242 | <div class="hidden flex-none lg:block"> 243 | <ul class="menu menu-horizontal"> 244 | // <!-- Navbar menu content here --> 245 | { links.iter().cloned().collect::<Html>() } 246 | </ul> 247 | </div> 248 | <div class="md:block hidden"> 249 | <ThemePicker /> 250 | </div> 251 | </div> 252 | <main> 253 | <Switch<Route> render={switch} /> 254 | </main> 255 | </div> 256 | <div class="drawer-side"> 257 | <label for="my-drawer-3" aria-label="close sidebar" class="drawer-overlay" /> 258 | <ul class="menu bg-base-200 min-h-full w-80 p-4"> 259 | // <!-- Sidebar content here --> 260 | { links.iter().cloned().collect::<Html>() } 261 | // { for links.clone().into_iter().enumerate().map(|(i, link)| html! { <li key={i} onclick={menu_entry_onclick.clone()}>{ link }</li> }) } 262 | <div class="p-4"> 263 | <ThemePicker /> 264 | </div> 265 | </ul> 266 | </div> 267 | </div> 268 | } 269 | } 270 | 271 | #[function_component(App)] 272 | pub fn app() -> Html { 273 | html! { 274 | <OneshotProvider<StorageTask, Postcard> path="/worker.js"> 275 | <BrowserRouter> 276 | <Main /> 277 | </BrowserRouter> 278 | </OneshotProvider<StorageTask, Postcard>> 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /client_2048/src/idb.rs: -------------------------------------------------------------------------------- 1 | use atrium_api::types::string::RecordKey; 2 | use indexed_db_futures::cursor::CursorDirection; 3 | use indexed_db_futures::database::Database; 4 | use indexed_db_futures::error::OpenDbError; 5 | use indexed_db_futures::prelude::*; 6 | use indexed_db_futures::transaction::TransactionMode; 7 | use indexed_db_futures::{KeyPath, SerialiseToJs}; 8 | use serde::{Deserialize, Serialize}; 9 | 10 | //Object Store names 11 | /// db name 12 | pub const DB_NAME: &str = "2048"; 13 | /// Store for game history(blue.2048.game), keys are record keys 14 | pub const GAME_STORE: &str = "games"; 15 | /// Store for current game, 1 record for the store uses self as the key 16 | pub const CURRENT_GAME_STORE: &str = "current_game"; 17 | /// Store for the user stats(blue.2048.player.stats) self as the key 18 | pub const STATS_STORE: &str = "stats"; 19 | /// Store for the user profile(blue.2048.player.profile) self as the key 20 | pub const PROFILE_STORE: &str = "profile"; 21 | /// Store for did:keys like blue.2048.key.game or blue.2048.key.player.stats 22 | pub const KEY_STORE: &str = "did:keys"; 23 | /// did resolver store 24 | pub const DID_RESOLVER_STORE: &str = "did:resolver"; 25 | /// atrium StateStore 26 | pub const STATE_STORE: &str = "states"; 27 | /// atrium SessionStore 28 | pub const SESSIONS_STORE: &str = "sessions"; 29 | 30 | /// Static keys for one record stores 31 | pub const SELF_KEY: &str = "self"; 32 | 33 | pub async fn create_database() -> Result<Database, OpenDbError> { 34 | let db = Database::open(DB_NAME) 35 | .with_version(1u8) 36 | .with_on_blocked(|event| { 37 | log::debug!("DB upgrade blocked: {:?}", event); 38 | Ok(()) 39 | }) 40 | .with_on_upgrade_needed_fut(|event, db| async move { 41 | match (event.old_version(), event.new_version()) { 42 | (0.0, Some(1.0)) => { 43 | let record_key_path = KeyPath::from("rkey"); 44 | let game_store = db 45 | .create_object_store(GAME_STORE) 46 | .with_key_path(record_key_path.clone()) 47 | .build()?; 48 | game_store 49 | .create_index("index_hash", KeyPath::from("index_hash")) 50 | .build()?; 51 | db.create_object_store(CURRENT_GAME_STORE).build()?; 52 | db.create_object_store(STATS_STORE).build()?; 53 | db.create_object_store(PROFILE_STORE).build()?; 54 | db.create_object_store(KEY_STORE).build()?; 55 | db.create_object_store(DID_RESOLVER_STORE).build()?; 56 | db.create_object_store(STATE_STORE).build()?; 57 | db.create_object_store(SESSIONS_STORE).build()?; 58 | } 59 | _ => {} 60 | } 61 | 62 | Ok(()) 63 | }) 64 | .await?; 65 | Ok(db) 66 | } 67 | 68 | /// A think wrapper around a at proto record with the record key for storage 69 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 70 | pub struct RecordStorageWrapper<T> { 71 | pub rkey: RecordKey, 72 | pub record: T, 73 | pub index_hash: String, 74 | } 75 | 76 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 77 | pub enum StorageError { 78 | Error(String), 79 | OpenDbError(String), 80 | } 81 | 82 | impl StorageError { 83 | pub fn to_string(&self) -> String { 84 | match self { 85 | StorageError::Error(err) => err.to_string(), 86 | StorageError::OpenDbError(err) => err.to_string(), 87 | } 88 | } 89 | } 90 | 91 | pub async fn transaction_put<T>( 92 | db: Database, 93 | item: T, 94 | store: &str, 95 | key: Option<String>, 96 | ) -> Result<(), StorageError> 97 | where 98 | T: SerialiseToJs, 99 | { 100 | let transaction = match db 101 | .transaction(store) 102 | .with_mode(TransactionMode::Readwrite) 103 | .build() 104 | { 105 | Ok(transaction) => transaction, 106 | Err(err) => { 107 | return Err(StorageError::Error(err.to_string())); 108 | } 109 | }; 110 | 111 | let store = match transaction.object_store(store) { 112 | Ok(store) => store, 113 | Err(err) => { 114 | return Err(StorageError::Error(err.to_string())); 115 | } 116 | }; 117 | 118 | match key { 119 | None => match store.put(item).serde() { 120 | Ok(action) => match action.await { 121 | Ok(_) => {} 122 | Err(err) => { 123 | return Err(StorageError::Error(err.to_string())); 124 | } 125 | }, 126 | Err(err) => return Err(StorageError::Error(err.to_string())), 127 | }, 128 | Some(key) => match store.put(item).with_key(key).serde() { 129 | Ok(action) => match action.await { 130 | Ok(_) => {} 131 | Err(err) => return Err(StorageError::Error(err.to_string())), 132 | }, 133 | Err(err) => return Err(StorageError::Error(err.to_string())), 134 | }, 135 | }; 136 | 137 | match transaction.commit().await { 138 | Ok(_) => Ok(()), 139 | Err(err) => Err(StorageError::Error(err.to_string())), 140 | } 141 | } 142 | 143 | pub async fn object_get<T>(db: Database, store: &str, key: &str) -> Result<Option<T>, StorageError> 144 | where 145 | T: for<'de> Deserialize<'de>, 146 | { 147 | let transaction = match db 148 | .transaction(store) 149 | .with_mode(TransactionMode::Readonly) 150 | .build() 151 | { 152 | Ok(transaction) => transaction, 153 | Err(err) => { 154 | return Err(StorageError::Error(err.to_string())); 155 | } 156 | }; 157 | 158 | let store = match transaction.object_store(store) { 159 | Ok(store) => store, 160 | Err(err) => { 161 | return Err(StorageError::Error(err.to_string())); 162 | } 163 | }; 164 | match store.get(key).serde() { 165 | Ok(action) => match action.await { 166 | Ok(result) => Ok(result), 167 | Err(err) => Err(StorageError::Error(err.to_string())), 168 | }, 169 | Err(err) => Err(StorageError::Error(err.to_string())), 170 | } 171 | } 172 | 173 | pub async fn object_get_index<T>( 174 | db: Database, 175 | store: &str, 176 | index_key: &str, 177 | ) -> Result<Option<T>, StorageError> 178 | where 179 | T: for<'de> Deserialize<'de>, 180 | { 181 | let transaction = match db 182 | .transaction(store) 183 | .with_mode(TransactionMode::Readonly) 184 | .build() 185 | { 186 | Ok(transaction) => transaction, 187 | Err(err) => { 188 | return Err(StorageError::Error(err.to_string())); 189 | } 190 | }; 191 | 192 | let store = match transaction.object_store(store) { 193 | Ok(store) => store, 194 | Err(err) => { 195 | return Err(StorageError::Error(err.to_string())); 196 | } 197 | }; 198 | 199 | let index = store 200 | .index("index_hash") 201 | .map_err(|err| StorageError::Error(err.to_string()))?; 202 | 203 | let Some(mut cursor) = index 204 | .open_cursor() 205 | .with_query(index_key) 206 | .serde() 207 | .map_err(|e| StorageError::Error(e.to_string()))? 208 | .await 209 | .map_err(|e| StorageError::Error(e.to_string()))? 210 | else { 211 | return Ok(None); 212 | }; 213 | cursor 214 | .next_record_ser() 215 | .await 216 | .map_err(|err| StorageError::Error(err.to_string())) 217 | } 218 | 219 | pub async fn paginated_cursor<T>( 220 | db: Database, 221 | store: &str, 222 | count: u32, 223 | skip: u32, 224 | ) -> Result<Vec<T>, StorageError> 225 | where 226 | T: for<'de> Deserialize<'de>, 227 | { 228 | let transaction = match db 229 | .transaction(store) 230 | .with_mode(TransactionMode::Readonly) 231 | .build() 232 | { 233 | Ok(transaction) => transaction, 234 | Err(err) => { 235 | return Err(StorageError::Error(err.to_string())); 236 | } 237 | }; 238 | 239 | let store = match transaction.object_store(store) { 240 | Ok(store) => store, 241 | Err(err) => { 242 | return Err(StorageError::Error(err.to_string())); 243 | } 244 | }; 245 | 246 | let mut result_items: Vec<T> = vec![]; 247 | let Some(mut cursor) = store 248 | .open_cursor() 249 | .with_direction(CursorDirection::Prev) 250 | .await 251 | .map_err(|e| StorageError::Error(e.to_string()))? 252 | else { 253 | return Ok(result_items); 254 | }; 255 | 256 | cursor 257 | .advance_by(skip) 258 | .await 259 | .map_err(|e| StorageError::Error(e.to_string()))?; 260 | for _ in 0..count { 261 | match cursor.next_record_ser::<T>().await { 262 | Ok(result) => { 263 | if let Some(item) = result { 264 | result_items.push(item); 265 | } else { 266 | //Once the cursor is empty, we break 267 | // because the next record looks to try and serialize 268 | break; 269 | } 270 | } 271 | Err(err) => { 272 | log::error!("Error getting next record: {}", err); 273 | } 274 | } 275 | } 276 | Ok(result_items) 277 | } 278 | 279 | pub async fn object_delete(db: Database, store: &str, key: &str) -> Result<(), StorageError> { 280 | let transaction = match db 281 | .transaction(store) 282 | .with_mode(TransactionMode::Readwrite) 283 | .build() 284 | { 285 | Ok(transaction) => transaction, 286 | Err(err) => { 287 | return Err(StorageError::Error(err.to_string())); 288 | } 289 | }; 290 | 291 | let store = match transaction.object_store(store) { 292 | Ok(store) => store, 293 | Err(err) => { 294 | return Err(StorageError::Error(err.to_string())); 295 | } 296 | }; 297 | match store.delete(key).await { 298 | Ok(_) => match transaction.commit().await { 299 | Ok(_) => Ok(()), 300 | Err(_) => Err(StorageError::Error( 301 | "Failed to commit transaction".to_string(), 302 | )), 303 | }, 304 | Err(err) => Err(StorageError::Error(err.to_string())), 305 | } 306 | } 307 | 308 | pub async fn clear_store(db: Database, store: &str) -> Result<(), StorageError> { 309 | let transaction = match db 310 | .transaction(store) 311 | .with_mode(TransactionMode::Readwrite) 312 | .build() 313 | { 314 | Ok(transaction) => transaction, 315 | Err(err) => { 316 | return Err(StorageError::Error(err.to_string())); 317 | } 318 | }; 319 | 320 | let store = match transaction.object_store(store) { 321 | Ok(store) => store, 322 | Err(err) => { 323 | return Err(StorageError::Error(err.to_string())); 324 | } 325 | }; 326 | match store.clear() { 327 | Ok(_) => match transaction.commit().await { 328 | Ok(_) => Ok(()), 329 | Err(_) => Err(StorageError::Error( 330 | "Failed to commit transaction".to_string(), 331 | )), 332 | }, 333 | Err(err) => Err(StorageError::Error(err.to_string())), 334 | } 335 | } 336 | -------------------------------------------------------------------------------- /admin_2048/src/main.rs: -------------------------------------------------------------------------------- 1 | use atrium_api::agent::atp_agent::AtpSession; 2 | use atrium_api::com::atproto::sync::list_repos_by_collection::Repo; 3 | use atrium_api::types::string::Did; 4 | use atrium_api::types::{LimitedNonZeroU8, LimitedU8, TryIntoUnknown}; 5 | use atrium_api::{ 6 | agent::atp_agent::AtpAgent, 7 | agent::atp_agent::store::MemorySessionStore, 8 | types::{Collection, LimitedNonZeroU16}, 9 | }; 10 | use atrium_common::resolver::Resolver; 11 | use atrium_common::store::memory::MemoryStore; 12 | use atrium_identity::{ 13 | did::{CommonDidResolver, CommonDidResolverConfig, DEFAULT_PLC_DIRECTORY_URL}, 14 | handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig, DnsTxtResolver}, 15 | }; 16 | use atrium_oauth::DefaultHttpClient; 17 | use atrium_xrpc_client::reqwest::ReqwestClient; 18 | use clap::{Parser, Subcommand}; 19 | use hickory_resolver::TokioAsyncResolver; 20 | use std::collections::HashMap; 21 | use std::sync::Arc; 22 | use twothousand_forty_eight::unified::validation::Validatable; 23 | use twothousand_forty_eight::v2::recording::SeededRecording; 24 | 25 | use types_2048::blue; 26 | 27 | const RELAY_ENDPOINT: &str = "https://relay1.us-east.bsky.network"; 28 | 29 | #[derive(Parser, Debug)] 30 | #[command(version, about, long_about = None)] 31 | #[command(propagate_version = true)] 32 | struct Cli { 33 | #[command(subcommand)] 34 | command: Commands, 35 | } 36 | 37 | #[derive(Subcommand, Debug)] 38 | enum Commands { 39 | /// Admin actions for leaderboards 40 | Leaderboard(Leaderboard), 41 | } 42 | 43 | #[derive(Parser, Debug)] 44 | #[command(name = "leaderboard", about = "Actions for leaderboards")] 45 | struct Leaderboard { 46 | /// Type of lexicon generation 47 | #[command(subcommand)] 48 | subcommand: LeaderboardCommands, 49 | } 50 | 51 | #[derive(Subcommand, Debug)] 52 | enum LeaderboardCommands { 53 | /// Generates the temp leaderboard 54 | Temp, 55 | } 56 | 57 | #[derive(Debug)] 58 | struct TempLeaderboardPlace { 59 | pub did: Did, 60 | pub handle: Option<String>, 61 | pub pds_url: String, 62 | pub top_score: Option<usize>, 63 | pub top_score_uri: Option<String>, 64 | pub games_played: usize, 65 | } 66 | async fn create_a_temp_leaderboard() -> anyhow::Result<()> { 67 | log::info!("Creating a temp leaderboard..."); 68 | let http_client = Arc::new(DefaultHttpClient::default()); 69 | //finds the did document from the users did 70 | let did_resolver = CommonDidResolver::new(CommonDidResolverConfig { 71 | plc_directory_url: DEFAULT_PLC_DIRECTORY_URL.to_string(), 72 | http_client: Arc::clone(&http_client), 73 | }); 74 | 75 | let agent = AtpAgent::new( 76 | ReqwestClient::new(RELAY_ENDPOINT), 77 | MemorySessionStore::default(), 78 | ); 79 | let result = agent 80 | .api 81 | .com 82 | .atproto 83 | .sync 84 | .list_repos_by_collection( 85 | atrium_api::com::atproto::sync::list_repos_by_collection::ParametersData { 86 | collection: blue::_2048::Game::NSID.parse().unwrap(), 87 | cursor: None, 88 | limit: Some(LimitedNonZeroU16::try_from(2000_u16).unwrap()), 89 | } 90 | .into(), 91 | ) 92 | .await; 93 | let output = match result { 94 | Ok(output) => output, 95 | Err(err) => { 96 | anyhow::bail!("{:?}", err) 97 | } 98 | }; 99 | let mut resolve_count = 0; 100 | let mut hashmap_by_pds: HashMap<String, Vec<TempLeaderboardPlace>> = HashMap::new(); 101 | for repo in &output.repos { 102 | resolve_count += 1; 103 | let resolved_did = match did_resolver.resolve(&repo.did).await { 104 | Ok(doc) => doc, 105 | Err(err) => { 106 | log::error!("Error resolving: {} {:?}", &repo.did.to_string(), err); 107 | continue; 108 | } 109 | }; 110 | let handle = resolved_did.also_known_as.unwrap().get(0).unwrap().clone(); 111 | let pds_url = match resolved_did.service.as_ref().and_then(|services| { 112 | services 113 | .iter() 114 | .find(|service| service.r#type == "AtprotoPersonalDataServer") 115 | .map(|service| service.service_endpoint.clone()) 116 | }) { 117 | None => { 118 | log::error!("No pds url found for {}", &repo.did.to_string()); 119 | continue; 120 | } 121 | Some(url) => url, 122 | }; 123 | 124 | match hashmap_by_pds.get_mut(&pds_url) { 125 | None => { 126 | hashmap_by_pds.insert( 127 | pds_url.clone(), 128 | vec![TempLeaderboardPlace { 129 | did: repo.did.clone(), 130 | handle: Some(handle), 131 | pds_url, 132 | top_score: None, 133 | top_score_uri: None, 134 | games_played: 0, 135 | }], 136 | ); 137 | } 138 | Some(already_exists) => { 139 | already_exists.push(TempLeaderboardPlace { 140 | did: repo.did.clone(), 141 | handle: Some(handle), 142 | pds_url: pds_url.clone(), 143 | top_score: None, 144 | top_score_uri: None, 145 | games_played: 0, 146 | }); 147 | } 148 | } 149 | if resolve_count % 10 == 0 { 150 | log::info!("{} repos resolved", resolve_count); 151 | } 152 | } 153 | log::info!( 154 | "{} repos resolved. Getting games from the repos now.", 155 | resolve_count 156 | ); 157 | 158 | let mut global_games_played = 0; 159 | 160 | let mut leaderboards: Vec<TempLeaderboardPlace> = Vec::new(); 161 | for (pds_url, repos) in hashmap_by_pds.iter_mut() { 162 | log::info!("Getting {} repos from {},", repos.len(), pds_url); 163 | let pds_agent = AtpAgent::new(ReqwestClient::new(pds_url), MemorySessionStore::default()); 164 | for repo in repos { 165 | match get_top_game(&pds_agent, &repo.did, &repo.handle).await { 166 | Ok(new_leaderboard_place) => { 167 | global_games_played += new_leaderboard_place.games_played; 168 | leaderboards.push(new_leaderboard_place); 169 | } 170 | Err(err) => { 171 | log::error!("Error getting top game: {}", err); 172 | log::error!("Skipping repo: {}", repo.did.to_string()); 173 | continue; 174 | } 175 | } 176 | } 177 | } 178 | 179 | log::info!("{} games played", global_games_played); 180 | 181 | // Sort leaderboards by top score in descending order 182 | leaderboards.sort_by(|a, b| b.top_score.cmp(&a.top_score)); 183 | 184 | // Print top 10 entries 185 | for (index, entry) in leaderboards.iter().enumerate() { 186 | if let (Some(score), Some(_)) = (entry.top_score, entry.top_score_uri.clone()) { 187 | let player = match &entry.handle { 188 | Some(handle) => handle.replace("at://", "@"), 189 | None => format!("@{}", entry.did.to_string()), 190 | }; 191 | 192 | println!("{}. {:} {}", index + 1, score, player); 193 | } 194 | } 195 | 196 | Ok(()) 197 | } 198 | 199 | async fn get_top_game( 200 | atp_agent: &AtpAgent<MemoryStore<(), AtpSession>, ReqwestClient>, 201 | did: &Did, 202 | handle: &Option<String>, 203 | ) -> anyhow::Result<TempLeaderboardPlace> { 204 | let mut cursor = None; 205 | let mut keep_calling = true; 206 | let mut top_score = 0; 207 | let mut top_score_uri: Option<String> = None; 208 | let mut games_played: usize = 0; 209 | while keep_calling { 210 | log::info!("Getting top game for {}", did.clone().to_string()); 211 | match atp_agent 212 | .api 213 | .com 214 | .atproto 215 | .repo 216 | .list_records( 217 | atrium_api::com::atproto::repo::list_records::ParametersData { 218 | collection: types_2048::blue::_2048::Game::NSID.parse().unwrap(), 219 | cursor: cursor.clone(), 220 | limit: Some(LimitedNonZeroU8::<100>::try_from(100_u8).unwrap()), 221 | repo: did.clone().into(), 222 | reverse: None, 223 | } 224 | .into(), 225 | ) 226 | .await 227 | { 228 | Ok(output) => { 229 | if output.records.len() == 100 { 230 | cursor = output.cursor.clone(); 231 | } else { 232 | keep_calling = false; 233 | cursor = None; 234 | } 235 | games_played += output.records.len(); 236 | 237 | for record in &output.records { 238 | let game: types_2048::blue::_2048::game::RecordData = 239 | types_2048::blue::_2048::game::RecordData::from(record.value.clone()); 240 | match parse_game_and_validate(&game.seeded_recording) { 241 | Ok(real_score) => { 242 | if real_score > top_score { 243 | top_score = real_score; 244 | top_score_uri = Some(record.uri.clone()); 245 | } 246 | } 247 | Err(err) => { 248 | log::error!("Error parsing game: {}", err); 249 | continue; 250 | } 251 | } 252 | } 253 | } 254 | Err(e) => { 255 | log::error!("Error getting top game: {}", e); 256 | break; 257 | } 258 | }; 259 | } 260 | 261 | Ok(TempLeaderboardPlace { 262 | did: did.clone(), 263 | handle: handle.clone(), 264 | pds_url: "https://relay1.us-east.bsky.network".to_string(), 265 | top_score: Some(top_score), 266 | top_score_uri: top_score_uri, 267 | games_played, 268 | }) 269 | } 270 | 271 | fn parse_game_and_validate(game: &String) -> anyhow::Result<usize> { 272 | let history: SeededRecording = match game.parse() { 273 | Ok(history) => history, 274 | Err(err) => Err(anyhow::anyhow!("Error parsing game: {}", err))?, 275 | }; 276 | 277 | return match history.validate() { 278 | Ok(valid_history) => { 279 | if valid_history.score > 0 { 280 | Ok(valid_history.score) 281 | } else { 282 | Err(anyhow::anyhow!("Invalid game: {}", game)) 283 | } 284 | } 285 | Err(err) => { 286 | log::error!("Error validating game: {}", err); 287 | Err(anyhow::anyhow!("Invalid game: {}", game)) 288 | } 289 | }; 290 | } 291 | 292 | #[tokio::main] 293 | async fn main() -> anyhow::Result<()> { 294 | env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); 295 | 296 | let cli = Cli::parse(); 297 | match &cli.command { 298 | Commands::Leaderboard(Leaderboard { subcommand }) => match subcommand { 299 | LeaderboardCommands::Temp => create_a_temp_leaderboard().await, 300 | }, 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /client_2048/src/agent.rs: -------------------------------------------------------------------------------- 1 | use crate::at_repo_sync::{AtRepoSync, AtRepoSyncError}; 2 | use crate::idb::{ 3 | DB_NAME, GAME_STORE, RecordStorageWrapper, StorageError, object_get, object_get_index, 4 | }; 5 | use crate::oauth_client::oauth_client; 6 | use atrium_api::agent::Agent; 7 | use atrium_api::types::LimitedU32; 8 | use atrium_api::types::string::{Datetime, Did, RecordKey, Tid}; 9 | use indexed_db_futures::database::Database; 10 | use js_sys::Uint8Array; 11 | use serde::{Deserialize, Serialize}; 12 | use twothousand_forty_eight::unified::game::GameState; 13 | use twothousand_forty_eight::unified::hash::Hashable; 14 | use twothousand_forty_eight::unified::reconstruction::Reconstructable; 15 | use twothousand_forty_eight::v2::recording::SeededRecording; 16 | use types_2048::blue; 17 | use types_2048::blue::_2048::defs::SyncStatusData; 18 | use types_2048::blue::_2048::game; 19 | use types_2048::blue::_2048::player::stats::RecordData; 20 | use wasm_bindgen::JsValue; 21 | use yew_agent::Codec; 22 | use yew_agent::prelude::*; 23 | 24 | /// Postcard codec for worker messages serialization. 25 | pub struct Postcard; 26 | 27 | impl Codec for Postcard { 28 | fn encode<I>(input: I) -> JsValue 29 | where 30 | I: Serialize, 31 | { 32 | let buf = postcard::to_allocvec(&input).expect("can't serialize a worker message"); 33 | Uint8Array::from(buf.as_slice()).into() 34 | } 35 | 36 | fn decode<O>(input: JsValue) -> O 37 | where 38 | O: for<'de> Deserialize<'de>, 39 | { 40 | let data = Uint8Array::from(input).to_vec(); 41 | postcard::from_bytes(&data).expect("can't deserialize a worker message") 42 | } 43 | } 44 | 45 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 46 | pub enum StorageRequest { 47 | ///(Seeded recording as a string, the users did if they are signed in) 48 | GameCompleted(String, Option<Did>), 49 | TryToSyncRemotely(RecordKey, Option<Did>), 50 | } 51 | 52 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 53 | pub enum StorageResponse { 54 | Success, 55 | AlreadySynced, 56 | Error(StorageError), 57 | RepoError(AtRepoSyncError), 58 | } 59 | 60 | #[oneshot] 61 | pub async fn StorageTask(request: StorageRequest) -> StorageResponse { 62 | let _db = match Database::open(DB_NAME).await { 63 | Ok(db) => db, 64 | Err(err) => { 65 | return StorageResponse::Error(StorageError::OpenDbError(err.to_string())); 66 | } 67 | }; 68 | 69 | let response = match request { 70 | StorageRequest::GameCompleted(game_history, did) => { 71 | handle_game_completed(game_history, did).await 72 | } 73 | StorageRequest::TryToSyncRemotely(record_key, did) => match did { 74 | None => Err(AtRepoSyncError::Error(String::from( 75 | "There should of been a DID and the user logged in", 76 | ))), 77 | Some(did) => remote_sync_game(record_key, did).await, 78 | }, 79 | }; 80 | response.unwrap_or_else(|error| StorageResponse::RepoError(error)) 81 | } 82 | 83 | pub async fn handle_game_completed( 84 | game_history: String, 85 | did: Option<Did>, 86 | ) -> Result<StorageResponse, AtRepoSyncError> { 87 | let seeded_recording: SeededRecording = match game_history.clone().parse() { 88 | Ok(seeded_recording) => seeded_recording, 89 | Err(err) => { 90 | return Err(AtRepoSyncError::Error(err.to_string())); 91 | } 92 | }; 93 | let at_repo_sync = match did { 94 | None => AtRepoSync::new_local_repo(), 95 | Some(did) => { 96 | let oauth_client = oauth_client(); 97 | let session = match oauth_client.restore(&did).await { 98 | Ok(session) => session, 99 | Err(err) => { 100 | log::error!("{:?}", err); 101 | return Err(AtRepoSyncError::Error(err.to_string())); 102 | } 103 | }; 104 | 105 | let agent = Agent::new(session); 106 | 107 | AtRepoSync::new_logged_in_repo(agent, did) 108 | } 109 | }; 110 | 111 | let db = match Database::open(DB_NAME).await { 112 | Ok(db) => db, 113 | Err(err) => { 114 | return Err(AtRepoSyncError::Error(err.to_string())); 115 | } 116 | }; 117 | 118 | let already_saved: Option<RecordStorageWrapper<game::RecordData>> = 119 | object_get_index(db, GAME_STORE, &seeded_recording.game_hash()) 120 | .await 121 | .map_err(|err| AtRepoSyncError::Error(err.to_string()))?; 122 | if let Some(already_saved) = already_saved { 123 | if already_saved.record.sync_status.synced_with_at_repo || !at_repo_sync.can_remote_sync() { 124 | log::info!("already saved or cannot sync"); 125 | return Ok(StorageResponse::AlreadySynced); 126 | } else { 127 | //TODO sync with remote repo idk what I want to do here yet 128 | } 129 | log::info!("already saved"); 130 | return Ok(StorageResponse::AlreadySynced); 131 | } 132 | 133 | let gamestate = match GameState::from_reconstructable_ruleset(&seeded_recording) { 134 | Ok(gamestate) => gamestate, 135 | Err(e) => { 136 | log::error!("Error reconstructing game: {:?}", e.to_string()); 137 | return Err(AtRepoSyncError::Error(e.to_string())); 138 | } 139 | }; 140 | 141 | let record = blue::_2048::game::RecordData { 142 | completed: gamestate.over, 143 | created_at: Datetime::now(), 144 | current_score: gamestate.score_current as i64, 145 | seeded_recording: game_history, 146 | sync_status: SyncStatusData { 147 | created_at: Datetime::now(), 148 | hash: "".to_string(), 149 | //Defaults to true till proven it is not synced 150 | synced_with_at_repo: true, 151 | updated_at: Datetime::now(), 152 | } 153 | .into(), 154 | won: gamestate.won, 155 | }; 156 | 157 | // if at_repo_sync.can_remote_sync() { 158 | let stats_sync = at_repo_sync.sync_stats().await; 159 | if stats_sync.is_err() { 160 | if at_repo_sync.can_remote_sync() { 161 | match stats_sync.err() { 162 | None => {} 163 | Some(err) => { 164 | log::error!("Error syncing stats: {:?}", err); 165 | if let AtRepoSyncError::AuthErrorNeedToReLogin = err { 166 | return Err(AtRepoSyncError::AuthErrorNeedToReLogin); 167 | } 168 | } 169 | } 170 | } 171 | } 172 | 173 | let stats = match calculate_new_stats(&seeded_recording, &at_repo_sync, gamestate).await { 174 | Ok(value) => value, 175 | Err(value) => return value, 176 | }; 177 | 178 | at_repo_sync.update_a_player_stats(stats).await?; 179 | 180 | let tid = Tid::now(LimitedU32::MIN); 181 | let record_key: RecordKey = tid.parse().unwrap(); 182 | 183 | //Using create_a_new_game because it will update local and create remote for now, may change name later 184 | at_repo_sync 185 | .create_a_new_game(record, record_key, seeded_recording.game_hash()) 186 | .await?; 187 | 188 | Ok(StorageResponse::Success) 189 | } 190 | 191 | pub async fn remote_sync_game( 192 | games_rkey: RecordKey, 193 | did: Did, 194 | ) -> Result<StorageResponse, AtRepoSyncError> { 195 | let oauth_client = oauth_client(); 196 | let at_repo_sync = match oauth_client.restore(&did).await { 197 | Ok(session) => { 198 | let agent = Agent::new(session); 199 | AtRepoSync::new_logged_in_repo(agent, did) 200 | } 201 | Err(err) => { 202 | log::error!("{:?}", err); 203 | return Err(AtRepoSyncError::Error(err.to_string())); 204 | } 205 | }; 206 | 207 | let db = match Database::open(DB_NAME).await { 208 | Ok(db) => db, 209 | Err(err) => { 210 | return Err(AtRepoSyncError::Error(err.to_string())); 211 | } 212 | }; 213 | 214 | let local_game = 215 | match object_get::<RecordStorageWrapper<game::RecordData>>(db, GAME_STORE, &games_rkey) 216 | .await 217 | { 218 | Ok(game) => match game { 219 | Some(game) => game, 220 | None => { 221 | return Err(AtRepoSyncError::Error("Game not found locally".to_string())); 222 | } 223 | }, 224 | Err(err) => Err(AtRepoSyncError::Error(err.to_string()))?, 225 | }; 226 | 227 | let seeded_recording: SeededRecording = match local_game.record.seeded_recording.clone().parse() 228 | { 229 | Ok(seeded_recording) => seeded_recording, 230 | Err(err) => { 231 | return Err(AtRepoSyncError::Error(err.to_string())); 232 | } 233 | }; 234 | 235 | let gamestate = match GameState::from_reconstructable_ruleset(&seeded_recording) { 236 | Ok(gamestate) => gamestate, 237 | Err(e) => { 238 | log::error!("Error reconstructing game: {:?}", e.to_string()); 239 | return Err(AtRepoSyncError::Error(e.to_string())); 240 | } 241 | }; 242 | 243 | let record = blue::_2048::game::RecordData { 244 | completed: gamestate.over, 245 | created_at: Datetime::now(), 246 | current_score: gamestate.score_current as i64, 247 | seeded_recording: local_game.record.seeded_recording, 248 | sync_status: SyncStatusData { 249 | created_at: Datetime::now(), 250 | hash: "".to_string(), 251 | //Defaults to true till proven it is not synced 252 | synced_with_at_repo: true, 253 | updated_at: Datetime::now(), 254 | } 255 | .into(), 256 | won: gamestate.won, 257 | }; 258 | 259 | // We want to try and create the game first in the event that it is already there 260 | //Using create_a_new_game because it will update local and create remote for now, may change later 261 | at_repo_sync 262 | .create_a_new_game(record, games_rkey, seeded_recording.game_hash()) 263 | .await?; 264 | 265 | // if at_repo_sync.can_remote_sync() { 266 | let stats_sync = at_repo_sync.sync_stats().await; 267 | if stats_sync.is_err() { 268 | if at_repo_sync.can_remote_sync() { 269 | match stats_sync.err() { 270 | None => {} 271 | Some(err) => { 272 | log::error!("Error syncing stats: {:?}", err); 273 | if let AtRepoSyncError::AuthErrorNeedToReLogin = err { 274 | return Err(AtRepoSyncError::AuthErrorNeedToReLogin); 275 | } 276 | } 277 | } 278 | } 279 | } 280 | 281 | let stats = match calculate_new_stats(&seeded_recording, &at_repo_sync, gamestate).await { 282 | Ok(value) => value, 283 | Err(value) => return value, 284 | }; 285 | 286 | at_repo_sync.update_a_player_stats(stats).await?; 287 | 288 | Ok(StorageResponse::Success) 289 | } 290 | 291 | async fn calculate_new_stats( 292 | seeded_recording: &SeededRecording, 293 | at_repo_sync: &AtRepoSync, 294 | gamestate: GameState, 295 | ) -> Result<RecordData, Result<StorageResponse, AtRepoSyncError>> { 296 | let mut stats = match at_repo_sync.get_local_player_stats().await { 297 | Ok(stats) => match stats { 298 | None => { 299 | return Err(Err(AtRepoSyncError::Error( 300 | "No stats found. Good chance they were never created if syncing is off. Or something much worse now." 301 | .to_string(), 302 | ))); 303 | } 304 | Some(stats) => stats, 305 | }, 306 | Err(err) => { 307 | return Err(Err(err)); 308 | } 309 | }; 310 | 311 | let highest_block_this_game = gamestate 312 | .board 313 | .tiles 314 | .iter() 315 | .flatten() 316 | .filter_map(|tile| *tile) 317 | .map(|x| x.value) 318 | .max() 319 | .unwrap_or(0) as i64; 320 | 321 | //Update the stats 322 | stats.games_played += 1; 323 | stats.total_score += gamestate.score_current as i64; 324 | stats.average_score = stats.total_score / stats.games_played; 325 | if highest_block_this_game > stats.highest_number_block { 326 | stats.highest_number_block = highest_block_this_game; 327 | } 328 | 329 | if gamestate.score_current as i64 > stats.highest_score { 330 | stats.highest_score = gamestate.score_current as i64; 331 | } 332 | 333 | let reconstruction = match seeded_recording.reconstruct() { 334 | Ok(reconstruction) => reconstruction, 335 | Err(err) => { 336 | return Err(Err(AtRepoSyncError::Error(err.to_string()))); 337 | } 338 | }; 339 | 340 | let mut twenty_48_this_game: Vec<usize> = vec![]; 341 | let mut turns_till_2048 = 0; 342 | let mut turns = 0; 343 | for board_in_the_moment in reconstruction.history { 344 | turns += 1; 345 | 346 | for tile in board_in_the_moment 347 | .tiles 348 | .iter() 349 | .flatten() 350 | .filter_map(|tile| *tile) 351 | { 352 | if tile.value as i64 > stats.highest_number_block { 353 | stats.highest_number_block = tile.value as i64; 354 | } 355 | 356 | if tile.value as i64 == 2048 && twenty_48_this_game.contains(&tile.id) == false { 357 | if turns_till_2048 == 0 { 358 | turns_till_2048 = turns; 359 | if turns < stats.least_moves_to_find_twenty_forty_eight { 360 | stats.least_moves_to_find_twenty_forty_eight = turns; 361 | } 362 | // stats.least_moves_to_find_twenty_forty_eight 363 | } 364 | stats.times_twenty_forty_eight_been_found += 1; 365 | twenty_48_this_game.push(tile.id); 366 | } 367 | } 368 | } 369 | Ok(stats) 370 | } 371 | -------------------------------------------------------------------------------- /client_2048/src/pages/stats.rs: -------------------------------------------------------------------------------- 1 | use crate::at_repo_sync::AtRepoSync; 2 | use crate::store::UserStore; 3 | use atrium_api::agent::Agent; 4 | use js_sys::encode_uri_component; 5 | use numfmt::{Formatter, Precision}; 6 | use yew::platform::spawn_local; 7 | use yew::{Html, Properties, function_component, html, use_effect_with, use_state}; 8 | use yewdux::prelude::*; 9 | 10 | #[derive(Properties, PartialEq)] 11 | pub struct BSkyButtonProps { 12 | pub text: String, 13 | } 14 | 15 | #[function_component(BSkyButton)] 16 | pub fn bsky_button(props: &BSkyButtonProps) -> Html { 17 | let display_text = format!( 18 | "{}\nThink you can do better? Join in on the fun with @2048.blue.", 19 | props.text 20 | ); 21 | 22 | let redirect_url = format!( 23 | "https://bsky.app/intent/compose?text={}", 24 | encode_uri_component(&display_text) 25 | ); 26 | html!( 27 | // <div class="stat-actions"> 28 | <a class="btn btn-sm btn-accent" href={redirect_url}> 29 | { "Share" } 30 | <svg 31 | class="inline-block w-8 fill-[#0a7aff]" 32 | viewBox="0 0 1024 1024" 33 | fill="none" 34 | xmlns="http://www.w3.org/2000/svg" 35 | > 36 | <path 37 | d="M351.121 315.106C416.241 363.994 486.281 463.123 512 516.315C537.719 463.123 607.759 363.994 672.879 315.106C719.866 279.83 796 252.536 796 339.388C796 356.734 786.055 485.101 780.222 505.943C759.947 578.396 686.067 596.876 620.347 585.691C735.222 605.242 764.444 670.002 701.333 734.762C581.473 857.754 529.061 703.903 515.631 664.481C513.169 657.254 512.017 653.873 512 656.748C511.983 653.873 510.831 657.254 508.369 664.481C494.939 703.903 442.527 857.754 322.667 734.762C259.556 670.002 288.778 605.242 403.653 585.691C337.933 596.876 264.053 578.396 243.778 505.943C237.945 485.101 228 356.734 228 339.388C228 252.536 304.134 279.83 351.121 315.106Z" 38 | /> 39 | </svg> 40 | </a> 41 | ) 42 | } 43 | 44 | #[function_component(StatsPage)] 45 | pub fn stats() -> Html { 46 | let (user_store, _) = use_store::<UserStore>(); 47 | let stats_state = use_state(|| None); 48 | let number_formatter = Formatter::new() 49 | .precision(Precision::Decimals(0)) 50 | .separator(',') 51 | .expect("Could not build the number formatter."); 52 | let user_store_clone = user_store.clone(); 53 | 54 | use_effect_with(stats_state.clone(), move |stats_state| { 55 | let stats_state = stats_state.clone(); 56 | spawn_local(async move { 57 | match user_store_clone.did.clone() { 58 | None => { 59 | let at_repo_sync = AtRepoSync::new_local_repo(); 60 | 61 | match at_repo_sync.get_local_player_stats().await.unwrap_or(None) { 62 | Some(stats) => stats_state.set(Some(stats)), 63 | _ => { 64 | //If there is not a local one create a new stats 65 | match at_repo_sync.create_a_new_player_stats().await { 66 | Ok(stats) => stats_state.set(Some(stats)), 67 | Err(err) => { 68 | log::error!( 69 | "Error creating a new local only stats: {:?}", 70 | err.to_string() 71 | ); 72 | } 73 | } 74 | } 75 | } 76 | } 77 | Some(did) => { 78 | let oauth_client = crate::oauth_client::oauth_client(); 79 | let session = match oauth_client.restore(&did).await { 80 | Ok(session) => session, 81 | Err(err) => { 82 | log::error!("{:?}", err); 83 | return; 84 | } 85 | }; 86 | let agent = Agent::new(session); 87 | let at_repo_sync = AtRepoSync::new_logged_in_repo(agent, did); 88 | match at_repo_sync.sync_stats().await { 89 | Ok(_) => match at_repo_sync.get_local_player_stats().await { 90 | Ok(stats) => stats_state.set(stats), 91 | Err(err) => { 92 | log::error!( 93 | "Error getting local stats after syncing: {:?}", 94 | err.to_string() 95 | ); 96 | } 97 | }, 98 | Err(err) => { 99 | log::error!("Error syncing stats: {:?}", err.to_string()); 100 | } 101 | } 102 | } 103 | } 104 | }); 105 | 106 | || () 107 | }); 108 | 109 | if let Some(stats_state) = (*stats_state).clone() { 110 | //HACK I am very sorry to who ever finds this. I don't have an explanation other than I gave up. Will comeback later... 111 | let mut formatter = number_formatter.clone(); 112 | let high_score_formatted = formatter.fmt2(stats_state.highest_score.clone()); 113 | 114 | let mut formatter = number_formatter.clone(); 115 | let average_score_formatted = formatter.fmt2(stats_state.average_score.clone()); 116 | 117 | let mut formatter = number_formatter.clone(); 118 | let total_score_formatted = formatter.fmt2(stats_state.total_score); 119 | 120 | let mut formatter = number_formatter.clone(); 121 | let highest_number_block_formatted = formatter.fmt2(stats_state.highest_number_block); 122 | 123 | let mut formatter = number_formatter.clone(); 124 | let times_twenty_forty_eight_been_found_formatted = 125 | formatter.fmt2(stats_state.times_twenty_forty_eight_been_found); 126 | 127 | let mut formatter = number_formatter.clone(); 128 | let lowest_turns_till_2048_formatted = 129 | formatter.fmt2(stats_state.least_moves_to_find_twenty_forty_eight); 130 | 131 | let mut formatter = number_formatter.clone(); 132 | let total_games_formatted = formatter.fmt2(stats_state.games_played); 133 | 134 | html! { 135 | <div class="p-4"> 136 | <div class="max-w-4xl mx-auto space-y-4"> 137 | // Header 138 | <div class="card bg-base-100 shadow-xl"> 139 | <div class="card-body"> 140 | <h2 class="card-title text-3xl font-bold"> 141 | { "Your at://2048 Stats" } 142 | </h2> 143 | <p class="text-base-content/70"> 144 | { "Track your progress and achievements" } 145 | </p> 146 | </div> 147 | </div> 148 | // Main Stats Grid 149 | <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> 150 | // Score Stats Card 151 | <div class="card shadow-xl"> 152 | <div class="card-body"> 153 | <h3 class="card-title">{ "Score Statistics" }</h3> 154 | <div class="stats stats-vertical shadow"> 155 | <div class="stat"> 156 | <div class="stat-figure"> 157 | <svg 158 | class="inline-block w-11 fill-primary" 159 | xmlns="http://www.w3.org/2000/svg" 160 | viewBox="0 0 576 512" 161 | > 162 | <path 163 | d="M400 0L176 0c-26.5 0-48.1 21.8-47.1 48.2c.2 5.3 .4 10.6 .7 15.8L24 64C10.7 64 0 74.7 0 88c0 92.6 33.5 157 78.5 200.7c44.3 43.1 98.3 64.8 138.1 75.8c23.4 6.5 39.4 26 39.4 45.6c0 20.9-17 37.9-37.9 37.9L192 448c-17.7 0-32 14.3-32 32s14.3 32 32 32l192 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-26.1 0C337 448 320 431 320 410.1c0-19.6 15.9-39.2 39.4-45.6c39.9-11 93.9-32.7 138.2-75.8C542.5 245 576 180.6 576 88c0-13.3-10.7-24-24-24L446.4 64c.3-5.2 .5-10.4 .7-15.8C448.1 21.8 426.5 0 400 0zM48.9 112l84.4 0c9.1 90.1 29.2 150.3 51.9 190.6c-24.9-11-50.8-26.5-73.2-48.3c-32-31.1-58-76-63-142.3zM464.1 254.3c-22.4 21.8-48.3 37.3-73.2 48.3c22.7-40.3 42.8-100.5 51.9-190.6l84.4 0c-5.1 66.3-31.1 111.2-63 142.3z" 164 | /> 165 | </svg> 166 | </div> 167 | <div class="stat-title">{ "Highest Score" }</div> 168 | <div class="stat-value">{ high_score_formatted }</div> 169 | </div> 170 | <div class="stat"> 171 | <div class="stat-figure "> 172 | <svg 173 | class="inline-block h-9 w-12 fill-primary" 174 | xmlns="http://www.w3.org/2000/svg" 175 | viewBox="0 0 576 512" 176 | > 177 | <path 178 | d="M384 32l128 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L398.4 96c-5.2 25.8-22.9 47.1-46.4 57.3L352 448l160 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-192 0-192 0c-17.7 0-32-14.3-32-32s14.3-32 32-32l160 0 0-294.7c-23.5-10.3-41.2-31.6-46.4-57.3L128 96c-17.7 0-32-14.3-32-32s14.3-32 32-32l128 0c14.6-19.4 37.8-32 64-32s49.4 12.6 64 32zm55.6 288l144.9 0L512 195.8 439.6 320zM512 416c-62.9 0-115.2-34-126-78.9c-2.6-11 1-22.3 6.7-32.1l95.2-163.2c5-8.6 14.2-13.8 24.1-13.8s19.1 5.3 24.1 13.8l95.2 163.2c5.7 9.8 9.3 21.1 6.7 32.1C627.2 382 574.9 416 512 416zM126.8 195.8L54.4 320l144.9 0L126.8 195.8zM.9 337.1c-2.6-11 1-22.3 6.7-32.1l95.2-163.2c5-8.6 14.2-13.8 24.1-13.8s19.1 5.3 24.1 13.8l95.2 163.2c5.7 9.8 9.3 21.1 6.7 32.1C242 382 189.7 416 126.8 416S11.7 382 .9 337.1z" 179 | /> 180 | </svg> 181 | </div> 182 | <div class="stat-title">{ "Average Score" }</div> 183 | <div class="stat-value">{ average_score_formatted }</div> 184 | </div> 185 | <div class="stat"> 186 | <div class="stat-figure "> 187 | <svg 188 | class="inline-block h-8 w-8 fill-primary" 189 | xmlns="http://www.w3.org/2000/svg" 190 | viewBox="0 0 576 512" 191 | > 192 | <path 193 | d="M160 80c0-26.5 21.5-48 48-48l32 0c26.5 0 48 21.5 48 48l0 352c0 26.5-21.5 48-48 48l-32 0c-26.5 0-48-21.5-48-48l0-352zM0 272c0-26.5 21.5-48 48-48l32 0c26.5 0 48 21.5 48 48l0 160c0 26.5-21.5 48-48 48l-32 0c-26.5 0-48-21.5-48-48L0 272zM368 96l32 0c26.5 0 48 21.5 48 48l0 288c0 26.5-21.5 48-48 48l-32 0c-26.5 0-48-21.5-48-48l0-288c0-26.5 21.5-48 48-48z" 194 | /> 195 | </svg> 196 | </div> 197 | <div class="stat-title">{ "Total Score" }</div> 198 | <div class="stat-value">{ total_score_formatted }</div> 199 | </div> 200 | </div> 201 | </div> 202 | <BSkyButton 203 | text={format!("High Score: {}\nAverage Score: {}\nTotal Score: {}\n",high_score_formatted, average_score_formatted, total_score_formatted);} 204 | /> 205 | </div> 206 | // Achievement Stats Card 207 | <div class="card shadow-xl"> 208 | <div class="card-body"> 209 | <h3 class="card-title">{ "Achievements" }</h3> 210 | <div class="stats stats-vertical shadow"> 211 | <div class="stat"> 212 | <div class="stat-figure "> 213 | <svg 214 | class="inline-block h-8 w-8 fill-primary" 215 | xmlns="http://www.w3.org/2000/svg" 216 | viewBox="0 0 576 512" 217 | > 218 | <path 219 | d="M0 96C0 60.7 28.7 32 64 32H384c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96z" 220 | /> 221 | </svg> 222 | </div> 223 | <div class="stat-title">{ "Highest Block" }</div> 224 | <div class="stat-value"> 225 | { highest_number_block_formatted } 226 | </div> 227 | </div> 228 | <div class="stat"> 229 | <div class="stat-figure "> 230 | <h1 class="text-primary text-xl font-bold"> 231 | { "2048" } 232 | </h1> 233 | </div> 234 | <div class="stat-title">{ "Times 2048 Found" }</div> 235 | <div class="stat-value"> 236 | { times_twenty_forty_eight_been_found_formatted } 237 | </div> 238 | </div> 239 | <div class="stat"> 240 | <div class="stat-figure"> 241 | <svg 242 | class="inline-block h-8 w-8 fill-primary" 243 | xmlns="http://www.w3.org/2000/svg" 244 | viewBox="0 0 576 512" 245 | > 246 | <path 247 | d="M320 48a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM125.7 175.5c9.9-9.9 23.4-15.5 37.5-15.5c1.9 0 3.8 .1 5.6 .3L137.6 254c-9.3 28 1.7 58.8 26.8 74.5l86.2 53.9-25.4 88.8c-4.9 17 5 34.7 22 39.6s34.7-5 39.6-22l28.7-100.4c5.9-20.6-2.6-42.6-20.7-53.9L238 299l30.9-82.4 5.1 12.3C289 264.7 323.9 288 362.7 288l21.3 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-21.3 0c-12.9 0-24.6-7.8-29.5-19.7l-6.3-15c-14.6-35.1-44.1-61.9-80.5-73.1l-48.7-15c-11.1-3.4-22.7-5.2-34.4-5.2c-31 0-60.8 12.3-82.7 34.3L57.4 153.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l23.1-23.1zM91.2 352L32 352c-17.7 0-32 14.3-32 32s14.3 32 32 32l69.6 0c19 0 36.2-11.2 43.9-28.5L157 361.6l-9.5-6c-17.5-10.9-30.5-26.8-37.9-44.9L91.2 352z" 248 | /> 249 | </svg> 250 | </div> 251 | <div class="stat-title">{ "Lowest turns to 2048" }</div> 252 | <div class="stat-value"> 253 | { lowest_turns_till_2048_formatted } 254 | </div> 255 | <div class="stat-desc">{ "moves" }</div> 256 | </div> 257 | </div> 258 | </div> 259 | <BSkyButton 260 | text={format!("Highest Block: {}\nTimes 2048 found: {}\nLowest turns to 2048: {}\n",highest_number_block_formatted, times_twenty_forty_eight_been_found_formatted, lowest_turns_till_2048_formatted);} 261 | /> 262 | </div> 263 | // Game History Card 264 | <div class="card shadow-xl"> 265 | <div class="card-body"> 266 | <h3 class="card-title">{ "Game History" }</h3> 267 | <div class="stats stats-vertical shadow"> 268 | <div class="stat"> 269 | <div class="stat-figure"> 270 | <svg 271 | class="inline-block h-8 w-12 fill-primary" 272 | xmlns="http://www.w3.org/2000/svg" 273 | viewBox="0 0 576 512" 274 | > 275 | <path 276 | d="M192 64C86 64 0 150 0 256S86 448 192 448l256 0c106 0 192-86 192-192s-86-192-192-192L192 64zM496 168a40 40 0 1 1 0 80 40 40 0 1 1 0-80zM392 304a40 40 0 1 1 80 0 40 40 0 1 1 -80 0zM168 200c0-13.3 10.7-24 24-24s24 10.7 24 24l0 32 32 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-32 0 0 32c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-32-32 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l32 0 0-32z" 277 | /> 278 | </svg> 279 | </div> 280 | <div class="stat-title">{ "Total Games" }</div> 281 | <div class="stat-value">{ total_games_formatted }</div> 282 | </div> 283 | <div class="stat"> 284 | <div class="stat-title">{ "Win Rate" }</div> 285 | <div class="stat-value"> 286 | { format!("{}%", 287 | if stats_state.games_played > 0 { 288 | (stats_state.times_twenty_forty_eight_been_found as f64 289 | / stats_state.games_played as f64 290 | * 100.0).round() 291 | } else { 292 | 0.0 293 | } 294 | ) } 295 | </div> 296 | </div> 297 | // <div class="stat"> 298 | // <div class="stat-title">{ "First Game" }</div> 299 | // <div class="stat-desc"> 300 | // { stats_state.created_at.as_str() } 301 | // </div> 302 | // </div> 303 | </div> 304 | </div> 305 | <BSkyButton 306 | text={format!("I've played {} games of at://2048", total_games_formatted);} 307 | /> 308 | </div> 309 | </div> 310 | </div> 311 | </div> 312 | } 313 | } else { 314 | html! { 315 | <div class="flex flex-col items-center justify-center h-screen bg-base-200"> 316 | <div class="flex items-center justify-center"> 317 | <span class="loading loading-spinner loading-lg" /> 318 | <h1 class="ml-4 text-3xl font-bold">{ "Loading Stats..." }</h1> 319 | </div> 320 | </div> 321 | } 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /client_2048/src/pages/history.rs: -------------------------------------------------------------------------------- 1 | use crate::Route; 2 | use crate::agent::{StorageRequest, StorageResponse, StorageTask}; 3 | use crate::at_repo_sync::{AtRepoSync, AtRepoSyncError}; 4 | use crate::idb::{DB_NAME, GAME_STORE, RecordStorageWrapper, paginated_cursor}; 5 | use crate::oauth_client::oauth_client; 6 | use crate::pages::game::TileProps; 7 | use crate::store::UserStore; 8 | use StorageResponse::RepoError; 9 | use atrium_api::agent::Agent; 10 | use atrium_api::types::string::Did; 11 | use gloo::dialogs::{alert, confirm}; 12 | use indexed_db_futures::database::Database; 13 | use std::fmt::Display; 14 | use std::rc::Rc; 15 | use twothousand_forty_eight::unified::game::GameState; 16 | use twothousand_forty_eight::unified::validation::{Validatable, ValidationResult}; 17 | use twothousand_forty_eight::v2::recording::SeededRecording; 18 | use types_2048::blue::_2048::game; 19 | use wasm_bindgen::JsCast; 20 | use web_sys::HtmlElement; 21 | use yew::platform::spawn_local; 22 | use yew::prelude::*; 23 | use yew_agent::oneshot::use_oneshot_runner; 24 | use yew_hooks::use_effect_once; 25 | use yew_router::hooks::use_navigator; 26 | use yew_router::prelude::Link; 27 | use yewdux::use_store; 28 | 29 | #[derive(Clone, PartialEq, Default, Debug)] 30 | pub enum TabState { 31 | #[default] 32 | Local, 33 | Remote, 34 | Both, 35 | } 36 | 37 | impl Display for TabState { 38 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 39 | match self { 40 | TabState::Local => write!(f, "Local"), 41 | TabState::Remote => write!(f, "Remote"), 42 | TabState::Both => write!(f, "Both"), 43 | } 44 | } 45 | } 46 | 47 | impl From<TabState> for String { 48 | fn from(tab: TabState) -> Self { 49 | match tab { 50 | TabState::Local => "Local".to_string(), 51 | TabState::Remote => "Remote".to_string(), 52 | TabState::Both => "Both".to_string(), 53 | } 54 | } 55 | } 56 | 57 | impl From<TabState> for &'static str { 58 | fn from(tab: TabState) -> Self { 59 | match tab { 60 | TabState::Local => "Local", 61 | TabState::Remote => "Remote", 62 | TabState::Both => "Both", 63 | } 64 | } 65 | } 66 | 67 | #[derive(Properties, Clone, PartialEq)] 68 | struct HistoryTabProps { 69 | action: Callback<TabState>, 70 | logged_in: bool, 71 | } 72 | #[function_component(HistoryTab)] 73 | fn tab_component(props: &HistoryTabProps) -> Html { 74 | let tab_state = use_state(|| TabState::default()); 75 | 76 | let tab_event_clone = tab_state.clone(); 77 | let action = props.action.clone(); 78 | let onclick = Callback::from(move |event: MouseEvent| { 79 | let element = event.target().unwrap().dyn_into::<HtmlElement>().unwrap(); 80 | let tab_name = element.text_content().unwrap(); 81 | let local_tab_state = match tab_name.as_str() { 82 | "Local" => TabState::Local, 83 | "Remote" => TabState::Remote, 84 | "Both" => TabState::Both, 85 | _ => TabState::Local, 86 | }; 87 | action.emit(local_tab_state.clone()); 88 | tab_event_clone.set(local_tab_state) 89 | }); 90 | 91 | html! { 92 | <div role="tablist" class="tabs tabs-lift tabs-lg"> 93 | <a 94 | onclick={onclick.clone()} 95 | role="tab" 96 | class={classes!("tab", (*tab_state == TabState::Local).then(|| Some("tab-active")))} 97 | > 98 | { "Local" } 99 | </a> 100 | if props.logged_in { 101 | <a 102 | onclick={onclick} 103 | role="tab" 104 | class={classes!("tab", (*tab_state == TabState::Remote).then(|| Some("tab-active")))} 105 | > 106 | { "Remote" } 107 | </a> 108 | } 109 | // <a 110 | // {onclick} 111 | // role="tab" 112 | // class={classes!("tab", (*tab_state == TabState::Both).then(|| Some("tab-active")))} 113 | // > 114 | // { "Both" } 115 | // </a> 116 | </div> 117 | } 118 | } 119 | 120 | #[function_component(MiniTile)] 121 | fn mini_tile(props: &TileProps) -> Html { 122 | let TileProps { 123 | tile_value: tile_value_ref, 124 | .. 125 | } = props; 126 | 127 | let text = if *tile_value_ref == 0 { 128 | String::new() 129 | } else { 130 | tile_value_ref.to_string() 131 | }; 132 | 133 | //TODO fix font size for big numbers 134 | let tile_class = crate::pages::game::get_bg_color_and_text_color(*tile_value_ref); 135 | html! { 136 | <div class=" p-1 flex items-center justify-center"> 137 | <div 138 | class={format!( 139 | "flex items-center justify-center w-8 h-full {} font-bold text rounded-md", 140 | tile_class 141 | )} 142 | > 143 | { text } 144 | </div> 145 | </div> 146 | } 147 | } 148 | 149 | #[derive(Properties, Clone, PartialEq)] 150 | struct MiniGameboardProps { 151 | recording: SeededRecording, 152 | } 153 | 154 | #[function_component(MiniGameboard)] 155 | fn mini_gameboard(props: &MiniGameboardProps) -> Html { 156 | let gamestate = GameState::from_reconstructable_ruleset(&props.recording).unwrap(); 157 | let flatten_tiles = gamestate 158 | .board 159 | .tiles 160 | .iter() 161 | .flatten() 162 | .filter_map(|tile| *tile) 163 | .collect::<Vec<_>>(); 164 | html! { 165 | <div 166 | class="w-1/4 flex-1 mx-auto w-full bg-light-board-background shadow-2xl rounded-md p-1" 167 | > 168 | <div class={classes!(String::from("grid grid-cols-4 p-1 md:p-2 w-full h-full"))}> 169 | { flatten_tiles.into_iter().map(|tile| { 170 | html! { <MiniTile key={tile.id} tile_value={tile.value} new_tile={tile.new} x={tile.x} y={tile.y} size={4} /> } 171 | }).collect::<Html>() } 172 | </div> 173 | </div> 174 | } 175 | } 176 | 177 | #[derive(Properties, Clone, PartialEq)] 178 | struct GameTileProps { 179 | game: Rc<RecordStorageWrapper<game::RecordData>>, 180 | did: Option<Did>, 181 | reload_action: Callback<()>, 182 | } 183 | 184 | #[function_component(GameTile)] 185 | fn game_tile(props: &GameTileProps) -> Html { 186 | let record_key = props.game.rkey.clone(); 187 | let seeded_recording = use_state(|| None); 188 | let validation_result: UseStateHandle<Option<ValidationResult>> = use_state(|| None); 189 | let resync_loading = use_state(|| false); 190 | let sync_error = use_state(|| None); 191 | let navigator = use_navigator().unwrap(); 192 | 193 | let storage_task = use_oneshot_runner::<StorageTask>(); 194 | let storage_agent = storage_task.clone(); 195 | 196 | let use_effect_seeded_clone = seeded_recording.clone(); 197 | use_effect_with(props.game.clone(), move |game| { 198 | match game.record.seeded_recording.parse::<SeededRecording>() { 199 | Ok(results) => use_effect_seeded_clone.set(Some(results)), 200 | Err(err) => { 201 | log::error!("{:?}", err); 202 | } 203 | } 204 | }); 205 | 206 | let validation_clone = validation_result.clone(); 207 | use_effect_with(props.game.clone(), move |game| { 208 | match &game.record.seeded_recording.parse::<SeededRecording>() { 209 | Ok(seeded_recording) => match seeded_recording.validate() { 210 | Ok(result) => validation_clone.set(Some(result)), 211 | Err(_) => {} 212 | }, 213 | Err(_) => {} 214 | } 215 | }); 216 | 217 | let storage_agent_for_click = storage_agent.clone(); // Clone it before use 218 | let did = props.did.clone(); 219 | let resync_loading_clone = resync_loading.clone(); 220 | let sync_error_clone = sync_error.clone(); 221 | let cloned_reload_action = props.reload_action.clone(); 222 | let sync_onclick = Callback::from(move |_: MouseEvent| { 223 | let did = did.clone(); 224 | let sync_error_clone = sync_error_clone.clone(); 225 | let request = StorageRequest::TryToSyncRemotely(record_key.clone(), did.clone()); 226 | let storage_agent_for_click = storage_agent_for_click.clone(); // Clone it before use 227 | let navigator = navigator.clone(); 228 | let cloned_reload_action = cloned_reload_action.clone(); 229 | 230 | resync_loading_clone.set(true); 231 | let resync_loading_clone_for_closure = resync_loading_clone.clone(); 232 | spawn_local(async move { 233 | let result = storage_agent_for_click.run(request).await; 234 | match result { 235 | StorageResponse::Error(err) => { 236 | let message_sorry = "Sorry there was an error saving your game. This is still in alpha and has some bugs so please excuse us. If you are logged in with your AT Proto account may try relogging and refreshing this page without hitting new game. It will try to sync again. Sorry again and thanks for trying out at://2048!"; 237 | alert(message_sorry); 238 | log::error!("Error saving game: {:?}", err.to_string()); 239 | } 240 | RepoError(error) => { 241 | log::error!("Error saving game: {:?}", error.to_string()); 242 | match error { 243 | AtRepoSyncError::AuthErrorNeedToReLogin => { 244 | match confirm( 245 | "Your AT Protocol session has expired. You need to relogin to save your game to your profile. Press confirm to be redirected to login page.", 246 | ) { 247 | true => { 248 | if let Some(did) = did.as_ref() { 249 | navigator.push(&Route::LoginPageWithDid { 250 | did: did.to_string(), 251 | }) 252 | } 253 | } 254 | false => { 255 | // dispatch.set(UserStore::default()); 256 | } 257 | } 258 | } 259 | AtRepoSyncError::Error(err) => { 260 | sync_error_clone.set(Some(err)); 261 | } 262 | _ => {} 263 | } 264 | } 265 | StorageResponse::Success => { 266 | cloned_reload_action.emit(()); 267 | } 268 | _ => {} 269 | }; 270 | resync_loading_clone_for_closure.set(false); 271 | }); 272 | }); 273 | 274 | // let formatted_game_date = js_sys::Date::new(&JsValue::from_str(props.game.created_at.as_str())); 275 | let formated_date = props 276 | .game 277 | .record 278 | .created_at 279 | .as_ref() 280 | .format("%m/%d/%Y %H:%M"); 281 | match validation_result.as_ref() { 282 | Some(validation_result) => { 283 | html! { 284 | <div class="bg-base-100 shadow-lg rounded-lg md:p-6 p-1 flex flex-row"> 285 | <div class="flex flex-col"> 286 | <span class="text-md"> 287 | { format!("Score: {}", validation_result.score) } 288 | </span> 289 | if let Some(seeded_recording) = seeded_recording.as_ref() { 290 | <MiniGameboard recording={seeded_recording.clone()} /> 291 | } 292 | <span> 293 | { match seeded_recording.as_ref() { 294 | Some(recording) => { 295 | html!{<Link<Route> classes="cursor-pointer underline text-blue-600 visited:text-purple-600" to={Route::SeedPage { seed: recording.seed }}>{ format!("Seed: {}", recording.seed) }</Link<Route>>} 296 | }, 297 | None => html!{ <p> {"Loading seed.."} </p> } 298 | } } 299 | </span> 300 | </div> 301 | <div class="pl-2 md:w-3/4 w-1/2 mx-auto"> 302 | <p> 303 | { match seeded_recording.as_ref() { 304 | Some(recording) => format!("Moves: {}", recording.moves.len().to_string()), 305 | None => String::from("Loading moves..") 306 | } } 307 | </p> 308 | <p>{ formated_date.to_string() }</p> 309 | <div class="pt-2"> 310 | if let Some(_) = props.did.clone() { 311 | if props.game.record.sync_status.synced_with_at_repo { 312 | <div class="badge badge-success"> 313 | <svg 314 | class="size-[1em]" 315 | xmlns="http://www.w3.org/2000/svg" 316 | viewBox="0 0 24 24" 317 | > 318 | <g 319 | fill="currentColor" 320 | stroke-linejoin="miter" 321 | stroke-linecap="butt" 322 | > 323 | <circle 324 | cx="12" 325 | cy="12" 326 | r="10" 327 | fill="none" 328 | stroke="currentColor" 329 | stroke-linecap="square" 330 | stroke-miterlimit="10" 331 | stroke-width="2" 332 | /> 333 | <polyline 334 | points="7 13 10 16 17 8" 335 | fill="none" 336 | stroke="currentColor" 337 | stroke-linecap="square" 338 | stroke-miterlimit="10" 339 | stroke-width="2" 340 | /> 341 | </g> 342 | </svg> 343 | { "Synced" } 344 | </div> 345 | } else { 346 | if *resync_loading { 347 | <button class="btn btn-outline" disabled=true> 348 | <span class="loading loading-spinner" /> 349 | { "loading" } 350 | </button> 351 | } else { 352 | <button onclick={sync_onclick} class="btn btn-outline"> 353 | <svg 354 | class="inline-block w-5 fill-[#0a7aff]" 355 | xmlns="http://www.w3.org/2000/svg" 356 | viewBox="0 0 640 512" 357 | > 358 | <path 359 | d="M144 480C64.5 480 0 415.5 0 336c0-62.8 40.2-116.2 96.2-135.9c-.1-2.7-.2-5.4-.2-8.1c0-88.4 71.6-160 160-160c59.3 0 111 32.2 138.7 80.2C409.9 102 428.3 96 448 96c53 0 96 43 96 96c0 12.2-2.3 23.8-6.4 34.6C596 238.4 640 290.1 640 352c0 70.7-57.3 128-128 128l-368 0zm79-217c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l39-39L296 392c0 13.3 10.7 24 24 24s24-10.7 24-24l0-134.1 39 39c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-80-80c-9.4-9.4-24.6-9.4-33.9 0l-80 80z" 360 | /> 361 | </svg> 362 | { "Sync" } 363 | </button> 364 | } 365 | } 366 | } 367 | </div> 368 | if let Some(err) = sync_error.as_ref() { 369 | <span class="text-red-500">{ err }</span> 370 | } 371 | </div> 372 | </div> 373 | } 374 | } 375 | None => html! { 376 | <div class="bg-base-100 shadow-lg rounded-lg md:p-6 p-1"> 377 | <div class="w-full max-w-2xl mx-auto"> 378 | <span>{ "there was an issue validating this game." }</span> 379 | </div> 380 | </div> 381 | }, 382 | } 383 | } 384 | 385 | #[derive(Clone, PartialEq)] 386 | struct PaginationOptions { 387 | count: u32, 388 | skip: u32, 389 | at_proto_cursor: Option<String>, 390 | /// Set to true once there is no more games to load 391 | fully_loaded: bool, 392 | } 393 | 394 | impl Default for PaginationOptions { 395 | fn default() -> Self { 396 | PaginationOptions { 397 | count: 10, 398 | skip: 0, 399 | at_proto_cursor: None, 400 | fully_loaded: false, 401 | } 402 | } 403 | } 404 | 405 | async fn get_local_games( 406 | options: PaginationOptions, 407 | ) -> Result<Rc<Vec<Rc<RecordStorageWrapper<game::RecordData>>>>, AtRepoSyncError> { 408 | let db = Database::open(DB_NAME) 409 | .await 410 | .map_err(|e| AtRepoSyncError::Error(e.to_string()))?; 411 | 412 | let local_games: Vec<RecordStorageWrapper<game::RecordData>> = 413 | paginated_cursor(db, GAME_STORE, options.count, options.skip) 414 | .await 415 | .map_err(|e| AtRepoSyncError::Error(e.to_string()))?; 416 | Ok(Rc::new( 417 | local_games 418 | .iter() 419 | .map(|game| Rc::new(game.clone())) 420 | .collect::<Vec<_>>(), 421 | )) 422 | } 423 | 424 | async fn get_remote_games( 425 | did: Did, 426 | pagination_options: PaginationOptions, 427 | ) -> Result< 428 | ( 429 | Rc<Vec<Rc<RecordStorageWrapper<game::RecordData>>>>, 430 | Option<String>, 431 | ), 432 | AtRepoSyncError, 433 | > { 434 | let oauth_client = oauth_client(); 435 | let session = match oauth_client.restore(&did).await { 436 | Ok(session) => session, 437 | Err(err) => { 438 | return Err(AtRepoSyncError::Error(err.to_string())); 439 | } 440 | }; 441 | let agent = Agent::new(session); 442 | let at_repo_sync = AtRepoSync::new_logged_in_repo(agent, did); 443 | match at_repo_sync 444 | .get_remote_games( 445 | pagination_options.at_proto_cursor, 446 | Some(pagination_options.count as u8), 447 | ) 448 | .await 449 | { 450 | Ok(results) => Ok(results), 451 | Err(err) => Err(AtRepoSyncError::Error(err.to_string())), 452 | } 453 | } 454 | 455 | async fn get_games( 456 | tab_state: &TabState, 457 | options: PaginationOptions, 458 | did: Option<Did>, 459 | ) -> Result< 460 | ( 461 | Rc<Vec<Rc<RecordStorageWrapper<game::RecordData>>>>, 462 | Option<String>, 463 | ), 464 | AtRepoSyncError, 465 | > { 466 | match tab_state { 467 | TabState::Local => { 468 | let result = get_local_games(options).await?; 469 | Ok((result, None)) 470 | } 471 | TabState::Remote => { 472 | if let Some(did) = did.clone() { 473 | get_remote_games(did, options).await 474 | } else { 475 | Err(AtRepoSyncError::AuthErrorNeedToReLogin) 476 | } 477 | } 478 | TabState::Both => { 479 | //May not implement this 480 | todo!() 481 | } 482 | } 483 | } 484 | 485 | #[function_component(HistoryPage)] 486 | pub fn history() -> Html { 487 | let (user_store, _) = use_store::<UserStore>(); 488 | let pagination = use_state(|| PaginationOptions::default()); 489 | // let display_games = use_state(|| Rc::new(vec![])); 490 | let display_games = use_state(|| None); 491 | let current_tab_state = use_state(|| Rc::new(TabState::Local)); 492 | 493 | let display_games_for_mount = display_games.clone(); 494 | let display_games_effect = display_games.clone(); 495 | let pagination_clone = pagination.clone(); 496 | let use_effect_pagination = pagination.clone(); 497 | use_effect_once(move || { 498 | spawn_local(async move { 499 | //Can default pagination since this is on load 500 | match get_local_games(PaginationOptions::default()).await { 501 | Ok(games) => { 502 | if games.len() < use_effect_pagination.count as usize { 503 | use_effect_pagination.set(PaginationOptions { 504 | count: use_effect_pagination.count, 505 | skip: 0, 506 | at_proto_cursor: None, 507 | fully_loaded: true, 508 | }); 509 | } 510 | &display_games_effect.set(Some(games)) 511 | } 512 | Err(err) => { 513 | log::error!("{:?}", err); 514 | &() 515 | } 516 | }; 517 | }); 518 | || () 519 | }); 520 | 521 | let pagination_clone = pagination_clone.clone(); 522 | let tab_click_callback = { 523 | let display_games = display_games_for_mount.clone(); 524 | let pagination_clone = pagination_clone.clone(); 525 | let user_store = user_store.clone(); 526 | let current_tab_state = current_tab_state.clone(); 527 | Callback::from(move |tab_state: TabState| { 528 | current_tab_state.set(Rc::new(tab_state.clone())); 529 | let display_games = display_games.clone(); 530 | let did = user_store.did.clone(); 531 | let pagination = pagination_clone.clone(); 532 | spawn_local(async move { 533 | //Just defaulting pagination on tab change 534 | match get_games(&tab_state, PaginationOptions::default(), did).await { 535 | Ok((games, cursor)) => { 536 | let mut new_pagination = PaginationOptions::default(); 537 | new_pagination.at_proto_cursor = cursor; 538 | pagination.set(new_pagination); 539 | &display_games.set(Some(games)) 540 | } 541 | Err(err) => { 542 | log::error!("{:?}", err); 543 | &() 544 | } 545 | }; 546 | }) 547 | }) 548 | }; 549 | 550 | let load_more_pagination_clone = pagination_clone.clone(); 551 | let load_more_games_clone = display_games_for_mount.clone(); 552 | let load_more_tab_clone = current_tab_state.clone(); 553 | let load_more_callback = { 554 | let pagination = load_more_pagination_clone.clone(); 555 | let display_games = load_more_games_clone.clone(); 556 | let user_store = user_store.clone(); 557 | Callback::from(move |_: MouseEvent| { 558 | let pagination = pagination.clone(); 559 | let display_games = display_games.clone(); 560 | let current_tab_state = load_more_tab_clone.clone(); 561 | let did = user_store.did.clone(); 562 | let mut new_pagination = PaginationOptions::default(); 563 | new_pagination.skip = pagination.skip + new_pagination.count; 564 | new_pagination.at_proto_cursor = pagination.at_proto_cursor.clone(); 565 | spawn_local(async move { 566 | match get_games(current_tab_state.as_ref(), new_pagination.clone(), did).await { 567 | Ok((games, cursor)) => { 568 | let mut combined = match &*display_games { 569 | Some(games) => games.as_ref().to_vec(), 570 | None => vec![], 571 | }; 572 | combined.extend(games.to_vec()); 573 | display_games.set(Some(Rc::new(combined))); 574 | new_pagination.fully_loaded = games.len() < new_pagination.count as usize; 575 | new_pagination.at_proto_cursor = cursor; 576 | pagination.set(new_pagination); 577 | } 578 | Err(err) => { 579 | log::error!("{:?}", err); 580 | } 581 | } 582 | }) 583 | }) 584 | }; 585 | 586 | let reload_callback_pagination_clone = pagination_clone.clone(); 587 | let games_reload_callback_clone = display_games_for_mount.clone(); 588 | let current_tab_state_clone = current_tab_state.clone(); 589 | let reload_callback = { 590 | let user_store = user_store.clone(); 591 | Callback::from(move |_| { 592 | let display_games = games_reload_callback_clone.clone(); 593 | let pagination = reload_callback_pagination_clone.clone(); 594 | let current_tab_state = current_tab_state_clone.clone(); 595 | let did = user_store.did.clone(); 596 | spawn_local(async move { 597 | match get_games(current_tab_state.as_ref(), (*pagination).clone(), did).await { 598 | Ok((games, _)) => display_games.set(Some(games)), 599 | Err(err) => { 600 | log::error!("{:?}", err); 601 | } 602 | }; 603 | }) 604 | }) 605 | }; 606 | 607 | html! { 608 | <div class="md:p-4 p-1"> 609 | <div class="max-w-4xl mx-auto space-y-6 justify-center"> 610 | <h1 class="text-4xl font-bold text-center md:mb-6 mb-1">{ "Game History" }</h1> 611 | <div class="bg-base-100 shadow-lg rounded-lg md:p-6 p-1"> 612 | <div class="w-full max-w-2xl mx-auto"> 613 | <HistoryTab 614 | action={tab_click_callback} 615 | logged_in={user_store.did.is_some()} 616 | /> 617 | <div class="grid grid-cols-1 gap-6"> 618 | if display_games_for_mount.is_none() { 619 | //If the games have not loaded yet show the loading spinner 620 | <div class="flex items-center justify-center"> 621 | <span class="loading loading-spinner loading-lg" /> 622 | <h1 class="ml-4 text-3xl font-bold">{ "Loading..." }</h1> 623 | </div> 624 | } else { 625 | // The actual bit that shows the game tiles 626 | { display_games_for_mount.as_ref().map(|games| { 627 | (**games).iter().enumerate().map(|(i, game)| { 628 | html! { 629 | <GameTile key={i} game={game.clone()} did={user_store.did.clone()} reload_action={reload_callback.clone()} /> 630 | } 631 | }).collect::<Html>() 632 | }).unwrap_or_default() } 633 | if let Some(games) = display_games.as_ref() { 634 | if games.len() == 0 { 635 | <div class="flex items-center justify-center"> 636 | <h1 class="pt-2 ml-4 text-3xl font-bold"> 637 | { "You have no games to show." } 638 | </h1> 639 | </div> 640 | } else { 641 | if !(*pagination).fully_loaded { 642 | <div class="flex w-full justify-center"> 643 | <button 644 | onclick={load_more_callback} 645 | class="btn btn-outline btn-wide" 646 | > 647 | { "Load more" } 648 | </button> 649 | </div> 650 | } 651 | } 652 | } 653 | } 654 | </div> 655 | </div> 656 | </div> 657 | </div> 658 | </div> 659 | } 660 | } 661 | --------------------------------------------------------------------------------