Trading card city builder game?

configure everything for unit testing

eldridge.cam 20d521d3 9b0fb2ae

verified
+536 -185
+1
.env.example
··· 1 1 RUST_LOG=info 2 2 3 3 DATABASE_URL="postgres://postgres:postgres@localhost:5432/cartography" 4 + TEST_DATABASE_URL="postgres://postgres:postgres@localhost:5432/cartography-test" 4 5 SHADOW_DATABASE_URL="postgres://postgres:postgres@localhost:5432/shadow" 5 6 ROOT_DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres" 6 7
+29 -2
Cargo.lock
··· 292 292 dependencies = [ 293 293 "anyhow", 294 294 "axum 0.8.8", 295 + "cartography-macros", 295 296 "clap", 296 297 "derive_more", 297 298 "futures", 298 299 "futures-rx", 300 + "hex", 301 + "http-body-util", 299 302 "json-patch", 300 303 "kameo", 301 304 "rand 0.10.0", ··· 307 310 "time", 308 311 "tokio", 309 312 "tokio-stream", 313 + "tower", 310 314 "tower-http", 311 315 "tracing", 312 316 "tracing-subscriber", 313 317 "utoipa 5.4.0 (git+https://github.com/foxfriends/utoipa)", 314 318 "uuid", 319 + ] 320 + 321 + [[package]] 322 + name = "cartography-macros" 323 + version = "0.1.0" 324 + dependencies = [ 325 + "hex", 326 + "proc-macro2", 327 + "quote", 328 + "regex", 329 + "syn", 315 330 ] 316 331 317 332 [[package]] ··· 1589 1604 ] 1590 1605 1591 1606 [[package]] 1607 + name = "regex" 1608 + version = "1.12.3" 1609 + source = "registry+https://github.com/rust-lang/crates.io-index" 1610 + checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" 1611 + dependencies = [ 1612 + "aho-corasick", 1613 + "memchr", 1614 + "regex-automata", 1615 + "regex-syntax", 1616 + ] 1617 + 1618 + [[package]] 1592 1619 name = "regex-automata" 1593 1620 version = "0.4.14" 1594 1621 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2117 2144 2118 2145 [[package]] 2119 2146 name = "syn" 2120 - version = "2.0.114" 2147 + version = "2.0.115" 2121 2148 source = "registry+https://github.com/rust-lang/crates.io-index" 2122 - checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" 2149 + checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" 2123 2150 dependencies = [ 2124 2151 "proc-macro2", 2125 2152 "quote",
+10 -33
Cargo.toml
··· 1 - [package] 2 - name = "cartography" 1 + [workspace] 2 + resolver = "3" 3 + members = [ 4 + "packages/cartography", 5 + "packages/cartography-macros", 6 + ] 7 + 8 + [workspace.package] 3 9 version = "0.1.0" 4 - authors = ["Cameron Eldridge <cameldridge@gmail.com>"] 5 - edition = "2021" 6 - description = """ 7 - Cartography game server. 8 - """ 10 + edition = "2024" 9 11 license = "MIT" 10 - 11 - # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 - 13 - [dependencies] 14 - anyhow = "1.0.101" 15 - axum = { version = "0.8.8", features = ["ws"] } 16 - clap = { version = "4.5.58", features = ["derive"] } 17 - derive_more = { version = "2.1.1", features = ["error", "display"] } 18 - futures = "0.3.31" 19 - futures-rx = "0.2.1" 20 - json-patch = { version = "4.1.0", features = ["utoipa"] } 21 - kameo = "0.19.2" 22 - rand = { version = "0.10.0", features = ["alloc"] } 23 - rmp-serde = "1.3.1" 24 - scalar_api_reference = { version = "0.1.0", features = ["axum"] } 25 - serde = { version = "1.0.228", features = ["derive"] } 26 - serde_json = "1.0.149" 27 - sqlx = { version = "0.8.6", features = ["runtime-tokio", "postgres", "time"] } 28 - time = { version = "0.3.47", features = ["serde", "serde-human-readable"] } 29 - tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] } 30 - tokio-stream = { version = "0.1.18", features = ["sync"] } 31 - tower-http = { version = "0.6.8", features = ["trace"] } 32 - tracing = "0.1.44" 33 - tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] } 34 - utoipa = { git = "https://github.com/foxfriends/utoipa", features = ["time"] } # { version = "5.4.0", features = ["time"] } 35 - uuid = { version = "1.20.0", features = ["serde", "v7"] } 12 + authors = ["Cameron Eldridge <cameldridge@gmail.com>"]
+6 -6
Justfile
··· 65 65 66 66 [group: "dev"] 67 67 generate: 68 - cargo sqlx prepare 68 + cargo sqlx prepare --workspace 69 69 cargo run -- --openapi > openapi.json 70 70 cd app && npx orval 71 71 cd app && npx svelte-kit sync ··· 77 77 cd app && npx prettier --write . 78 78 79 79 [group: "dev"] 80 - test: 81 - cargo test 80 + test: up 81 + SQLX_OFFLINE=true DATABASE_URL={{ROOT_DATABASE_URL}} cargo test 82 82 cd app && npm test 83 83 84 84 [group: "database"] ··· 90 90 npx graphile-migrate watch 91 91 92 92 [group: "database"] 93 - migration: 93 + migration message: 94 94 npx prettier migrations -w 95 - npx graphile-migrate commit 95 + npx graphile-migrate commit -m {{message}} 96 96 97 97 [group: "database"] 98 98 unmigration: ··· 112 112 113 113 [group: "database"] 114 114 seed: 115 - trilogy run ./seeds/seed.tri 115 + RUST_LOG=off trilogy run ./seeds/seed.tri 116 116 117 117 [group: "database"] 118 118 apply-seed:
+2 -22
app/.prettierignore
··· 1 - .DS_Store 2 1 node_modules 3 2 /build 4 3 /.svelte-kit 5 - /package 6 - .env 7 - .env.* 8 - !.env.example 4 + /src/lib/appserver/dto 5 + /src/lib/appserver/api.ts 9 6 10 - # Ignore files for PNPM, NPM and YARN 11 - pnpm-lock.yaml 12 7 package-lock.json 13 - yarn.lock 14 - 15 - /src-tauri/target 16 - /src-tauri/gen/schemas 17 - 18 - /migrations/committed 19 - /generated 20 - 21 - /server/deps 22 - /server/_build 23 - /server/build 24 - 25 - /api/build 26 - 27 - /server-ex
+9 -1
generated/schema.sql
··· 170 170 CREATE TABLE public.citizens ( 171 171 species_id text NOT NULL, 172 172 name text NOT NULL, 173 - home_tile_id bigint, 174 173 id bigint NOT NULL, 174 + home_tile_id bigint, 175 175 CONSTRAINT citizens_name_check CHECK (((0 < length(name)) AND (length(name) < 64))) 176 176 ); 177 177 ··· 722 722 723 723 ALTER TABLE ONLY public.cards 724 724 ADD CONSTRAINT cards_card_type_id_fkey FOREIGN KEY (card_type_id) REFERENCES public.card_types(id) ON UPDATE CASCADE ON DELETE RESTRICT; 725 + 726 + 727 + -- 728 + -- Name: citizens citizens_home_tile_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - 729 + -- 730 + 731 + ALTER TABLE ONLY public.citizens 732 + ADD CONSTRAINT citizens_home_tile_id_fkey FOREIGN KEY (home_tile_id) REFERENCES public.tiles(id) ON UPDATE CASCADE ON DELETE SET NULL; 725 733 726 734 727 735 --
+21
packages/cartography-macros/Cargo.toml
··· 1 + [package] 2 + name = "cartography-macros" 3 + description = """ 4 + Cartography macros (internal usage only). 5 + """ 6 + version.workspace = true 7 + edition.workspace = true 8 + license.workspace = true 9 + authors.workspace = true 10 + 11 + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 + 13 + [lib] 14 + proc-macro = true 15 + 16 + [dependencies] 17 + hex = "0.4.3" 18 + proc-macro2 = "1.0.106" 19 + quote = "1.0.44" 20 + regex = "1.12.3" 21 + syn = "2.0.115"
+22
packages/cartography-macros/src/lib.rs
··· 1 + use proc_macro::TokenStream; 2 + use quote::quote; 3 + 4 + mod migrate; 5 + 6 + #[proc_macro] 7 + pub fn graphile_migrate(input: TokenStream) -> TokenStream { 8 + use syn::LitStr; 9 + 10 + let input = syn::parse_macro_input!(input as LitStr); 11 + match migrate::expand(input) { 12 + Ok(ts) => ts.into(), 13 + Err(e) => { 14 + if let Some(parse_err) = e.downcast_ref::<syn::Error>() { 15 + parse_err.to_compile_error().into() 16 + } else { 17 + let msg = e.to_string(); 18 + quote!(::std::compile_error!(#msg)).into() 19 + } 20 + } 21 + } 22 + }
+64
packages/cartography-macros/src/migrate.rs
··· 1 + use proc_macro2::TokenStream; 2 + use quote::quote; 3 + use regex::Regex; 4 + use std::sync::LazyLock; 5 + use syn::LitStr; 6 + 7 + static HASH_REGEX: LazyLock<Regex> = 8 + LazyLock::new(|| Regex::new(r"(?m)^--! Hash: sha1:([0-9a-fA-F]+)$").unwrap()); 9 + static FILE_REGEX: LazyLock<Regex> = 10 + LazyLock::new(|| Regex::new(r"^(\d+)(?:-(.*))?\.sql$").unwrap()); 11 + 12 + pub fn expand(path_arg: LitStr) -> Result<TokenStream, Box<dyn std::error::Error>> { 13 + let workspace_root = std::process::Command::new(env!("CARGO")) 14 + .arg("locate-project") 15 + .arg("--workspace") 16 + .arg("--message-format=plain") 17 + .output() 18 + .expect("failed to locate Cargo.toml") 19 + .stdout; 20 + let path = 21 + std::path::PathBuf::from(String::from_utf8(workspace_root).expect("cargo output not utf8")) 22 + .parent() 23 + .expect("Cargo.toml should be a proper path") 24 + .join(path_arg.value()); 25 + let dir = std::fs::read_dir(&path) 26 + .map_err(|error| format!("{error}\nmigrations directory: {}", path.display()))?; 27 + let mut migrations = dir.filter_map(|item| item.ok()).collect::<Vec<_>>(); 28 + migrations.sort_by_key(|item| item.file_name()); 29 + let migrations = migrations 30 + .into_iter() 31 + .map(|item| { 32 + let sql = std::fs::read_to_string(item.path())?; 33 + let name = item.file_name(); 34 + let name = name.to_str().expect("name is not valid"); 35 + let file_captures = FILE_REGEX.captures(name).expect("filename is not valid"); 36 + let version: i64 = file_captures.get(1).unwrap().as_str().parse()?; 37 + let description = file_captures 38 + .get(2) 39 + .map(|c| c.as_str().to_owned()) 40 + .unwrap_or(name.to_string()); 41 + let captures = HASH_REGEX 42 + .captures(&sql) 43 + .expect("failed to find checksum hash in file"); 44 + let checksum: Vec<_> = hex::decode(captures.get(1).unwrap().as_str())?; 45 + Ok(quote! { 46 + sqlx::migrate::Migration { 47 + version: #version, 48 + description: std::borrow::Cow::Borrowed(#description), 49 + migration_type: sqlx::migrate::MigrationType::Simple, 50 + sql: std::borrow::Cow::Borrowed(#sql), 51 + checksum: std::borrow::Cow::Borrowed(&[#(#checksum),*]), 52 + no_tx: false, 53 + } 54 + }) 55 + }) 56 + .collect::<Result<Vec<_>, Box<dyn std::error::Error>>>()?; 57 + 58 + Ok(quote! { 59 + sqlx::migrate::Migrator { 60 + migrations: std::borrow::Cow::Borrowed(&[#(#migrations,)*]), 61 + ..sqlx::migrate::Migrator::DEFAULT 62 + } 63 + }) 64 + }
+41
packages/cartography/Cargo.toml
··· 1 + [package] 2 + name = "cartography" 3 + description = """ 4 + Cartography game server. 5 + """ 6 + version.workspace = true 7 + edition.workspace = true 8 + license.workspace = true 9 + authors.workspace = true 10 + 11 + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 + 13 + [dependencies] 14 + anyhow = "1.0.101" 15 + axum = { version = "0.8.8", features = ["ws"] } 16 + clap = { version = "4.5.58", features = ["derive"] } 17 + derive_more = { version = "2.1.1", features = ["error", "display"] } 18 + futures = "0.3.31" 19 + futures-rx = "0.2.1" 20 + json-patch = { version = "4.1.0", features = ["utoipa"] } 21 + kameo = "0.19.2" 22 + rand = { version = "0.10.0", features = ["alloc"] } 23 + rmp-serde = "1.3.1" 24 + scalar_api_reference = { version = "0.1.0", features = ["axum"] } 25 + serde = { version = "1.0.228", features = ["derive"] } 26 + serde_json = "1.0.149" 27 + sqlx = { version = "0.8.6", features = ["runtime-tokio", "postgres", "time"] } 28 + time = { version = "0.3.47", features = ["serde", "serde-human-readable"] } 29 + tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] } 30 + tokio-stream = { version = "0.1.18", features = ["sync"] } 31 + tower-http = { version = "0.6.8", features = ["trace"] } 32 + tracing = "0.1.44" 33 + tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] } 34 + utoipa = { git = "https://github.com/foxfriends/utoipa", features = ["time"] } # { version = "5.4.0", features = ["time"] } 35 + uuid = { version = "1.20.0", features = ["serde", "v7"] } 36 + 37 + [dev-dependencies] 38 + hex = "0.4.3" 39 + http-body-util = "0.1.3" 40 + tower = "0.5.3" 41 + cartography-macros = { path = "../cartography-macros/" }
+1
packages/cartography/fixtures/account.sql
··· 1 + INSERT INTO accounts (id) VALUES ('foxfriends');
+122
packages/cartography/fixtures/seed.sql
··· 1 + INSERT INTO resources (id) 2 + VALUES 3 + ('bread'), 4 + ('flour'), 5 + ('grain'), 6 + ('water'), 7 + ('lettuce'), 8 + ('carrot'), 9 + ('tomato'), 10 + ('salad') 11 + ON CONFLICT DO NOTHING; 12 + 13 + INSERT INTO card_sets (id, release_date) 14 + VALUES 15 + ('base', '2026-01-01T00:00:00Z') 16 + ON CONFLICT (id) DO UPDATE 17 + SET release_date = EXCLUDED.release_date; 18 + 19 + INSERT INTO card_types (id, card_set_id, class) 20 + VALUES 21 + ('rabbit', 'base', 'citizen'), 22 + ('cat', 'base', 'citizen'), 23 + ('bird', 'base', 'citizen'), 24 + ('cat-colony', 'base', 'tile'), 25 + ('rabbit-warren', 'base', 'tile'), 26 + ('bird-nest', 'base', 'tile'), 27 + ('water-well', 'base', 'tile'), 28 + ('carrot-farm', 'base', 'tile'), 29 + ('tomato-farm', 'base', 'tile'), 30 + ('lettuce-farm', 'base', 'tile'), 31 + ('grain-farm', 'base', 'tile'), 32 + ('flour-mill', 'base', 'tile'), 33 + ('bread-bakery', 'base', 'tile'), 34 + ('salad-shop', 'base', 'tile') 35 + ON CONFLICT (id) DO UPDATE 36 + SET class = EXCLUDED.class, 37 + card_set_id = EXCLUDED.card_set_id; 38 + 39 + INSERT INTO species (id) 40 + VALUES 41 + ('rabbit'), 42 + ('cat'), 43 + ('bird') 44 + ON CONFLICT DO NOTHING; 45 + 46 + INSERT INTO pack_banners (id, start_date, end_date) 47 + VALUES 48 + ('default', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'), 49 + ('base-standard', '2026-01-01T00:00:00Z', NULL), 50 + ('halloween-2026', '2026-10-01T00:00:00Z', '2026-10-31T23:59:59Z') 51 + ON CONFLICT (id) DO UPDATE 52 + SET start_date = EXCLUDED.start_date, 53 + end_date = EXCLUDED.end_date; 54 + 55 + INSERT INTO pack_banner_cards (pack_banner_id, card_type_id, frequency) 56 + VALUES 57 + ('base-standard', 'cat-colony', 3), 58 + ('base-standard', 'rabbit-warren', 3), 59 + ('base-standard', 'bird-nest', 3), 60 + ('base-standard', 'water-well', 3), 61 + ('base-standard', 'carrot-farm', 3), 62 + ('base-standard', 'tomato-farm', 3), 63 + ('base-standard', 'lettuce-farm', 3), 64 + ('base-standard', 'grain-farm', 3), 65 + ('base-standard', 'flour-mill', 3), 66 + ('base-standard', 'bread-bakery', 3), 67 + ('base-standard', 'salad-shop', 3), 68 + ('base-standard', 'rabbit', 1), 69 + ('base-standard', 'cat', 1), 70 + ('base-standard', 'bird', 1) 71 + ON CONFLICT (pack_banner_id, card_type_id) DO UPDATE 72 + SET frequency = EXCLUDED.frequency; 73 + 74 + INSERT INTO species_needs (species_id, resource_id, quantity) 75 + VALUES 76 + ('rabbit', 'salad', 1), 77 + ('cat', 'bread', 1), 78 + ('bird', 'grain', 1) 79 + ON CONFLICT (species_id, resource_id) DO UPDATE 80 + SET quantity = EXCLUDED.quantity; 81 + 82 + INSERT INTO tile_types (id, category, houses, employs) 83 + VALUES 84 + ('cat-colony', 'residential', 3, 0), 85 + ('rabbit-warren', 'residential', 3, 0), 86 + ('bird-nest', 'residential', 3, 0), 87 + ('water-well', 'source', 0, 1), 88 + ('carrot-farm', 'production', 0, 3), 89 + ('tomato-farm', 'production', 0, 3), 90 + ('lettuce-farm', 'production', 0, 3), 91 + ('grain-farm', 'production', 0, 3), 92 + ('flour-mill', 'production', 0, 2), 93 + ('bread-bakery', 'amenity', 0, 2), 94 + ('salad-shop', 'amenity', 0, 2) 95 + ON CONFLICT (id) DO UPDATE 96 + SET category = EXCLUDED.category, 97 + houses = EXCLUDED.houses, 98 + employs = EXCLUDED.employs; 99 + 100 + INSERT INTO tile_type_consumes (tile_type_id, resource_id, quantity) 101 + VALUES 102 + ('flour-mill', 'grain', 1), 103 + ('bread-bakery', 'flour', 3), 104 + ('bread-bakery', 'water', 2), 105 + ('salad-shop', 'tomato', 1), 106 + ('salad-shop', 'lettuce', 1), 107 + ('salad-shop', 'carrot', 1) 108 + ON CONFLICT (tile_type_id, resource_id) DO UPDATE 109 + SET quantity = EXCLUDED.quantity; 110 + 111 + INSERT INTO tile_type_produces (tile_type_id, resource_id, quantity) 112 + VALUES 113 + ('water-well', 'water', 10), 114 + ('carrot-farm', 'carrot', 5), 115 + ('tomato-farm', 'tomato', 5), 116 + ('lettuce-farm', 'lettuce', 5), 117 + ('grain-farm', 'grain', 5), 118 + ('flour-mill', 'flour', 10), 119 + ('bread-bakery', 'bread', 5), 120 + ('salad-shop', 'salad', 3) 121 + ON CONFLICT (tile_type_id, resource_id) DO UPDATE 122 + SET quantity = EXCLUDED.quantity;
+94
packages/cartography/src/app.rs
··· 1 + use crate::api::{operations, ws}; 2 + use crate::bus::Bus; 3 + use axum::Router; 4 + use kameo::actor::Spawn as _; 5 + use utoipa::OpenApi as _; 6 + 7 + #[derive(utoipa::OpenApi)] 8 + #[openapi( 9 + paths( 10 + operations::get_banner, 11 + operations::list_banners, 12 + operations::pull_banner, 13 + 14 + operations::list_card_types, 15 + 16 + operations::list_fields, 17 + ), 18 + components( 19 + schemas( 20 + crate::dto::AccountIdOrMe 21 + ) 22 + ), 23 + tags( 24 + (name = "Global", description = "Publicly available global data about the Cartography game."), 25 + (name = "Player", description = "Player specific data; typically requires authorization."), 26 + (name = "Game", description = "Actions with effects on gameplay."), 27 + ), 28 + )] 29 + pub struct ApiDoc; 30 + 31 + pub struct Config { 32 + pool: sqlx::PgPool, 33 + } 34 + 35 + impl Config { 36 + pub async fn from_env() -> anyhow::Result<Self> { 37 + let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL is required"); 38 + 39 + let pool = sqlx::postgres::PgPoolOptions::new() 40 + .max_connections(10) 41 + .connect(&db_url) 42 + .await?; 43 + 44 + Ok(Self { pool }) 45 + } 46 + 47 + #[cfg(test)] 48 + pub fn test(pool: sqlx::PgPool) -> Self { 49 + Self { pool } 50 + } 51 + 52 + pub fn into_router(self) -> Router { 53 + let bus = Bus::spawn(()); 54 + 55 + // let scalar_config = serde_json::json!({ 56 + // "url": "/api/openapi.json", 57 + // "agent": scalar_api_reference::config::AgentOptions::disabled() 58 + // }); 59 + 60 + axum::Router::new() 61 + .route( 62 + "/api/v1/cardtypes", 63 + axum::routing::get(operations::list_card_types), 64 + ) 65 + .route( 66 + "/api/v1/banners", 67 + axum::routing::post(operations::list_banners), 68 + ) 69 + .route( 70 + "/api/v1/banners/{banner_id}", 71 + axum::routing::get(operations::get_banner), 72 + ) 73 + .route( 74 + "/api/v1/banners/{banner_id}/pull", 75 + axum::routing::post(operations::pull_banner), 76 + ) 77 + .route( 78 + "/api/v1/players/{player_id}/fields", 79 + axum::routing::get(operations::list_fields), 80 + ) 81 + .route("/play/ws", axum::routing::any(ws::v1)) 82 + .route( 83 + "/api/openapi.json", 84 + axum::routing::get(axum::response::Json(ApiDoc::openapi())), 85 + ) 86 + // .merge(scalar_api_reference::axum::router("/docs", &scalar_config)) // TODO: waiting on scalar_api_reference to re-publish 87 + .layer(axum::Extension(bus)) 88 + .layer(axum::Extension(self.pool)) 89 + } 90 + } 91 + 92 + pub async fn new() -> anyhow::Result<Router> { 93 + Ok(Config::from_env().await?.into_router()) 94 + }
+52
packages/cartography/src/main.rs
··· 1 + mod actor; 2 + mod api; 3 + mod app; 4 + mod bus; 5 + mod db; 6 + mod dto; 7 + #[cfg(test)] 8 + mod test; 9 + 10 + use clap::Parser as _; 11 + use tracing_subscriber::prelude::*; 12 + use utoipa::OpenApi as _; 13 + 14 + #[derive(clap::Parser)] 15 + struct Args { 16 + #[clap(long)] 17 + openapi: bool, 18 + } 19 + 20 + #[tokio::main] 21 + async fn main() -> anyhow::Result<()> { 22 + let args = Args::parse(); 23 + 24 + if args.openapi { 25 + println!("{}", app::ApiDoc::openapi().to_pretty_json().unwrap()); 26 + std::process::exit(0); 27 + } 28 + 29 + tracing_subscriber::registry() 30 + .with(tracing_subscriber::EnvFilter::from_default_env()) 31 + .with(tracing_subscriber::fmt::layer().pretty()) 32 + .init(); 33 + 34 + let host: std::net::IpAddr = std::env::var("HOST") 35 + .as_deref() 36 + .unwrap_or("0.0.0.0") 37 + .parse() 38 + .expect("HOST must be a valid IP address"); 39 + let port = std::env::var("PORT") 40 + .as_deref() 41 + .unwrap_or("12000") 42 + .parse() 43 + .expect("PORT must be a valid u16"); 44 + 45 + let app = app::new() 46 + .await? 47 + .layer(tower_http::trace::TraceLayer::new_for_http()); 48 + 49 + let listener = tokio::net::TcpListener::bind((host, port)).await?; 50 + axum::serve(listener, app).await?; 51 + Ok(()) 52 + }
+21
packages/cartography/src/test.rs
··· 1 + use http_body_util::BodyExt; 2 + 3 + pub trait ResponseExt { 4 + async fn json<T: serde::de::DeserializeOwned>(self) -> anyhow::Result<T>; 5 + } 6 + 7 + impl ResponseExt for axum::response::Response { 8 + async fn json<T: serde::de::DeserializeOwned>(self) -> anyhow::Result<T> { 9 + Ok(serde_json::from_slice( 10 + &self.into_body().collect().await.unwrap().to_bytes(), 11 + )?) 12 + } 13 + } 14 + 15 + pub mod prelude { 16 + pub use super::ResponseExt as _; 17 + pub use tower::ServiceExt as _; 18 + 19 + pub const MIGRATOR: sqlx::migrate::Migrator = 20 + cartography_macros::graphile_migrate!("migrations/committed"); 21 + }
+1 -1
src/actor/field_state/mod.rs packages/cartography/src/actor/field_state/mod.rs
··· 1 - use super::player_socket::Response; 2 1 use super::Unsubscribe; 2 + use super::player_socket::Response; 3 3 use kameo::prelude::*; 4 4 use serde::{Deserialize, Serialize}; 5 5 use sqlx::PgPool;
src/actor/mod.rs packages/cartography/src/actor/mod.rs
src/actor/player_socket/authenticate.rs packages/cartography/src/actor/player_socket/authenticate.rs
+1 -1
src/actor/player_socket/mod.rs packages/cartography/src/actor/player_socket/mod.rs
··· 8 8 use serde::{Deserialize, Serialize}; 9 9 use sqlx::PgPool; 10 10 use std::collections::HashMap; 11 - use tokio::sync::mpsc::{unbounded_channel, UnboundedSender}; 11 + use tokio::sync::mpsc::{UnboundedSender, unbounded_channel}; 12 12 use tokio_stream::wrappers::UnboundedReceiverStream; 13 13 use uuid::Uuid; 14 14
src/actor/player_socket/unsubscribe.rs packages/cartography/src/actor/player_socket/unsubscribe.rs
+1 -1
src/actor/player_socket/watch_field.rs packages/cartography/src/actor/player_socket/watch_field.rs
··· 1 - use super::super::field_state::FieldWatcher; 2 1 use super::super::Unsubscribe; 2 + use super::super::field_state::FieldWatcher; 3 3 use super::{PlayerSocket, Response}; 4 4 use kameo::actor::Spawn; 5 5 use tokio::sync::mpsc::UnboundedSender;
src/api/errors/banner_not_found.rs packages/cartography/src/api/errors/banner_not_found.rs
src/api/errors/mod.rs packages/cartography/src/api/errors/mod.rs
src/api/mod.rs packages/cartography/src/api/mod.rs
+2 -2
src/api/operations/get_banner.rs packages/cartography/src/api/operations/get_banner.rs
··· 1 1 use crate::api::errors::{ 2 - internal_server_error, BannerNotFoundError, ErrorDetailResponse, JsonError, 2 + BannerNotFoundError, ErrorDetailResponse, JsonError, internal_server_error, 3 3 }; 4 4 use crate::dto::*; 5 - use axum::extract::Path; 6 5 use axum::Json; 6 + use axum::extract::Path; 7 7 8 8 #[derive(serde::Serialize, utoipa::ToSchema)] 9 9 pub struct GetBannerResponse {
src/api/operations/list_banners.rs packages/cartography/src/api/operations/list_banners.rs
src/api/operations/list_card_types.rs packages/cartography/src/api/operations/list_card_types.rs
+1 -1
src/api/operations/list_fields.rs packages/cartography/src/api/operations/list_fields.rs
··· 1 - use axum::extract::Path; 2 1 use axum::Json; 2 + use axum::extract::Path; 3 3 4 4 use crate::api::errors::{internal_server_error, respond_internal_server_error}; 5 5 use crate::dto::*;
src/api/operations/mod.rs packages/cartography/src/api/operations/mod.rs
+34 -3
src/api/operations/pull_banner.rs packages/cartography/src/api/operations/pull_banner.rs
··· 1 1 use crate::api::errors::{ 2 - internal_server_error, BannerNotFoundError, ErrorDetailResponse, JsonError, 2 + BannerNotFoundError, ErrorDetailResponse, JsonError, internal_server_error, 3 3 }; 4 4 use crate::db::CardClass; 5 5 use crate::dto::*; 6 - use axum::extract::Path; 7 6 use axum::Json; 7 + use axum::extract::Path; 8 8 use rand::distr::weighted::WeightedIndex; 9 9 use rand::rngs::Xoshiro256PlusPlus; 10 - use rand::{rng, Rng, RngExt, SeedableRng}; 10 + use rand::{Rng, RngExt, SeedableRng, rng}; 11 11 12 12 #[derive(serde::Serialize, utoipa::ToSchema)] 13 + #[cfg_attr(test, derive(serde::Deserialize))] 13 14 pub struct PullBannerResponse { 14 15 pack: Pack, 15 16 pack_cards: Vec<Card>, ··· 173 174 .0, 174 175 })) 175 176 } 177 + 178 + #[cfg(test)] 179 + mod tests { 180 + use crate::test::prelude::*; 181 + use axum::body::Body; 182 + use axum::http::Request; 183 + use sqlx::PgPool; 184 + 185 + use super::PullBannerResponse; 186 + 187 + #[sqlx::test( 188 + migrator = "MIGRATOR", 189 + fixtures(path = "../../../fixtures", scripts("seed", "account")) 190 + )] 191 + async fn pull_banner_standard(pool: PgPool) { 192 + let app = crate::app::Config::test(pool).into_router(); 193 + 194 + let request = Request::post("/api/v1/banners/base-standard/pull") 195 + .body(Body::empty()) 196 + .unwrap(); 197 + 198 + let Ok(response) = app.oneshot(request).await; 199 + assert!(response.status().is_success(), "{}", response.json::<serde_json::Value>().await.unwrap()); 200 + let response: PullBannerResponse = response.json().await.unwrap(); 201 + assert_eq!(response.pack.pack_banner_id, "base-standard"); 202 + assert_eq!(response.pack.account_id, "foxfriends"); 203 + assert_eq!(response.pack.opened_at, None); 204 + assert_eq!(response.pack_cards.len(), 5); 205 + } 206 + }
+1 -1
src/api/ws.rs packages/cartography/src/api/ws.rs
··· 1 1 use crate::actor::player_socket::{PlayerSocket, Request, Response}; 2 - use axum::extract::ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade}; 3 2 use axum::Extension; 3 + use axum::extract::ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade}; 4 4 use futures::StreamExt; 5 5 use kameo::prelude::*; 6 6 use serde::{Deserialize, Serialize};
src/bus.rs packages/cartography/src/bus.rs
src/db.rs packages/cartography/src/db.rs
src/dto/mod.rs packages/cartography/src/dto/mod.rs
-111
src/main.rs
··· 1 - mod actor; 2 - mod api; 3 - mod bus; 4 - mod db; 5 - mod dto; 6 - 7 - use clap::Parser as _; 8 - use kameo::actor::Spawn as _; 9 - use tracing_subscriber::prelude::*; 10 - use utoipa::OpenApi as _; 11 - 12 - #[derive(utoipa::OpenApi)] 13 - #[openapi( 14 - paths( 15 - api::operations::get_banner, 16 - api::operations::list_banners, 17 - api::operations::pull_banner, 18 - 19 - api::operations::list_card_types, 20 - 21 - api::operations::list_fields, 22 - ), 23 - components( 24 - schemas( 25 - crate::dto::AccountIdOrMe 26 - ) 27 - ), 28 - tags( 29 - (name = "Global", description = "Publicly available global data about the Cartography game."), 30 - (name = "Player", description = "Player specific data; typically requires authorization."), 31 - (name = "Game", description = "Actions with effects on gameplay."), 32 - ), 33 - )] 34 - struct ApiDoc; 35 - 36 - #[derive(clap::Parser)] 37 - struct Args { 38 - #[clap(long)] 39 - openapi: bool, 40 - } 41 - 42 - #[tokio::main] 43 - async fn main() -> anyhow::Result<()> { 44 - let args = Args::parse(); 45 - 46 - if args.openapi { 47 - println!("{}", ApiDoc::openapi().to_pretty_json().unwrap()); 48 - std::process::exit(0); 49 - } 50 - 51 - tracing_subscriber::registry() 52 - .with(tracing_subscriber::EnvFilter::from_default_env()) 53 - .with(tracing_subscriber::fmt::layer().pretty()) 54 - .init(); 55 - 56 - let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL is required"); 57 - let host: std::net::IpAddr = std::env::var("HOST") 58 - .as_deref() 59 - .unwrap_or("0.0.0.0") 60 - .parse() 61 - .expect("HOST must be a valid IP address"); 62 - let port = std::env::var("PORT") 63 - .as_deref() 64 - .unwrap_or("12000") 65 - .parse() 66 - .expect("PORT must be a valid u16"); 67 - let pool = sqlx::postgres::PgPoolOptions::new() 68 - .max_connections(10) 69 - .connect(&db_url) 70 - .await?; 71 - 72 - // let scalar_config = serde_json::json!({ 73 - // "url": "/api/openapi.json", 74 - // "agent": scalar_api_reference::config::AgentOptions::disabled() 75 - // }); 76 - 77 - let bus = bus::Bus::spawn(()); 78 - let app = axum::Router::new() 79 - .route( 80 - "/api/v1/cardtypes", 81 - axum::routing::get(api::operations::list_card_types), 82 - ) 83 - .route( 84 - "/api/v1/banners", 85 - axum::routing::post(api::operations::list_banners), 86 - ) 87 - .route( 88 - "/api/v1/banners/{banner_id}", 89 - axum::routing::get(api::operations::get_banner), 90 - ) 91 - .route( 92 - "/api/v1/banners/{banner_id}/pull", 93 - axum::routing::post(api::operations::pull_banner), 94 - ) 95 - .route( 96 - "/api/v1/players/{player_id}/fields", 97 - axum::routing::get(api::operations::list_fields), 98 - ) 99 - .route("/play/ws", axum::routing::any(api::ws::v1)) 100 - .route( 101 - "/api/openapi.json", 102 - axum::routing::get(axum::response::Json(ApiDoc::openapi())), 103 - ) 104 - // .merge(scalar_api_reference::axum::router("/docs", &scalar_config)) // TODO: waiting on scalar_api_reference to re-publish 105 - .layer(axum::Extension(bus)) 106 - .layer(axum::Extension(pool)) 107 - .layer(tower_http::trace::TraceLayer::new_for_http()); 108 - let listener = tokio::net::TcpListener::bind((host, port)).await?; 109 - axum::serve(listener, app).await?; 110 - Ok(()) 111 - }