feat(seed): curated FIFA17 dev content pack + opt-in game-scoped ownership seed
This commit is contained in:
+5
-1
@@ -42,7 +42,11 @@ pub struct AppState {
|
||||
}
|
||||
|
||||
pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||
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)?);
|
||||
|
||||
@@ -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/<game>/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<String>,
|
||||
}
|
||||
|
||||
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ pub async fn build_app(pool: db::Pool, data_dir: &str) -> Result<Router> {
|
||||
database_url: "sqlite::memory:".into(),
|
||||
data_dir: data_dir.to_string(),
|
||||
max_connections: 1,
|
||||
dev_content_games: Vec::new(),
|
||||
};
|
||||
app::build(pool, cfg).await
|
||||
}
|
||||
|
||||
+14
@@ -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?;
|
||||
|
||||
+186
-1
@@ -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<String, usize>,
|
||||
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-<card_id>`), 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<DevSeedReport> {
|
||||
// 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<DevSeedReport> {
|
||||
let card_ids: Vec<String> = 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<String, usize> = BTreeMap::new();
|
||||
let mut nations = std::collections::BTreeSet::new();
|
||||
let mut leagues = std::collections::BTreeSet::new();
|
||||
let mut club_counts: BTreeMap<String, usize> = 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<usize> {
|
||||
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<CardDefinition> =
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user