217 lines
8.0 KiB
Rust
217 lines
8.0 KiB
Rust
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.
|
|
// Currently we just ensure the market gets listings on first run.
|
|
Ok(())
|
|
}
|
|
|
|
/// Grants the starter pack to a newly created club.
|
|
pub async fn grant_starter_pack(
|
|
pool: &Pool,
|
|
club_id: &str,
|
|
pack_defs: &[PackDefinition],
|
|
) -> AppResult<()> {
|
|
// Look for the gold starter pack first; fall back to the first available definition.
|
|
let starter_def = pack_defs
|
|
.iter()
|
|
.find(|p| p.id == "gold_pack")
|
|
.or_else(|| pack_defs.first());
|
|
|
|
if let Some(def) = starter_def {
|
|
info!("Granting starter pack '{}' to club {}", def.id, club_id);
|
|
pack_svc::grant_pack(pool, club_id, &def.id).await?;
|
|
} else {
|
|
tracing::warn!("No pack definitions loaded; skipping starter pack grant");
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|