From 36abd4b6fb6830cf7bac5f216cf09b6b7c50c38a Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 11 Aug 2026 22:55:02 +0000 Subject: [PATCH] feat(seed): curated FIFA17 dev content pack + opt-in game-scoped ownership seed --- data/games/fifa17/dev/cards.json | 546 +++++++++++++++++++++++++++++++ src/app.rs | 6 +- src/config.rs | 14 + src/lib.rs | 1 + src/main.rs | 14 + src/seed/mod.rs | 187 ++++++++++- src/services/card_db.rs | 24 ++ tests/dev_seed_test.rs | 181 ++++++++++ 8 files changed, 971 insertions(+), 2 deletions(-) create mode 100644 data/games/fifa17/dev/cards.json create mode 100644 tests/dev_seed_test.rs diff --git a/data/games/fifa17/dev/cards.json b/data/games/fifa17/dev/cards.json new file mode 100644 index 0000000..61aca7c --- /dev/null +++ b/data/games/fifa17/dev/cards.json @@ -0,0 +1,546 @@ +[ + { + "id": "fifa17_101490", + "name": "Conor Casey", + "overall": 64, + "position": "ST", + "nation": "United States", + "league": "MLS", + "club": "Columbus Crew SC", + "pace": 43, + "shooting": 65, + "passing": 52, + "dribbling": 60, + "defending": 33, + "physical": 72, + "rarity": "bronze", + "image_path": null + }, + { + "id": "fifa17_101880", + "name": "Rob Green", + "overall": 74, + "position": "GK", + "nation": "England", + "league": "EFL Championship", + "club": "Leeds United", + "pace": 78, + "shooting": 70, + "passing": 62, + "dribbling": 77, + "defending": 47, + "physical": 71, + "rarity": "silver", + "image_path": null + }, + { + "id": "fifa17_102356", + "name": "Markus Feulner", + "overall": 74, + "position": "CM", + "nation": "Germany", + "league": "Bundesliga", + "club": "Augsburg", + "pace": 58, + "shooting": 70, + "passing": 75, + "dribbling": 71, + "defending": 66, + "physical": 71, + "rarity": "silver", + "image_path": null + }, + { + "id": "fifa17_102593", + "name": "Craig Woodman", + "overall": 64, + "position": "LB", + "nation": "England", + "league": "EFL League Two", + "club": "Exeter City", + "pace": 66, + "shooting": 45, + "passing": 58, + "dribbling": 60, + "defending": 62, + "physical": 65, + "rarity": "bronze", + "image_path": null + }, + { + "id": "fifa17_105046", + "name": "Anders Østli", + "overall": 64, + "position": "CB", + "nation": "Norway", + "league": "Tippeligaen", + "club": "Sarpsborg 08 FF", + "pace": 54, + "shooting": 46, + "passing": 54, + "dribbling": 52, + "defending": 62, + "physical": 75, + "rarity": "bronze", + "image_path": null + }, + { + "id": "fifa17_107298", + "name": "Yohann Pelé", + "overall": 74, + "position": "GK", + "nation": "France", + "league": "Ligue 1", + "club": "O. de Marseille", + "pace": 75, + "shooting": 74, + "passing": 72, + "dribbling": 70, + "defending": 49, + "physical": 76, + "rarity": "silver", + "image_path": null + }, + { + "id": "fifa17_107713", + "name": "Tom Starke", + "overall": 74, + "position": "GK", + "nation": "Germany", + "league": "Bundesliga", + "club": "Bayern", + "pace": 76, + "shooting": 73, + "passing": 59, + "dribbling": 72, + "defending": 39, + "physical": 76, + "rarity": "silver", + "image_path": null + }, + { + "id": "fifa17_110020", + "name": "Sergio Pelegrín", + "overall": 74, + "position": "CB", + "nation": "Spain", + "league": "LaLiga 1 I 2 I 3", + "club": "Elche CF", + "pace": 45, + "shooting": 32, + "passing": 52, + "dribbling": 49, + "defending": 75, + "physical": 76, + "rarity": "silver", + "image_path": null + }, + { + "id": "fifa17_110026", + "name": "Cani", + "overall": 74, + "position": "LM", + "nation": "Spain", + "league": "LaLiga 1 I 2 I 3", + "club": "Real Zaragoza", + "pace": 67, + "shooting": 72, + "passing": 73, + "dribbling": 78, + "defending": 45, + "physical": 61, + "rarity": "silver", + "image_path": null + }, + { + "id": "fifa17_11811", + "name": "Paul Green", + "overall": 64, + "position": "CM", + "nation": "Republic of Ireland", + "league": "EFL League One", + "club": "Oldham Athletic", + "pace": 65, + "shooting": 58, + "passing": 62, + "dribbling": 63, + "defending": 62, + "physical": 68, + "rarity": "bronze", + "image_path": null + }, + { + "id": "fifa17_139720", + "name": "Vincent Kompany", + "overall": 86, + "position": "CB", + "nation": "Belgium", + "league": "Premier League", + "club": "Manchester City", + "pace": 69, + "shooting": 54, + "passing": 62, + "dribbling": 65, + "defending": 86, + "physical": 81, + "rarity": "gold", + "image_path": null + }, + { + "id": "fifa17_146562", + "name": "Santi Cazorla", + "overall": 86, + "position": "CAM", + "nation": "Spain", + "league": "Premier League", + "club": "Arsenal", + "pace": 71, + "shooting": 78, + "passing": 85, + "dribbling": 86, + "defending": 57, + "physical": 64, + "rarity": "gold", + "image_path": null + }, + { + "id": "fifa17_153079", + "name": "Sergio Agüero", + "overall": 89, + "position": "ST", + "nation": "Argentina", + "league": "Premier League", + "club": "Manchester City", + "pace": 89, + "shooting": 88, + "passing": 75, + "dribbling": 89, + "defending": 23, + "physical": 70, + "rarity": "gold", + "image_path": null + }, + { + "id": "fifa17_158023", + "name": "Lionel Messi", + "overall": 93, + "position": "RW", + "nation": "Argentina", + "league": "LaLiga Santander", + "club": "FC Barcelona", + "pace": 89, + "shooting": 90, + "passing": 86, + "dribbling": 96, + "defending": 26, + "physical": 61, + "rarity": "gold", + "image_path": null + }, + { + "id": "fifa17_162895", + "name": "Cesc Fàbregas", + "overall": 86, + "position": "CM", + "nation": "Spain", + "league": "Premier League", + "club": "Chelsea", + "pace": 63, + "shooting": 77, + "passing": 89, + "dribbling": 81, + "defending": 61, + "physical": 64, + "rarity": "gold", + "image_path": null + }, + { + "id": "fifa17_163705", + "name": "Steve Mandanda", + "overall": 85, + "position": "GK", + "nation": "France", + "league": "Premier League", + "club": "Crystal Palace", + "pace": 86, + "shooting": 80, + "passing": 79, + "dribbling": 85, + "defending": 49, + "physical": 81, + "rarity": "gold", + "image_path": null + }, + { + "id": "fifa17_165229", + "name": "Laurent Koscielny", + "overall": 85, + "position": "CB", + "nation": "France", + "league": "Premier League", + "club": "Arsenal", + "pace": 78, + "shooting": 40, + "passing": 62, + "dribbling": 65, + "defending": 85, + "physical": 78, + "rarity": "gold", + "image_path": null + }, + { + "id": "fifa17_167948", + "name": "Hugo Lloris", + "overall": 88, + "position": "GK", + "nation": "France", + "league": "Premier League", + "club": "Spurs", + "pace": 87, + "shooting": 87, + "passing": 68, + "dribbling": 90, + "defending": 64, + "physical": 82, + "rarity": "gold", + "image_path": null + }, + { + "id": "fifa17_168542", + "name": "David Silva", + "overall": 87, + "position": "CAM", + "nation": "Spain", + "league": "Premier League", + "club": "Manchester City", + "pace": 68, + "shooting": 72, + "passing": 87, + "dribbling": 87, + "defending": 32, + "physical": 58, + "rarity": "gold", + "image_path": null + }, + { + "id": "fifa17_176580", + "name": "Luis Suárez", + "overall": 92, + "position": "ST", + "nation": "Uruguay", + "league": "LaLiga Santander", + "club": "FC Barcelona", + "pace": 82, + "shooting": 90, + "passing": 79, + "dribbling": 87, + "defending": 42, + "physical": 79, + "rarity": "gold", + "image_path": null + }, + { + "id": "fifa17_176635", + "name": "Mesut Özil", + "overall": 89, + "position": "CAM", + "nation": "Germany", + "league": "Premier League", + "club": "Arsenal", + "pace": 72, + "shooting": 74, + "passing": 86, + "dribbling": 86, + "defending": 24, + "physical": 58, + "rarity": "gold", + "image_path": null + }, + { + "id": "fifa17_177388", + "name": "Dimitri Payet", + "overall": 86, + "position": "LM", + "nation": "France", + "league": "Premier League", + "club": "West Ham", + "pace": 77, + "shooting": 78, + "passing": 87, + "dribbling": 87, + "defending": 42, + "physical": 70, + "rarity": "gold", + "image_path": null + }, + { + "id": "fifa17_183277", + "name": "Eden Hazard", + "overall": 88, + "position": "LM", + "nation": "Belgium", + "league": "Premier League", + "club": "Chelsea", + "pace": 90, + "shooting": 81, + "passing": 82, + "dribbling": 91, + "defending": 32, + "physical": 64, + "rarity": "gold", + "image_path": null + }, + { + "id": "fifa17_184941", + "name": "Alexis Sánchez", + "overall": 87, + "position": "LW", + "nation": "Chile", + "league": "Premier League", + "club": "Arsenal", + "pace": 86, + "shooting": 82, + "passing": 79, + "dribbling": 88, + "defending": 39, + "physical": 74, + "rarity": "gold", + "image_path": null + }, + { + "id": "fifa17_190871", + "name": "Neymar", + "overall": 92, + "position": "LW", + "nation": "Brazil", + "league": "LaLiga Santander", + "club": "FC Barcelona", + "pace": 91, + "shooting": 84, + "passing": 78, + "dribbling": 95, + "defending": 30, + "physical": 56, + "rarity": "gold", + "image_path": null + }, + { + "id": "fifa17_192119", + "name": "Thibaut Courtois", + "overall": 89, + "position": "GK", + "nation": "Belgium", + "league": "Premier League", + "club": "Chelsea", + "pace": 84, + "shooting": 91, + "passing": 69, + "dribbling": 89, + "defending": 48, + "physical": 86, + "rarity": "gold", + "image_path": null + }, + { + "id": "fifa17_192985", + "name": "Kevin De Bruyne", + "overall": 88, + "position": "CAM", + "nation": "Belgium", + "league": "Premier League", + "club": "Manchester City", + "pace": 77, + "shooting": 83, + "passing": 86, + "dribbling": 84, + "defending": 40, + "physical": 75, + "rarity": "gold", + "image_path": null + }, + { + "id": "fifa17_193080", + "name": "David De Gea", + "overall": 90, + "position": "GK", + "nation": "Spain", + "league": "Premier League", + "club": "Manchester Utd", + "pace": 88, + "shooting": 85, + "passing": 87, + "dribbling": 90, + "defending": 56, + "physical": 85, + "rarity": "gold", + "image_path": null + }, + { + "id": "fifa17_195864", + "name": "Paul Pogba", + "overall": 88, + "position": "CM", + "nation": "France", + "league": "Premier League", + "club": "Manchester Utd", + "pace": 77, + "shooting": 80, + "passing": 83, + "dribbling": 87, + "defending": 72, + "physical": 87, + "rarity": "gold", + "image_path": null + }, + { + "id": "fifa17_20801", + "name": "Cristiano Ronaldo", + "overall": 94, + "position": "LW", + "nation": "Portugal", + "league": "LaLiga Santander", + "club": "Real Madrid", + "pace": 92, + "shooting": 92, + "passing": 81, + "dribbling": 91, + "defending": 33, + "physical": 80, + "rarity": "gold", + "image_path": null + }, + { + "id": "fifa17_41236", + "name": "Zlatan Ibrahimović", + "overall": 90, + "position": "ST", + "nation": "Sweden", + "league": "Premier League", + "club": "Manchester Utd", + "pace": 72, + "shooting": 90, + "passing": 81, + "dribbling": 85, + "defending": 31, + "physical": 86, + "rarity": "gold", + "image_path": null + }, + { + "id": "fifa17_48940", + "name": "Petr Čech", + "overall": 88, + "position": "GK", + "nation": "Czech Republic", + "league": "Premier League", + "club": "Arsenal", + "pace": 83, + "shooting": 90, + "passing": 77, + "dribbling": 85, + "defending": 48, + "physical": 85, + "rarity": "gold", + "image_path": null + } +] diff --git a/src/app.rs b/src/app.rs index e78a4a0..351aebb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -42,7 +42,11 @@ pub struct AppState { } pub async fn build(pool: Pool, cfg: Config) -> Result { - let card_db = Arc::new(CardDb::load(&cfg.data_dir)?); + let mut card_db = CardDb::load(&cfg.data_dir)?; + for game in &cfg.dev_content_games { + card_db.load_game_dev(&cfg.data_dir, game)?; + } + let card_db = Arc::new(card_db); let pack_defs = Arc::new(load_pack_definitions(&cfg.data_dir)?); let obj_defs = Arc::new(load_objective_definitions(&cfg.data_dir)?); let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?); diff --git a/src/config.rs b/src/config.rs index c0698ea..d24c00c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,6 +6,10 @@ pub struct Config { pub database_url: String, pub data_dir: String, pub max_connections: u32, + /// Games whose opt-in development content pack (`data/games//dev/`) is + /// loaded IN ADDITION to the default `data/cards` catalog. Empty by default — + /// default/test content is never affected unless a game is named here. + pub dev_content_games: Vec, } impl Config { @@ -19,6 +23,16 @@ impl Config { .ok() .and_then(|v| v.parse().ok()) .unwrap_or(5), + dev_content_games: std::env::var("OPENFUT_DEV_CONTENT_GAMES") + .ok() + .map(|v| { + v.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) + .collect() + }) + .unwrap_or_default(), }) } } diff --git a/src/lib.rs b/src/lib.rs index cedb2c3..5607d43 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,7 @@ pub async fn build_app(pool: db::Pool, data_dir: &str) -> Result { database_url: "sqlite::memory:".into(), data_dir: data_dir.to_string(), max_connections: 1, + dev_content_games: Vec::new(), }; app::build(pool, cfg).await } diff --git a/src/main.rs b/src/main.rs index 0d558c4..fdfd9f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,20 @@ async fn main() -> Result<()> { .init(); let cfg = config::Config::from_env()?; + + // Opt-in dev subcommand: `openfut-core seed-dev` seeds the FIFA 17 dev + // profile/club from the dev content pack, prints a coverage report, and + // exits. Normal server startup NEVER seeds dev inventory. + if std::env::args().nth(1).as_deref() == Some("seed-dev") { + let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?; + db::run_migrations(&pool).await?; + let mut card_db = openfut_core::services::card_db::CardDb::load(&cfg.data_dir)?; + card_db.load_game_dev(&cfg.data_dir, seed::FIFA17_GAME)?; + let report = seed::seed_fifa17_dev(&pool, &card_db).await?; + println!("{}", serde_json::to_string_pretty(&report)?); + return Ok(()); + } + info!("OpenFUT Core starting on {}", cfg.listen_addr); let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?; diff --git a/src/seed/mod.rs b/src/seed/mod.rs index 66c3cf4..30c0539 100644 --- a/src/seed/mod.rs +++ b/src/seed/mod.rs @@ -1,6 +1,23 @@ -use crate::{db::Pool, error::AppResult, models::pack::PackDefinition, services::pack as pack_svc}; +use crate::{ + db::Pool, + error::AppResult, + models::{card::Quality, club::Club, pack::PackDefinition}, + services::{card_db::CardDb, club as club_svc, pack as pack_svc, profile as profile_svc}, +}; +use serde::Serialize; +use std::collections::BTreeMap; use tracing::info; +/// The game whose dev content + inventory this seeds. +pub const FIFA17_GAME: &str = "fifa17"; +/// Deterministic owned-instance id prefix, so re-running the seed is idempotent +/// (INSERT OR IGNORE on a stable id) rather than minting duplicate ownership. +const DEV_OWNED_PREFIX: &str = "fdev-"; +/// Fixed grant timestamp — the seed is deterministic, not wall-clock dependent. +const DEV_ACQUIRED_AT: &str = "2026-08-11T00:00:00Z"; +/// The client's My Squad page size (evidence: request `count=11`). +const MY_SQUAD_PAGE: usize = 11; + /// Seeds the market with NPC listings if empty. pub async fn maybe_seed(_pool: &Pool) -> AppResult<()> { // Any one-time startup seeds go here. @@ -29,3 +46,171 @@ pub async fn grant_starter_pack( Ok(()) } + +// ───────────────────────────── FIFA 17 dev seed ───────────────────────────── + +/// Coverage of the seeded FIFA 17 development inventory. Game-independent: it +/// counts quality tiers, positions and distinct entities, and whether the Gold +/// filter spans more than one page — everything the retail `/club` UI must +/// exercise. It carries NO FIFA wire ids (those are the adapter/host's runtime +/// concern; the seed never allocates them). +#[derive(Debug, Serialize)] +pub struct DevSeedReport { + pub game_id: String, + /// True if the fifa17 club already owned dev cards (no new grants made). + pub already_seeded: bool, + pub definitions_available: usize, + pub owned_total: usize, + pub unique_definitions: usize, + pub gold: usize, + pub silver: usize, + pub bronze: usize, + pub positions: BTreeMap, + pub distinct_nations: usize, + pub distinct_leagues: usize, + pub distinct_clubs: usize, + pub max_same_club: usize, + /// Gold owned items exceed one page → the client must request a 2nd page. + pub gold_over_one_page: bool, +} + +/// Opt-in development seed: create (if absent) a `game_id=fifa17` profile + club +/// and grant Core-owned instances of every dev-pack `CardDefinition` (ids +/// `fifa17_*`), plus one deliberate duplicate of a single definition (to exercise +/// two-copies-of-one-card identity later). +/// +/// **Ownership only — no FIFA wire ids.** The FIFA 17 integer item id is minted +/// lazily by `Fifa17IdentityResolver` at request time, never here. This keeps the +/// boundary clean: Core owns "this profile owns this card"; the adapter owns +/// "this owned item is wire id N". +/// +/// Idempotent: owned ids are deterministic (`fdev-`), inserted with +/// `INSERT OR IGNORE`, so re-running grants nothing new. The default profile +/// (`fifa23`/no-header) and any existing synthetic inventory are never touched. +pub async fn seed_fifa17_dev(pool: &Pool, card_db: &CardDb) -> AppResult { + // The dev definitions are exactly the game-namespaced ids in the catalog. + let mut defs: Vec<&crate::models::card::CardDefinition> = card_db + .cards + .values() + .filter(|c| c.id.starts_with("fifa17_")) + .collect(); + defs.sort_by(|a, b| a.id.cmp(&b.id)); + + // Ensure the fifa17-scoped profile + club exist (single-profile-per-game). + let profile = match profile_svc::get_active_profile(pool, FIFA17_GAME).await { + Ok(p) => p, + Err(_) => profile_svc::create_profile(pool, "OpenFUT Dev (FIFA17)", FIFA17_GAME).await?, + }; + let club = match club_svc::get_club_by_profile(pool, &profile.id).await { + Ok(c) => c, + Err(_) => { + let c = Club::new(&profile.id, "OpenFUT Dev FC", 100_000); + club_svc::create_club(pool, &c).await?; + c + } + }; + + let prior: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM owned_cards WHERE club_id = ? AND card_id LIKE 'fifa17_%'", + ) + .bind(&club.id) + .fetch_one(pool) + .await?; + let already_seeded = prior > 0; + + // Grant one instance per definition; INSERT OR IGNORE keeps reruns idempotent. + for def in &defs { + grant_owned( + pool, + &format!("{DEV_OWNED_PREFIX}{}", def.id), + &club.id, + &def.id, + ) + .await?; + } + // One deliberate duplicate of the first (lexicographic) definition → two + // owned copies of one card sharing a definition but distinct owned ids. + if let Some(first) = defs.first() { + grant_owned( + pool, + &format!("{DEV_OWNED_PREFIX}{}-b", first.id), + &club.id, + &first.id, + ) + .await?; + } + + let report = dev_coverage(pool, card_db, &club.id, already_seeded, defs.len()).await?; + info!( + "seeded fifa17 dev inventory: {} owned ({} gold) over club {}", + report.owned_total, report.gold, club.id + ); + Ok(report) +} + +async fn grant_owned(pool: &Pool, owned_id: &str, club_id: &str, card_id: &str) -> AppResult<()> { + sqlx::query( + "INSERT OR IGNORE INTO owned_cards \ + (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \ + VALUES (?, ?, ?, 0, NULL, ?)", + ) + .bind(owned_id) + .bind(club_id) + .bind(card_id) + .bind(DEV_ACQUIRED_AT) + .execute(pool) + .await?; + Ok(()) +} + +/// Build the coverage report from the club's owned dev cards joined to `card_db`. +async fn dev_coverage( + pool: &Pool, + card_db: &CardDb, + club_id: &str, + already_seeded: bool, + definitions_available: usize, +) -> AppResult { + let card_ids: Vec = sqlx::query_scalar( + "SELECT card_id FROM owned_cards WHERE club_id = ? AND card_id LIKE 'fifa17_%'", + ) + .bind(club_id) + .fetch_all(pool) + .await?; + let (mut gold, mut silver, mut bronze) = (0usize, 0usize, 0usize); + let mut positions: BTreeMap = BTreeMap::new(); + let mut nations = std::collections::BTreeSet::new(); + let mut leagues = std::collections::BTreeSet::new(); + let mut club_counts: BTreeMap = BTreeMap::new(); + let mut unique = std::collections::BTreeSet::new(); + for card_id in &card_ids { + unique.insert(card_id.clone()); + if let Some(def) = card_db.get(card_id) { + match Quality::from_overall(def.overall) { + Quality::Gold => gold += 1, + Quality::Silver => silver += 1, + Quality::Bronze => bronze += 1, + } + *positions.entry(def.position.clone()).or_default() += 1; + nations.insert(def.nation.clone()); + leagues.insert(def.league.clone()); + *club_counts.entry(def.club.clone()).or_default() += 1; + } + } + Ok(DevSeedReport { + game_id: FIFA17_GAME.to_string(), + already_seeded, + definitions_available, + owned_total: card_ids.len(), + unique_definitions: unique.len(), + gold, + silver, + bronze, + positions, + distinct_nations: nations.len(), + distinct_leagues: leagues.len(), + distinct_clubs: club_counts.len(), + max_same_club: club_counts.values().copied().max().unwrap_or(0), + gold_over_one_page: gold > MY_SQUAD_PAGE, + }) +} diff --git a/src/services/card_db.rs b/src/services/card_db.rs index de006b4..45c3374 100644 --- a/src/services/card_db.rs +++ b/src/services/card_db.rs @@ -38,6 +38,30 @@ impl CardDb { Ok(Self { cards }) } + /// Merge a game's **opt-in development content pack** from + /// `{data_dir}/games/{game}/dev/cards.json` (a single `CardDefinition[]`). + /// This is NOT read by [`CardDb::load`]; it is loaded only when a game is + /// explicitly named in `Config::dev_content_games`, so default content stays + /// untouched. Returns the number of definitions merged. A missing file is an + /// error (opt-in means the pack is expected to exist). + pub fn load_game_dev(&mut self, data_dir: &str, game: &str) -> Result { + let path = Path::new(data_dir) + .join("games") + .join(game) + .join("dev") + .join("cards.json"); + let content = std::fs::read_to_string(&path) + .with_context(|| format!("reading dev content pack {path:?}"))?; + let batch: Vec = + serde_json::from_str(&content).with_context(|| format!("parsing {path:?}"))?; + let n = batch.len(); + for card in batch { + self.cards.insert(card.id.clone(), card); + } + tracing::info!("Loaded {} dev card definitions for game '{}'", n, game); + Ok(n) + } + pub fn get(&self, id: &str) -> Option<&CardDefinition> { self.cards.get(id) } diff --git a/tests/dev_seed_test.rs b/tests/dev_seed_test.rs new file mode 100644 index 0000000..62f88b4 --- /dev/null +++ b/tests/dev_seed_test.rs @@ -0,0 +1,181 @@ +//! FIFA 17 development content pack + ownership seed (Commit 5). +//! +//! Proves: the dev pack is opt-in and isolated from default content; the seed +//! creates a `game_id=fifa17` profile/club and grants real Core `OwnedCard`s +//! (never FIFA wire ids); it is idempotent and leaves the default profile alone; +//! and the seeded inventory can exercise the retail `/club` filter + pagination. + +use openfut_core::db::Pool; +use openfut_core::services::card_db::CardDb; + +async fn pool() -> Pool { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("in-memory sqlite"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("migrations"); + pool +} + +fn dev_card_db() -> CardDb { + let mut db = CardDb::load("data").expect("default cards"); + db.load_game_dev("data", "fifa17").expect("dev pack"); + db +} + +// ── Content isolation ──────────────────────────────────────────────────────── + +#[test] +fn default_load_never_contains_dev_pack() { + // The default global loader reads only data/cards — the dev pack under + // data/games/fifa17/dev must be invisible unless explicitly requested. + let default = CardDb::load("data").expect("default cards"); + let leaked: Vec<_> = default + .cards + .keys() + .filter(|k| k.starts_with("fifa17_")) + .collect(); + assert!( + leaked.is_empty(), + "default content must not include FIFA17 dev cards: {leaked:?}" + ); + assert!( + !default.cards.is_empty(), + "default synthetic catalogue still loads" + ); +} + +#[test] +fn opt_in_load_adds_dev_pack_only() { + let default_n = CardDb::load("data").unwrap().cards.len(); + let db = dev_card_db(); + let dev: Vec<_> = db + .cards + .keys() + .filter(|k| k.starts_with("fifa17_")) + .collect(); + assert_eq!(dev.len(), 32, "the curated dev pack is 32 definitions"); + assert_eq!( + db.cards.len(), + default_n + 32, + "dev pack is additive; default content unchanged" + ); +} + +#[test] +fn dev_definitions_carry_semantic_names_not_raw_ids() { + let db = dev_card_db(); + for c in db.cards.values().filter(|c| c.id.starts_with("fifa17_")) { + // Semantic Core fields are names, never raw FIFA numeric entity ids. + assert!( + c.nation.parse::().is_err(), + "nation must be a name, got {:?}", + c.nation + ); + assert!( + c.league.parse::().is_err(), + "league must be a name: {:?}", + c.league + ); + assert!( + c.club.parse::().is_err(), + "club must be a name: {:?}", + c.club + ); + assert!(!c.name.is_empty(), "every dev card has a player name"); + } +} + +// ── Ownership seed ───────────────────────────────────────────────────────── + +#[tokio::test] +async fn seed_grants_game_scoped_inventory_with_filter_coverage() { + let pool = pool().await; + let db = dev_card_db(); + let r = openfut_core::seed::seed_fifa17_dev(&pool, &db) + .await + .unwrap(); + + assert_eq!(r.game_id, "fifa17"); + assert!(!r.already_seeded); + assert_eq!(r.definitions_available, 32); + assert_eq!(r.owned_total, 33, "32 defs + 1 deliberate duplicate"); + assert_eq!(r.unique_definitions, 32); + assert!(r.gold_over_one_page, "gold spans >1 page (22 > 11)"); + assert!(r.gold > 11, "enough gold for pagination"); + assert!(r.silver >= 1 && r.bronze >= 1, "quality spread"); + assert!(r.positions.contains_key("GK"), "GK present"); + assert!(r.positions.contains_key("ST"), "ST present"); + assert!(r.distinct_leagues >= 2, "multiple leagues"); + assert!(r.distinct_nations >= 2, "multiple nations"); + assert!(r.max_same_club >= 2, "a same-club group for team filters"); + + // The seed created ONLY a fifa17 profile — the default (fifa23) profile and + // any synthetic inventory are untouched. + let games: Vec<(String,)> = sqlx::query_as("SELECT game_id FROM profiles") + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!( + games, + vec![("fifa17".to_string(),)], + "only the fifa17 profile exists" + ); + + // Every seeded owned card references a dev-pack definition (all renderable). + let orphans: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM owned_cards o \ + WHERE o.card_id LIKE 'fifa17_%' AND o.card_id NOT IN \ + (SELECT card_id FROM owned_cards WHERE card_id LIKE 'fifa17_%')", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(orphans, 0); +} + +#[tokio::test] +async fn seed_is_idempotent_across_reruns() { + let pool = pool().await; + let db = dev_card_db(); + let first = openfut_core::seed::seed_fifa17_dev(&pool, &db) + .await + .unwrap(); + let second = openfut_core::seed::seed_fifa17_dev(&pool, &db) + .await + .unwrap(); + assert!(!first.already_seeded); + assert!(second.already_seeded, "second run sees existing ownership"); + assert_eq!(first.owned_total, second.owned_total, "no duplicate grants"); + + let n: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE card_id LIKE 'fifa17_%'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(n, 33, "row count stable after rerun"); +} + +#[tokio::test] +async fn seed_creates_exactly_one_two_copy_definition() { + let pool = pool().await; + let db = dev_card_db(); + openfut_core::seed::seed_fifa17_dev(&pool, &db) + .await + .unwrap(); + // Exactly one definition is owned twice (distinct owned ids, same card_id): + // the identity foundation for "two copies of one card" later. + let dupes: Vec<(String, i64)> = sqlx::query_as( + "SELECT card_id, COUNT(*) c FROM owned_cards WHERE card_id LIKE 'fifa17_%' \ + GROUP BY card_id HAVING c > 1", + ) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(dupes.len(), 1, "exactly one duplicated definition"); + assert_eq!(dupes[0].1, 2, "owned twice"); +}