Trading card city builder game?

add pull banner api

eldridge.cam f781e71e 147f674f

verified
+604 -25
+60
.sqlx/query-6f75a49a622c23453b9ba57afaecb7e26b6421ba6ae2c3b78fa194ebafd94033.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n WITH inserted_pack AS (\n INSERT INTO packs (account_id, pack_banner_id, seed, algorithm)\n VALUES ($1, $2, $3, 'xoshiro256++')\n RETURNING id, account_id, pack_banner_id, opened_at\n ),\n\n inserted_tile_cards AS (\n INSERT INTO cards (card_type_id)\n VALUES (UNNEST($4::text []))\n RETURNING *\n ),\n\n inserted_tiles AS (\n INSERT INTO tiles (id, tile_type_id, name)\n SELECT id, card_type_id, card_type_id\n FROM inserted_tile_cards\n RETURNING id\n ),\n\n inserted_citizen_cards AS (\n INSERT INTO cards (card_type_id)\n VALUES (UNNEST($5::text []))\n RETURNING *\n ),\n\n inserted_citizens AS (\n INSERT INTO citizens (id, species_id, name)\n SELECT id, card_type_id, card_type_id\n FROM inserted_citizen_cards\n RETURNING id\n ),\n\n inserted_cards AS (\n SELECT * FROM inserted_tile_cards\n UNION ALL\n SELECT * FROM inserted_citizen_cards\n ),\n\n inserted_pack_contents AS (\n INSERT INTO pack_contents (pack_id, \"position\", card_id)\n SELECT inserted_pack.id, ROW_NUMBER() OVER (), inserted_cards.id\n FROM inserted_pack, inserted_cards\n RETURNING *\n )\n\n SELECT\n inserted_pack.*,\n JSONB_AGG(\n JSONB_BUILD_OBJECT(\n 'id', inserted_cards.id,\n 'card_type_id', inserted_cards.card_type_id\n )\n ) AS \"pack_cards: sqlx::types::Json<Vec<Card>>\"\n FROM inserted_pack, inserted_cards\n GROUP BY\n inserted_pack.id,\n inserted_pack.account_id,\n inserted_pack.pack_banner_id,\n inserted_pack.opened_at\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Int8" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "account_id", 14 + "type_info": { 15 + "Custom": { 16 + "name": "citext", 17 + "kind": "Simple" 18 + } 19 + } 20 + }, 21 + { 22 + "ordinal": 2, 23 + "name": "pack_banner_id", 24 + "type_info": "Text" 25 + }, 26 + { 27 + "ordinal": 3, 28 + "name": "opened_at", 29 + "type_info": "Timestamptz" 30 + }, 31 + { 32 + "ordinal": 4, 33 + "name": "pack_cards: sqlx::types::Json<Vec<Card>>", 34 + "type_info": "Jsonb" 35 + } 36 + ], 37 + "parameters": { 38 + "Left": [ 39 + { 40 + "Custom": { 41 + "name": "citext", 42 + "kind": "Simple" 43 + } 44 + }, 45 + "Text", 46 + "Int8", 47 + "TextArray", 48 + "TextArray" 49 + ] 50 + }, 51 + "nullable": [ 52 + false, 53 + false, 54 + false, 55 + true, 56 + null 57 + ] 58 + }, 59 + "hash": "6f75a49a622c23453b9ba57afaecb7e26b6421ba6ae2c3b78fa194ebafd94033" 60 + }
+22
.sqlx/query-a9b6f5683c626b4c84579e5df711576729a49894833ef10fe016cea566deef9a.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT pack_size FROM pack_banners WHERE id = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "pack_size", 9 + "type_info": "Int4" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "a9b6f5683c626b4c84579e5df711576729a49894833ef10fe016cea566deef9a" 22 + }
+44
.sqlx/query-c03325e5cf8c7bfe229570820de993fbe696cafe6cc06db0657769698b6d848b.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT card_type_id, frequency, class AS \"class: CardClass\"\n FROM pack_banner_cards\n INNER JOIN card_types ON card_types.id = pack_banner_cards.card_type_id\n WHERE pack_banner_cards.pack_banner_id = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "card_type_id", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "frequency", 14 + "type_info": "Int4" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "class: CardClass", 19 + "type_info": { 20 + "Custom": { 21 + "name": "card_class", 22 + "kind": { 23 + "Enum": [ 24 + "tile", 25 + "citizen" 26 + ] 27 + } 28 + } 29 + } 30 + } 31 + ], 32 + "parameters": { 33 + "Left": [ 34 + "Text" 35 + ] 36 + }, 37 + "nullable": [ 38 + false, 39 + false, 40 + false 41 + ] 42 + }, 43 + "hash": "c03325e5cf8c7bfe229570820de993fbe696cafe6cc06db0657769698b6d848b" 44 + }
+201 -2
Cargo.lock
··· 298 298 "futures-rx", 299 299 "json-patch", 300 300 "kameo", 301 + "rand 0.10.0", 301 302 "rmp-serde", 302 303 "scalar_api_reference", 303 304 "serde", ··· 318 319 version = "1.0.4" 319 320 source = "registry+https://github.com/rust-lang/crates.io-index" 320 321 checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 322 + 323 + [[package]] 324 + name = "chacha20" 325 + version = "0.10.0" 326 + source = "registry+https://github.com/rust-lang/crates.io-index" 327 + checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" 328 + dependencies = [ 329 + "cfg-if", 330 + "cpufeatures 0.3.0", 331 + "rand_core 0.10.0", 332 + ] 321 333 322 334 [[package]] 323 335 name = "clap" ··· 394 406 version = "0.2.17" 395 407 source = "registry+https://github.com/rust-lang/crates.io-index" 396 408 checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" 409 + dependencies = [ 410 + "libc", 411 + ] 412 + 413 + [[package]] 414 + name = "cpufeatures" 415 + version = "0.3.0" 416 + source = "registry+https://github.com/rust-lang/crates.io-index" 417 + checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" 397 418 dependencies = [ 398 419 "libc", 399 420 ] ··· 762 783 ] 763 784 764 785 [[package]] 786 + name = "getrandom" 787 + version = "0.4.1" 788 + source = "registry+https://github.com/rust-lang/crates.io-index" 789 + checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" 790 + dependencies = [ 791 + "cfg-if", 792 + "libc", 793 + "r-efi", 794 + "rand_core 0.10.0", 795 + "wasip2", 796 + "wasip3", 797 + ] 798 + 799 + [[package]] 765 800 name = "hashbrown" 766 801 version = "0.15.5" 767 802 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 989 1024 ] 990 1025 991 1026 [[package]] 1027 + name = "id-arena" 1028 + version = "2.3.0" 1029 + source = "registry+https://github.com/rust-lang/crates.io-index" 1030 + checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" 1031 + 1032 + [[package]] 992 1033 name = "idna" 993 1034 version = "1.1.0" 994 1035 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1101 1142 dependencies = [ 1102 1143 "spin", 1103 1144 ] 1145 + 1146 + [[package]] 1147 + name = "leb128fmt" 1148 + version = "0.1.0" 1149 + source = "registry+https://github.com/rust-lang/crates.io-index" 1150 + checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" 1104 1151 1105 1152 [[package]] 1106 1153 name = "libc" ··· 1414 1461 ] 1415 1462 1416 1463 [[package]] 1464 + name = "prettyplease" 1465 + version = "0.2.37" 1466 + source = "registry+https://github.com/rust-lang/crates.io-index" 1467 + checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" 1468 + dependencies = [ 1469 + "proc-macro2", 1470 + "syn", 1471 + ] 1472 + 1473 + [[package]] 1417 1474 name = "proc-macro2" 1418 1475 version = "1.0.106" 1419 1476 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1459 1516 ] 1460 1517 1461 1518 [[package]] 1519 + name = "rand" 1520 + version = "0.10.0" 1521 + source = "registry+https://github.com/rust-lang/crates.io-index" 1522 + checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" 1523 + dependencies = [ 1524 + "chacha20", 1525 + "getrandom 0.4.1", 1526 + "rand_core 0.10.0", 1527 + ] 1528 + 1529 + [[package]] 1462 1530 name = "rand_chacha" 1463 1531 version = "0.3.1" 1464 1532 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1495 1563 dependencies = [ 1496 1564 "getrandom 0.3.4", 1497 1565 ] 1566 + 1567 + [[package]] 1568 + name = "rand_core" 1569 + version = "0.10.0" 1570 + source = "registry+https://github.com/rust-lang/crates.io-index" 1571 + checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" 1498 1572 1499 1573 [[package]] 1500 1574 name = "redox_syscall" ··· 1732 1806 checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" 1733 1807 dependencies = [ 1734 1808 "cfg-if", 1735 - "cpufeatures", 1809 + "cpufeatures 0.2.17", 1736 1810 "digest", 1737 1811 ] 1738 1812 ··· 1743 1817 checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" 1744 1818 dependencies = [ 1745 1819 "cfg-if", 1746 - "cpufeatures", 1820 + "cpufeatures 0.2.17", 1747 1821 "digest", 1748 1822 ] 1749 1823 ··· 2530 2604 ] 2531 2605 2532 2606 [[package]] 2607 + name = "wasip3" 2608 + version = "0.4.0+wasi-0.3.0-rc-2026-01-06" 2609 + source = "registry+https://github.com/rust-lang/crates.io-index" 2610 + checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" 2611 + dependencies = [ 2612 + "wit-bindgen", 2613 + ] 2614 + 2615 + [[package]] 2533 2616 name = "wasite" 2534 2617 version = "0.1.0" 2535 2618 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2581 2664 ] 2582 2665 2583 2666 [[package]] 2667 + name = "wasm-encoder" 2668 + version = "0.244.0" 2669 + source = "registry+https://github.com/rust-lang/crates.io-index" 2670 + checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" 2671 + dependencies = [ 2672 + "leb128fmt", 2673 + "wasmparser", 2674 + ] 2675 + 2676 + [[package]] 2677 + name = "wasm-metadata" 2678 + version = "0.244.0" 2679 + source = "registry+https://github.com/rust-lang/crates.io-index" 2680 + checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" 2681 + dependencies = [ 2682 + "anyhow", 2683 + "indexmap", 2684 + "wasm-encoder", 2685 + "wasmparser", 2686 + ] 2687 + 2688 + [[package]] 2689 + name = "wasmparser" 2690 + version = "0.244.0" 2691 + source = "registry+https://github.com/rust-lang/crates.io-index" 2692 + checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" 2693 + dependencies = [ 2694 + "bitflags", 2695 + "hashbrown 0.15.5", 2696 + "indexmap", 2697 + "semver", 2698 + ] 2699 + 2700 + [[package]] 2584 2701 name = "whoami" 2585 2702 version = "1.6.1" 2586 2703 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2759 2876 version = "0.51.0" 2760 2877 source = "registry+https://github.com/rust-lang/crates.io-index" 2761 2878 checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" 2879 + dependencies = [ 2880 + "wit-bindgen-rust-macro", 2881 + ] 2882 + 2883 + [[package]] 2884 + name = "wit-bindgen-core" 2885 + version = "0.51.0" 2886 + source = "registry+https://github.com/rust-lang/crates.io-index" 2887 + checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" 2888 + dependencies = [ 2889 + "anyhow", 2890 + "heck", 2891 + "wit-parser", 2892 + ] 2893 + 2894 + [[package]] 2895 + name = "wit-bindgen-rust" 2896 + version = "0.51.0" 2897 + source = "registry+https://github.com/rust-lang/crates.io-index" 2898 + checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" 2899 + dependencies = [ 2900 + "anyhow", 2901 + "heck", 2902 + "indexmap", 2903 + "prettyplease", 2904 + "syn", 2905 + "wasm-metadata", 2906 + "wit-bindgen-core", 2907 + "wit-component", 2908 + ] 2909 + 2910 + [[package]] 2911 + name = "wit-bindgen-rust-macro" 2912 + version = "0.51.0" 2913 + source = "registry+https://github.com/rust-lang/crates.io-index" 2914 + checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" 2915 + dependencies = [ 2916 + "anyhow", 2917 + "prettyplease", 2918 + "proc-macro2", 2919 + "quote", 2920 + "syn", 2921 + "wit-bindgen-core", 2922 + "wit-bindgen-rust", 2923 + ] 2924 + 2925 + [[package]] 2926 + name = "wit-component" 2927 + version = "0.244.0" 2928 + source = "registry+https://github.com/rust-lang/crates.io-index" 2929 + checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" 2930 + dependencies = [ 2931 + "anyhow", 2932 + "bitflags", 2933 + "indexmap", 2934 + "log", 2935 + "serde", 2936 + "serde_derive", 2937 + "serde_json", 2938 + "wasm-encoder", 2939 + "wasm-metadata", 2940 + "wasmparser", 2941 + "wit-parser", 2942 + ] 2943 + 2944 + [[package]] 2945 + name = "wit-parser" 2946 + version = "0.244.0" 2947 + source = "registry+https://github.com/rust-lang/crates.io-index" 2948 + checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" 2949 + dependencies = [ 2950 + "anyhow", 2951 + "id-arena", 2952 + "indexmap", 2953 + "log", 2954 + "semver", 2955 + "serde", 2956 + "serde_derive", 2957 + "serde_json", 2958 + "unicode-xid", 2959 + "wasmparser", 2960 + ] 2762 2961 2763 2962 [[package]] 2764 2963 name = "writeable"
+1
Cargo.toml
··· 19 19 futures-rx = "0.2.1" 20 20 json-patch = { version = "4.1.0", features = ["utoipa"] } 21 21 kameo = "0.19.2" 22 + rand = { version = "0.10.0", features = ["alloc"] } 22 23 rmp-serde = "1.3.1" 23 24 scalar_api_reference = { version = "0.1.0", features = ["axum"] } 24 25 serde = { version = "1.0.228", features = ["derive"] }
+18 -1
generated/schema.sql
··· 283 283 id text NOT NULL, 284 284 start_date timestamp with time zone NOT NULL, 285 285 end_date timestamp with time zone, 286 + pack_size integer DEFAULT 5 NOT NULL, 286 287 CONSTRAINT pack_banners_id_check CHECK (((0 < length(id)) AND (length(id) <= 64))) 287 288 ); 288 289 ··· 320 321 id bigint NOT NULL, 321 322 account_id public.citext NOT NULL, 322 323 pack_banner_id text NOT NULL, 323 - opened_at timestamp with time zone 324 + opened_at timestamp with time zone, 325 + seed bigint NOT NULL, 326 + algorithm text NOT NULL 324 327 ); 325 328 326 329 ··· 329 332 -- 330 333 331 334 COMMENT ON TABLE public.packs IS 'A historical record of all packs opened by an account.'; 335 + 336 + 337 + -- 338 + -- Name: COLUMN packs.seed; Type: COMMENT; Schema: public; Owner: - 339 + -- 340 + 341 + COMMENT ON COLUMN public.packs.seed IS 'The u64 seed used to generate this pack. It is cast to i64 and stored here; interpret as raw bytes not meaningful number.'; 342 + 343 + 344 + -- 345 + -- Name: COLUMN packs.algorithm; Type: COMMENT; Schema: public; Owner: - 346 + -- 347 + 348 + COMMENT ON COLUMN public.packs.algorithm IS 'The seedable random number generation algorithm used to generate this pack.'; 332 349 333 350 334 351 --
+11
migrations/committed/000015.sql
··· 1 + --! Previous: sha1:e1df9dc29a55d976f7f130d613fc1ba172e2df1b 2 + --! Hash: sha1:2aab3a026b4d550dd38394a42fbb742817b3eae0 3 + 4 + ALTER TABLE packs DROP COLUMN IF EXISTS seed, DROP COLUMN IF EXISTS algorithm; 5 + ALTER TABLE packs ADD COLUMN seed BIGINT NOT NULL, ADD COLUMN algorithm TEXT NOT NULL; 6 + 7 + COMMENT ON COLUMN packs.seed IS 'The u64 seed used to generate this pack. It is cast to i64 and stored here; interpret as raw bytes not meaningful number.'; 8 + COMMENT ON COLUMN packs.algorithm IS 'The seedable random number generation algorithm used to generate this pack.'; 9 + 10 + ALTER TABLE pack_banners DROP COLUMN IF EXISTS pack_size; 11 + ALTER TABLE pack_banners ADD COLUMN pack_size INT NOT NULL DEFAULT 5;
+4
src/api/error.rs src/api/errors/mod.rs
··· 4 4 use serde_json::Value; 5 5 use std::error::Error; 6 6 7 + mod banner_not_found; 8 + 9 + pub use banner_not_found::BannerNotFoundError; 10 + 7 11 pub trait ApiError: Error { 8 12 const STATUS: StatusCode; 9 13 const CODE: &str;
+16
src/api/errors/banner_not_found.rs
··· 1 + use super::ApiError; 2 + use axum::http::StatusCode; 3 + 4 + #[derive(Debug, derive_more::Display, derive_more::Error)] 5 + #[display("a banner with id {banner_id} was not found")] 6 + pub struct BannerNotFoundError { 7 + pub banner_id: String, 8 + } 9 + 10 + impl ApiError for BannerNotFoundError { 11 + const STATUS: StatusCode = StatusCode::NOT_FOUND; 12 + const CODE: &str = "BannerNotFound"; 13 + type Detail = (); 14 + 15 + fn detail(&self) -> Self::Detail {} 16 + }
+1 -1
src/api/mod.rs
··· 1 - mod error; 1 + mod errors; 2 2 3 3 pub mod operations; 4 4 pub mod ws;
+3 -16
src/api/operations/get_banner.rs
··· 1 - use crate::api::error::{internal_server_error, ApiError, ErrorDetailResponse, JsonError}; 1 + use crate::api::errors::{ 2 + internal_server_error, BannerNotFoundError, ErrorDetailResponse, JsonError, 3 + }; 2 4 use crate::dto::*; 3 5 use axum::extract::Path; 4 - use axum::http::StatusCode; 5 6 use axum::Json; 6 7 7 8 #[derive(serde::Serialize, utoipa::ToSchema)] 8 9 pub struct GetBannerResponse { 9 10 banner: PackBanner, 10 11 banner_cards: Vec<PackBannerCard>, 11 - } 12 - 13 - #[derive(Debug, derive_more::Display, derive_more::Error)] 14 - #[display("a banner with id {banner_id} was not found")] 15 - pub struct BannerNotFoundError { 16 - banner_id: String, 17 - } 18 - 19 - impl ApiError for BannerNotFoundError { 20 - const STATUS: StatusCode = StatusCode::NOT_FOUND; 21 - const CODE: &str = "BannerNotFound"; 22 - type Detail = (); 23 - 24 - fn detail(&self) -> Self::Detail {} 25 12 } 26 13 27 14 #[utoipa::path(
+1 -1
src/api/operations/list_banners.rs
··· 1 - use crate::api::error::internal_server_error; 1 + use crate::api::errors::internal_server_error; 2 2 use crate::dto::*; 3 3 use axum::Json; 4 4
+1 -1
src/api/operations/list_card_types.rs
··· 1 - use crate::api::error::internal_server_error; 1 + use crate::api::errors::internal_server_error; 2 2 use crate::dto::*; 3 3 use axum::Json; 4 4
+1 -1
src/api/operations/list_fields.rs
··· 1 1 use axum::extract::Path; 2 2 use axum::Json; 3 3 4 - use crate::api::error::{internal_server_error, respond_internal_server_error}; 4 + use crate::api::errors::{internal_server_error, respond_internal_server_error}; 5 5 use crate::dto::*; 6 6 7 7 #[derive(serde::Serialize, utoipa::ToSchema)]
+2
src/api/operations/mod.rs
··· 1 1 mod get_banner; 2 2 mod list_banners; 3 + mod pull_banner; 3 4 pub use get_banner::*; 4 5 pub use list_banners::*; 6 + pub use pull_banner::*; 5 7 6 8 mod list_card_types; 7 9 pub use list_card_types::*;
+175
src/api/operations/pull_banner.rs
··· 1 + use crate::api::errors::{ 2 + internal_server_error, BannerNotFoundError, ErrorDetailResponse, JsonError, 3 + }; 4 + use crate::db::CardClass; 5 + use crate::dto::*; 6 + use axum::extract::Path; 7 + use axum::Json; 8 + use rand::distr::weighted::WeightedIndex; 9 + use rand::rngs::Xoshiro256PlusPlus; 10 + use rand::{rng, Rng, RngExt, SeedableRng}; 11 + 12 + #[derive(serde::Serialize, utoipa::ToSchema)] 13 + pub struct PullBannerResponse { 14 + pack: Pack, 15 + pack_cards: Vec<Card>, 16 + } 17 + 18 + #[utoipa::path( 19 + post, 20 + path = "/api/v1/banners/{banner_id}/pull", 21 + description = "Get full pack banner details.", 22 + tag = "Game", 23 + params( 24 + ("banner_id" = String, Path, description = "Banner ID"), 25 + ), 26 + responses( 27 + (status = OK, description = "The created pack after pulling the banner.", body = PullBannerResponse), 28 + (status = NOT_FOUND, description = "Banner was not found", body = ErrorDetailResponse), 29 + ), 30 + )] 31 + pub async fn pull_banner( 32 + db: axum::Extension<sqlx::PgPool>, 33 + Path(banner_id): Path<String>, 34 + ) -> axum::response::Result<Json<PullBannerResponse>> { 35 + let mut conn = db.begin().await.map_err(internal_server_error)?; 36 + 37 + let banner = sqlx::query!( 38 + "SELECT pack_size FROM pack_banners WHERE id = $1", 39 + banner_id 40 + ) 41 + .fetch_optional(&mut *conn) 42 + .await 43 + .map_err(internal_server_error)? 44 + .ok_or_else(|| { 45 + JsonError(BannerNotFoundError { 46 + banner_id: banner_id.clone(), 47 + }) 48 + })?; 49 + 50 + let pack_banner_cards = sqlx::query!( 51 + r#" 52 + SELECT card_type_id, frequency, class AS "class: CardClass" 53 + FROM pack_banner_cards 54 + INNER JOIN card_types ON card_types.id = pack_banner_cards.card_type_id 55 + WHERE pack_banner_cards.pack_banner_id = $1 56 + "#, 57 + banner_id 58 + ) 59 + .fetch_all(&mut *conn) 60 + .await 61 + .map_err(internal_server_error)?; 62 + 63 + if pack_banner_cards.is_empty() { 64 + return Err(JsonError(BannerNotFoundError { banner_id }).into()); 65 + } 66 + 67 + let seed = rng().next_u64(); 68 + let pack_generator = Xoshiro256PlusPlus::seed_from_u64(seed); 69 + let distribution = 70 + WeightedIndex::new(pack_banner_cards.iter().map(|card| card.frequency)).unwrap(); 71 + 72 + let (tile_types, species): (Vec<_>, Vec<_>) = pack_generator 73 + .sample_iter(distribution) 74 + .take(banner.pack_size as usize) 75 + .map(|index| &pack_banner_cards[index]) 76 + .partition(|card| card.class == CardClass::Tile); 77 + // TODO[v1]: name generation for these things, for now the name is the ID (i18n-key) 78 + let tile_type_ids: Vec<_> = tile_types 79 + .into_iter() 80 + .map(|card| &card.card_type_id) 81 + .cloned() 82 + .collect(); 83 + let species_ids: Vec<_> = species 84 + .into_iter() 85 + .map(|card| &card.card_type_id) 86 + .cloned() 87 + .collect(); 88 + 89 + let pack = sqlx::query!( 90 + r#" 91 + WITH inserted_pack AS ( 92 + INSERT INTO packs (account_id, pack_banner_id, seed, algorithm) 93 + VALUES ($1, $2, $3, 'xoshiro256++') 94 + RETURNING id, account_id, pack_banner_id, opened_at 95 + ), 96 + 97 + inserted_tile_cards AS ( 98 + INSERT INTO cards (card_type_id) 99 + VALUES (UNNEST($4::text [])) 100 + RETURNING * 101 + ), 102 + 103 + inserted_tiles AS ( 104 + INSERT INTO tiles (id, tile_type_id, name) 105 + SELECT id, card_type_id, card_type_id 106 + FROM inserted_tile_cards 107 + RETURNING id 108 + ), 109 + 110 + inserted_citizen_cards AS ( 111 + INSERT INTO cards (card_type_id) 112 + VALUES (UNNEST($5::text [])) 113 + RETURNING * 114 + ), 115 + 116 + inserted_citizens AS ( 117 + INSERT INTO citizens (id, species_id, name) 118 + SELECT id, card_type_id, card_type_id 119 + FROM inserted_citizen_cards 120 + RETURNING id 121 + ), 122 + 123 + inserted_cards AS ( 124 + SELECT * FROM inserted_tile_cards 125 + UNION ALL 126 + SELECT * FROM inserted_citizen_cards 127 + ), 128 + 129 + inserted_pack_contents AS ( 130 + INSERT INTO pack_contents (pack_id, "position", card_id) 131 + SELECT inserted_pack.id, ROW_NUMBER() OVER (), inserted_cards.id 132 + FROM inserted_pack, inserted_cards 133 + RETURNING * 134 + ) 135 + 136 + SELECT 137 + inserted_pack.*, 138 + JSONB_AGG( 139 + JSONB_BUILD_OBJECT( 140 + 'id', inserted_cards.id, 141 + 'card_type_id', inserted_cards.card_type_id 142 + ) 143 + ) AS "pack_cards: sqlx::types::Json<Vec<Card>>" 144 + FROM inserted_pack, inserted_cards 145 + GROUP BY 146 + inserted_pack.id, 147 + inserted_pack.account_id, 148 + inserted_pack.pack_banner_id, 149 + inserted_pack.opened_at 150 + "#, 151 + "foxfriends", 152 + banner_id, 153 + seed as i64, 154 + &tile_type_ids, 155 + &species_ids, 156 + ) 157 + .fetch_one(&mut *conn) 158 + .await 159 + .map_err(internal_server_error)?; 160 + 161 + conn.commit().await.map_err(internal_server_error)?; 162 + 163 + Ok(Json(PullBannerResponse { 164 + pack: Pack { 165 + id: pack.id, 166 + account_id: pack.account_id, 167 + pack_banner_id: pack.pack_banner_id, 168 + opened_at: pack.opened_at, 169 + }, 170 + pack_cards: pack 171 + .pack_cards 172 + .expect("pack cards should have been correct JSON") 173 + .0, 174 + })) 175 + }
-1
src/db.rs
··· 4 4 #[derive(sqlx::Type)] 5 5 #[sqlx(type_name = "card_class", rename_all = "lowercase")] 6 6 #[derive(Serialize, Deserialize, PartialEq, Eq, Hash, Clone, Copy, Debug, ToSchema)] 7 - #[expect(dead_code)] 8 7 pub enum CardClass { 9 8 Tile, 10 9 Citizen,
+36
src/dto/mod.rs
··· 56 56 pub card_type_id: String, 57 57 pub frequency: u32, 58 58 } 59 + 60 + #[derive(PartialEq, Clone, Debug, Serialize, Deserialize, ToSchema)] 61 + pub struct Pack { 62 + pub id: i64, 63 + pub account_id: String, 64 + pub pack_banner_id: String, 65 + pub opened_at: Option<OffsetDateTime>, 66 + } 67 + 68 + #[derive(PartialEq, Clone, Debug, Serialize, Deserialize, ToSchema)] 69 + pub struct Card { 70 + pub id: i64, 71 + pub card_type_id: String, 72 + } 73 + 74 + #[derive(PartialEq, Clone, Debug, Serialize, Deserialize, ToSchema)] 75 + #[serde(tag = "class")] 76 + #[expect(dead_code)] 77 + pub enum CardData { 78 + #[serde(rename = "Tile")] 79 + Tile(Tile), 80 + #[serde(rename = "Citizen")] 81 + Citizen(Citizen), 82 + } 83 + 84 + #[derive(PartialEq, Clone, Debug, Serialize, Deserialize, ToSchema)] 85 + pub struct Tile { 86 + pub tile_type_id: String, 87 + pub name: String, 88 + } 89 + 90 + #[derive(PartialEq, Clone, Debug, Serialize, Deserialize, ToSchema)] 91 + pub struct Citizen { 92 + pub species_id: String, 93 + pub name: String, 94 + }
+7 -1
src/main.rs
··· 14 14 paths( 15 15 api::operations::get_banner, 16 16 api::operations::list_banners, 17 + api::operations::pull_banner, 17 18 18 19 api::operations::list_card_types, 19 20 ··· 21 22 ), 22 23 tags( 23 24 (name = "Global", description = "Publicly available global data about the Cartography game."), 24 - (name = "Player", description = "Player specific data; typically requires authorization.") 25 + (name = "Player", description = "Player specific data; typically requires authorization."), 26 + (name = "Game", description = "Actions with effects on gameplay."), 25 27 ), 26 28 )] 27 29 struct ApiDoc; ··· 80 82 .route( 81 83 "/api/v1/banners/{banner_id}", 82 84 axum::routing::get(api::operations::get_banner), 85 + ) 86 + .route( 87 + "/api/v1/banners/{banner_id}/pull", 88 + axum::routing::post(api::operations::pull_banner), 83 89 ) 84 90 .route( 85 91 "/api/v1/players/{player_id}/fields",