···1919futures-rx = "0.2.1"
2020json-patch = { version = "4.1.0", features = ["utoipa"] }
2121kameo = "0.19.2"
2222+rand = { version = "0.10.0", features = ["alloc"] }
2223rmp-serde = "1.3.1"
2324scalar_api_reference = { version = "0.1.0", features = ["axum"] }
2425serde = { version = "1.0.228", features = ["derive"] }
+18-1
generated/schema.sql
···283283 id text NOT NULL,
284284 start_date timestamp with time zone NOT NULL,
285285 end_date timestamp with time zone,
286286+ pack_size integer DEFAULT 5 NOT NULL,
286287 CONSTRAINT pack_banners_id_check CHECK (((0 < length(id)) AND (length(id) <= 64)))
287288);
288289···320321 id bigint NOT NULL,
321322 account_id public.citext NOT NULL,
322323 pack_banner_id text NOT NULL,
323323- opened_at timestamp with time zone
324324+ opened_at timestamp with time zone,
325325+ seed bigint NOT NULL,
326326+ algorithm text NOT NULL
324327);
325328326329···329332--
330333331334COMMENT ON TABLE public.packs IS 'A historical record of all packs opened by an account.';
335335+336336+337337+--
338338+-- Name: COLUMN packs.seed; Type: COMMENT; Schema: public; Owner: -
339339+--
340340+341341+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.';
342342+343343+344344+--
345345+-- Name: COLUMN packs.algorithm; Type: COMMENT; Schema: public; Owner: -
346346+--
347347+348348+COMMENT ON COLUMN public.packs.algorithm IS 'The seedable random number generation algorithm used to generate this pack.';
332349333350334351--
+11
migrations/committed/000015.sql
···11+--! Previous: sha1:e1df9dc29a55d976f7f130d613fc1ba172e2df1b
22+--! Hash: sha1:2aab3a026b4d550dd38394a42fbb742817b3eae0
33+44+ALTER TABLE packs DROP COLUMN IF EXISTS seed, DROP COLUMN IF EXISTS algorithm;
55+ALTER TABLE packs ADD COLUMN seed BIGINT NOT NULL, ADD COLUMN algorithm TEXT NOT NULL;
66+77+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.';
88+COMMENT ON COLUMN packs.algorithm IS 'The seedable random number generation algorithm used to generate this pack.';
99+1010+ALTER TABLE pack_banners DROP COLUMN IF EXISTS pack_size;
1111+ALTER TABLE pack_banners ADD COLUMN pack_size INT NOT NULL DEFAULT 5;
···11mod get_banner;
22mod list_banners;
33+mod pull_banner;
34pub use get_banner::*;
45pub use list_banners::*;
66+pub use pull_banner::*;
5768mod list_card_types;
79pub use list_card_types::*;
+175
src/api/operations/pull_banner.rs
···11+use crate::api::errors::{
22+ internal_server_error, BannerNotFoundError, ErrorDetailResponse, JsonError,
33+};
44+use crate::db::CardClass;
55+use crate::dto::*;
66+use axum::extract::Path;
77+use axum::Json;
88+use rand::distr::weighted::WeightedIndex;
99+use rand::rngs::Xoshiro256PlusPlus;
1010+use rand::{rng, Rng, RngExt, SeedableRng};
1111+1212+#[derive(serde::Serialize, utoipa::ToSchema)]
1313+pub struct PullBannerResponse {
1414+ pack: Pack,
1515+ pack_cards: Vec<Card>,
1616+}
1717+1818+#[utoipa::path(
1919+ post,
2020+ path = "/api/v1/banners/{banner_id}/pull",
2121+ description = "Get full pack banner details.",
2222+ tag = "Game",
2323+ params(
2424+ ("banner_id" = String, Path, description = "Banner ID"),
2525+ ),
2626+ responses(
2727+ (status = OK, description = "The created pack after pulling the banner.", body = PullBannerResponse),
2828+ (status = NOT_FOUND, description = "Banner was not found", body = ErrorDetailResponse),
2929+ ),
3030+)]
3131+pub async fn pull_banner(
3232+ db: axum::Extension<sqlx::PgPool>,
3333+ Path(banner_id): Path<String>,
3434+) -> axum::response::Result<Json<PullBannerResponse>> {
3535+ let mut conn = db.begin().await.map_err(internal_server_error)?;
3636+3737+ let banner = sqlx::query!(
3838+ "SELECT pack_size FROM pack_banners WHERE id = $1",
3939+ banner_id
4040+ )
4141+ .fetch_optional(&mut *conn)
4242+ .await
4343+ .map_err(internal_server_error)?
4444+ .ok_or_else(|| {
4545+ JsonError(BannerNotFoundError {
4646+ banner_id: banner_id.clone(),
4747+ })
4848+ })?;
4949+5050+ let pack_banner_cards = sqlx::query!(
5151+ r#"
5252+ SELECT card_type_id, frequency, class AS "class: CardClass"
5353+ FROM pack_banner_cards
5454+ INNER JOIN card_types ON card_types.id = pack_banner_cards.card_type_id
5555+ WHERE pack_banner_cards.pack_banner_id = $1
5656+ "#,
5757+ banner_id
5858+ )
5959+ .fetch_all(&mut *conn)
6060+ .await
6161+ .map_err(internal_server_error)?;
6262+6363+ if pack_banner_cards.is_empty() {
6464+ return Err(JsonError(BannerNotFoundError { banner_id }).into());
6565+ }
6666+6767+ let seed = rng().next_u64();
6868+ let pack_generator = Xoshiro256PlusPlus::seed_from_u64(seed);
6969+ let distribution =
7070+ WeightedIndex::new(pack_banner_cards.iter().map(|card| card.frequency)).unwrap();
7171+7272+ let (tile_types, species): (Vec<_>, Vec<_>) = pack_generator
7373+ .sample_iter(distribution)
7474+ .take(banner.pack_size as usize)
7575+ .map(|index| &pack_banner_cards[index])
7676+ .partition(|card| card.class == CardClass::Tile);
7777+ // TODO[v1]: name generation for these things, for now the name is the ID (i18n-key)
7878+ let tile_type_ids: Vec<_> = tile_types
7979+ .into_iter()
8080+ .map(|card| &card.card_type_id)
8181+ .cloned()
8282+ .collect();
8383+ let species_ids: Vec<_> = species
8484+ .into_iter()
8585+ .map(|card| &card.card_type_id)
8686+ .cloned()
8787+ .collect();
8888+8989+ let pack = sqlx::query!(
9090+ r#"
9191+ WITH inserted_pack AS (
9292+ INSERT INTO packs (account_id, pack_banner_id, seed, algorithm)
9393+ VALUES ($1, $2, $3, 'xoshiro256++')
9494+ RETURNING id, account_id, pack_banner_id, opened_at
9595+ ),
9696+9797+ inserted_tile_cards AS (
9898+ INSERT INTO cards (card_type_id)
9999+ VALUES (UNNEST($4::text []))
100100+ RETURNING *
101101+ ),
102102+103103+ inserted_tiles AS (
104104+ INSERT INTO tiles (id, tile_type_id, name)
105105+ SELECT id, card_type_id, card_type_id
106106+ FROM inserted_tile_cards
107107+ RETURNING id
108108+ ),
109109+110110+ inserted_citizen_cards AS (
111111+ INSERT INTO cards (card_type_id)
112112+ VALUES (UNNEST($5::text []))
113113+ RETURNING *
114114+ ),
115115+116116+ inserted_citizens AS (
117117+ INSERT INTO citizens (id, species_id, name)
118118+ SELECT id, card_type_id, card_type_id
119119+ FROM inserted_citizen_cards
120120+ RETURNING id
121121+ ),
122122+123123+ inserted_cards AS (
124124+ SELECT * FROM inserted_tile_cards
125125+ UNION ALL
126126+ SELECT * FROM inserted_citizen_cards
127127+ ),
128128+129129+ inserted_pack_contents AS (
130130+ INSERT INTO pack_contents (pack_id, "position", card_id)
131131+ SELECT inserted_pack.id, ROW_NUMBER() OVER (), inserted_cards.id
132132+ FROM inserted_pack, inserted_cards
133133+ RETURNING *
134134+ )
135135+136136+ SELECT
137137+ inserted_pack.*,
138138+ JSONB_AGG(
139139+ JSONB_BUILD_OBJECT(
140140+ 'id', inserted_cards.id,
141141+ 'card_type_id', inserted_cards.card_type_id
142142+ )
143143+ ) AS "pack_cards: sqlx::types::Json<Vec<Card>>"
144144+ FROM inserted_pack, inserted_cards
145145+ GROUP BY
146146+ inserted_pack.id,
147147+ inserted_pack.account_id,
148148+ inserted_pack.pack_banner_id,
149149+ inserted_pack.opened_at
150150+ "#,
151151+ "foxfriends",
152152+ banner_id,
153153+ seed as i64,
154154+ &tile_type_ids,
155155+ &species_ids,
156156+ )
157157+ .fetch_one(&mut *conn)
158158+ .await
159159+ .map_err(internal_server_error)?;
160160+161161+ conn.commit().await.map_err(internal_server_error)?;
162162+163163+ Ok(Json(PullBannerResponse {
164164+ pack: Pack {
165165+ id: pack.id,
166166+ account_id: pack.account_id,
167167+ pack_banner_id: pack.pack_banner_id,
168168+ opened_at: pack.opened_at,
169169+ },
170170+ pack_cards: pack
171171+ .pack_cards
172172+ .expect("pack cards should have been correct JSON")
173173+ .0,
174174+ }))
175175+}