Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33b5ac1908 | |||
| eab522a1eb |
@@ -1,546 +0,0 @@
|
||||
[
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
-- Distinguish NPC-generated listings from player-posted ones so that the
|
||||
-- periodic NPC refresh does not accidentally wipe player listings.
|
||||
ALTER TABLE market_listings ADD COLUMN is_npc INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_market_npc ON market_listings(is_npc, sold);
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Track when the player last claimed their rivals weekly reward to enforce a
|
||||
-- 24-hour cooldown between claims.
|
||||
ALTER TABLE seasons ADD COLUMN rivals_last_claimed_at TEXT;
|
||||
@@ -1,14 +0,0 @@
|
||||
-- Multi-game support. Every profile (and therefore all of its downstream state,
|
||||
-- which hangs off profiles(id) via profile_id / club_id foreign keys) is scoped to
|
||||
-- a game. Bridges identify their game with the X-OpenFUT-Game request header.
|
||||
--
|
||||
-- Default 'fifa23' preserves the existing single-game behaviour: the current bridge
|
||||
-- and the integration tests send no game header, so they keep operating on the same
|
||||
-- (now fifa23-tagged) profile with zero behaviour change. The FIFA 17 bridge sends
|
||||
-- X-OpenFUT-Game: fifa17 and therefore gets its own isolated profile/club/state.
|
||||
--
|
||||
-- Only profiles needs the column: get_active_profile becomes game-scoped, and because
|
||||
-- all other tables reference a profile (directly via profile_id or via
|
||||
-- club_id -> clubs.profile_id), scoping the active profile isolates the whole tree.
|
||||
ALTER TABLE profiles ADD COLUMN game_id TEXT NOT NULL DEFAULT 'fifa23';
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_game ON profiles(game_id);
|
||||
@@ -1,23 +0,0 @@
|
||||
-- Generic, game-scoped OPAQUE extension storage.
|
||||
--
|
||||
-- Core persists, versions, associates (to a canonical entity + a server-computed
|
||||
-- fingerprint), and enforces generic safety bounds on these bytes — but NEVER
|
||||
-- interprets them. A game adapter owns the payload's schema and meaning. This is
|
||||
-- how a game keeps wire-only round-trip state (e.g. FIFA 17 squad custom[]/
|
||||
-- kicktakers/kitNumber) durable and atomic with its canonical entity without
|
||||
-- leaking game-specific columns into generic Core.
|
||||
--
|
||||
-- Scope key: (game_id, entity_kind, entity_id, namespace). `namespace` is an
|
||||
-- opaque adapter key (e.g. "fifa17.squad.v1"); `schema_version` is the adapter's
|
||||
-- payload version (distinct from this table's storage schema).
|
||||
CREATE TABLE IF NOT EXISTS game_entity_ext (
|
||||
game_id TEXT NOT NULL,
|
||||
entity_kind TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
namespace TEXT NOT NULL,
|
||||
schema_version INTEGER NOT NULL,
|
||||
canonical_fingerprint TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (game_id, entity_kind, entity_id, namespace)
|
||||
);
|
||||
+1
-7
@@ -42,11 +42,7 @@ pub struct AppState {
|
||||
}
|
||||
|
||||
pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||
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 card_db = Arc::new(CardDb::load(&cfg.data_dir)?);
|
||||
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)?);
|
||||
@@ -172,8 +168,6 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||
.route("/packs/open/:pack_id", post(routes::packs::post_open_pack))
|
||||
.route("/squad", get(routes::squad::get_squad))
|
||||
.route("/squad", post(routes::squad::post_squad))
|
||||
.route("/squad/ext", get(routes::squad::get_squad_ext))
|
||||
.route("/squad/replace", put(routes::squad::put_squad_replace))
|
||||
.route("/squads", get(routes::squad::get_squads))
|
||||
.route("/squads/:squad_id", get(routes::squad::get_squad_by_id))
|
||||
.route("/squads/:squad_id", delete(routes::squad::delete_squad))
|
||||
|
||||
+1
-14
@@ -5,11 +5,8 @@ pub struct Config {
|
||||
pub listen_addr: String,
|
||||
pub database_url: String,
|
||||
pub data_dir: String,
|
||||
#[allow(dead_code)]
|
||||
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 {
|
||||
@@ -23,16 +20,6 @@ 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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ use tracing::info;
|
||||
|
||||
pub type Pool = SqlitePool;
|
||||
|
||||
pub async fn init_pool(database_url: &str, max_connections: u32) -> Result<Pool> {
|
||||
pub async fn init_pool(database_url: &str) -> Result<Pool> {
|
||||
info!("Connecting to database: {}", database_url);
|
||||
let opts = SqliteConnectOptions::from_str(database_url)?.create_if_missing(true);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
.max_connections(5)
|
||||
.connect_with(opts)
|
||||
.await?;
|
||||
sqlx::query("PRAGMA journal_mode=WAL")
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
//! Request extractors shared across routes.
|
||||
|
||||
use axum::{
|
||||
async_trait,
|
||||
extract::FromRequestParts,
|
||||
http::{request::Parts, HeaderName},
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
|
||||
/// The game a request belongs to, read from the `X-OpenFUT-Game` header.
|
||||
///
|
||||
/// Multi-game support: each game bridge tags its requests with its own id
|
||||
/// (e.g. `fifa17`, `fifa23`) so core can scope the active profile - and thus all
|
||||
/// downstream club/card/squad/market state - to that game. Defaults to `fifa23`
|
||||
/// when the header is absent, so the existing FIFA 23 bridge and the integration
|
||||
/// tests (which send no header) keep operating on their game unchanged.
|
||||
///
|
||||
/// Extraction never fails: a missing or malformed header falls back to the default.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GameId(pub String);
|
||||
|
||||
/// The game assumed when no `X-OpenFUT-Game` header is present.
|
||||
pub const DEFAULT_GAME: &str = "fifa23";
|
||||
|
||||
static HEADER: HeaderName = HeaderName::from_static("x-openfut-game");
|
||||
|
||||
impl GameId {
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for GameId
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = Infallible;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let game = parts
|
||||
.headers
|
||||
.get(&HEADER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(DEFAULT_GAME)
|
||||
.to_string();
|
||||
Ok(GameId(game))
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ pub mod app;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod extractors;
|
||||
pub mod middleware;
|
||||
pub mod modding;
|
||||
pub mod models;
|
||||
@@ -21,7 +20,6 @@ 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
|
||||
}
|
||||
|
||||
+1
-15
@@ -16,23 +16,9 @@ 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?;
|
||||
let pool = db::init_pool(&cfg.database_url).await?;
|
||||
db::run_migrations(&pool).await?;
|
||||
|
||||
seed::maybe_seed(&pool).await?;
|
||||
|
||||
+24
-1
@@ -1 +1,24 @@
|
||||
// Reserved for future modding loader utilities.
|
||||
use anyhow::{Context, Result};
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::path::Path;
|
||||
|
||||
#[allow(dead_code)]
|
||||
/// Generic loader for JSON arrays from a directory.
|
||||
pub fn load_json_dir<T: DeserializeOwned>(dir: &Path) -> Result<Vec<T>> {
|
||||
let mut items = Vec::new();
|
||||
if !dir.exists() {
|
||||
return Ok(items);
|
||||
}
|
||||
for entry in std::fs::read_dir(dir).with_context(|| format!("reading dir {dir:?}"))? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||
let content =
|
||||
std::fs::read_to_string(&path).with_context(|| format!("reading {path:?}"))?;
|
||||
let batch: Vec<T> =
|
||||
serde_json::from_str(&content).with_context(|| format!("parsing {path:?}"))?;
|
||||
items.extend(batch);
|
||||
}
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
//! Modding support: load JSON data files from the data/ directory.
|
||||
//! All game content (cards, packs, objectives, SBCs) is data-driven.
|
||||
|
||||
pub mod loader;
|
||||
|
||||
@@ -13,20 +13,6 @@ pub enum Rarity {
|
||||
Icon,
|
||||
}
|
||||
|
||||
impl Rarity {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Rarity::Bronze => "bronze",
|
||||
Rarity::Silver => "silver",
|
||||
Rarity::Gold => "gold",
|
||||
Rarity::RareGold => "raregold",
|
||||
Rarity::Totw => "totw",
|
||||
Rarity::Hero => "hero",
|
||||
Rarity::Icon => "icon",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Visual card quality tier (gold/silver/bronze).
|
||||
///
|
||||
/// Game-independent semantic dimension, kept distinct from `Rarity` (which also
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
//! Generic, game-scoped **opaque** extension state.
|
||||
//!
|
||||
//! Core stores and versions these bytes and associates them with a canonical
|
||||
//! entity + a server-computed fingerprint, but never interprets them. A game
|
||||
//! adapter owns the payload schema/meaning. This keeps game-only wire round-trip
|
||||
//! state (e.g. a FIFA 17 squad's `custom[]`/`kicktakers`/`kitNumber`) durable and
|
||||
//! atomic with its canonical entity without leaking game concepts into Core.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Generic safety bounds Core enforces without interpreting the payload.
|
||||
pub const MAX_EXT_PAYLOAD_BYTES: usize = 64 * 1024;
|
||||
pub const MAX_EXT_NAMESPACE_LEN: usize = 64;
|
||||
|
||||
/// An opaque extension payload a game adapter asks Core to persist atomically
|
||||
/// alongside a canonical entity. `payload` is uninterpreted bytes-as-text.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct OpaqueExtensionWrite {
|
||||
/// Opaque adapter key, e.g. `"fifa17.squad.v1"`. Core treats it as a string.
|
||||
pub namespace: String,
|
||||
/// Adapter's payload schema version (distinct from the DB storage schema).
|
||||
pub schema_version: i64,
|
||||
/// Uninterpreted payload (the adapter's serialized game-only state).
|
||||
pub payload: String,
|
||||
}
|
||||
|
||||
impl OpaqueExtensionWrite {
|
||||
/// Generic bounds check — namespace non-empty/length, payload size. Semantic
|
||||
/// validation of the payload is the adapter's job; Core only guards size.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.namespace.is_empty() || self.namespace.len() > MAX_EXT_NAMESPACE_LEN {
|
||||
return Err(format!(
|
||||
"namespace length {} out of bounds (1..={MAX_EXT_NAMESPACE_LEN})",
|
||||
self.namespace.len()
|
||||
));
|
||||
}
|
||||
if self.payload.len() > MAX_EXT_PAYLOAD_BYTES {
|
||||
return Err(format!(
|
||||
"extension payload {} bytes exceeds max {MAX_EXT_PAYLOAD_BYTES}",
|
||||
self.payload.len()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A stored opaque extension row (read side). `canonical_fingerprint` is the
|
||||
/// server-computed fingerprint of the canonical entity at write time; a reader
|
||||
/// compares it against the entity's *current* fingerprint to detect staleness.
|
||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||
pub struct GameEntityExt {
|
||||
pub game_id: String,
|
||||
pub entity_kind: String,
|
||||
pub entity_id: String,
|
||||
pub namespace: String,
|
||||
pub schema_version: i64,
|
||||
pub canonical_fingerprint: String,
|
||||
pub payload: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -5,7 +5,6 @@ pub mod notification;
|
||||
pub mod club;
|
||||
pub mod draft;
|
||||
pub mod fut_champs;
|
||||
pub mod game_ext;
|
||||
pub mod event;
|
||||
pub mod season;
|
||||
pub mod market;
|
||||
|
||||
@@ -21,19 +21,6 @@ pub enum ObjectiveMetric {
|
||||
CoinsEarned,
|
||||
}
|
||||
|
||||
impl ObjectiveMetric {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ObjectiveMetric::MatchesWon => "matcheswon",
|
||||
ObjectiveMetric::MatchesPlayed => "matchesplayed",
|
||||
ObjectiveMetric::GoalsScored => "goalsscored",
|
||||
ObjectiveMetric::PacksOpened => "packsopened",
|
||||
ObjectiveMetric::SbcsCompleted => "sbcscompleted",
|
||||
ObjectiveMetric::CoinsEarned => "coinsearned",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Objective definition from data/objectives/*.json
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObjectiveDefinition {
|
||||
|
||||
@@ -8,22 +8,18 @@ pub struct Profile {
|
||||
pub username: String,
|
||||
pub level: i64,
|
||||
pub xp: i64,
|
||||
/// The game this profile belongs to (e.g. "fifa17", "fifa23"). Scopes all of
|
||||
/// this profile's downstream state so multiple games share one core + DB.
|
||||
pub game_id: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub fn new(username: impl Into<String>, game_id: impl Into<String>) -> Self {
|
||||
pub fn new(username: impl Into<String>) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
username: username.into(),
|
||||
level: 1,
|
||||
xp: 0,
|
||||
game_id: game_id.into(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
|
||||
@@ -96,7 +96,4 @@ pub struct SquadReplaced {
|
||||
pub slots_written: usize,
|
||||
pub evaluation: crate::services::squad_rules::SquadEvaluation,
|
||||
pub client_disagreements: Vec<crate::services::squad_rules::EvaluationComparison>,
|
||||
/// Server-computed deterministic fingerprint of the committed canonical squad
|
||||
/// (anchors any opaque game extension against stale projection).
|
||||
pub canonical_fingerprint: String,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{extract::State, Json};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -8,8 +7,8 @@ use crate::{
|
||||
services::{achievement as ach_svc, club as club_svc, profile as profile_svc},
|
||||
};
|
||||
|
||||
pub async fn get_achievements(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_achievements(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let _ = ach_svc::check_and_unlock(&state.pool, &state.achievement_defs, &profile.id, &club.id).await;
|
||||
let achievements = ach_svc::list_with_status(&state.pool, &state.achievement_defs).await?;
|
||||
|
||||
+28
-85
@@ -1,11 +1,9 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::{AppError, AppResult},
|
||||
extractors::GameId,
|
||||
error::AppResult,
|
||||
models::{club::Club, profile::CreateProfileRequest},
|
||||
seed,
|
||||
services::{club as club_svc, profile as profile_svc},
|
||||
@@ -13,12 +11,11 @@ use crate::{
|
||||
|
||||
pub async fn post_auth_local(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<CreateProfileRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let username = req.username.unwrap_or_else(|| "Player 1".into());
|
||||
|
||||
let profile = profile_svc::create_profile(&state.pool, &username, game.as_str()).await?;
|
||||
let profile = profile_svc::create_profile(&state.pool, &username).await?;
|
||||
|
||||
let club = Club::new(&profile.id, "OpenFUT FC", 5000);
|
||||
club_svc::create_club(&state.pool, &club).await?;
|
||||
@@ -34,105 +31,51 @@ pub async fn post_auth_local(
|
||||
|
||||
/// GET /auth/status — lightweight check: does a profile exist?
|
||||
/// Returns 200 `{ "has_profile": true/false }` without erroring.
|
||||
pub async fn get_auth_status(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles WHERE game_id = ?")
|
||||
.bind(game.as_str())
|
||||
pub async fn get_auth_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles")
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
Ok(Json(json!({ "has_profile": count > 0 })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResetRequest {
|
||||
pub confirm: Option<String>,
|
||||
}
|
||||
|
||||
/// POST /auth/reset — wipe all game data and start fresh.
|
||||
/// Requires `{"confirm":"reset"}` in the request body as a safeguard against
|
||||
/// accidental or unauthenticated calls. Deletes every user-data table in
|
||||
/// dependency order; schema (migrations) is preserved.
|
||||
pub async fn post_auth_reset(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<ResetRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
if req.confirm.as_deref() != Some("reset") {
|
||||
return Err(AppError::BadRequest(
|
||||
r#"include {"confirm":"reset"} in the request body to confirm data wipe"#.into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Multi-game: reset ONLY this game's profile subtree so a FIFA 17 reset never
|
||||
// wipes FIFA 23 (and vice versa). Resolve the game's profile + its clubs, then
|
||||
// delete their dependent rows in reverse-dependency order.
|
||||
let profile_id: Option<String> = sqlx::query_scalar(
|
||||
"SELECT id FROM profiles WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
|
||||
)
|
||||
.bind(game.as_str())
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
let Some(profile_id) = profile_id else {
|
||||
return Ok(Json(json!({
|
||||
"reset": true,
|
||||
"message": "nothing to reset for this game"
|
||||
})));
|
||||
};
|
||||
|
||||
let club_ids: Vec<String> = sqlx::query_scalar("SELECT id FROM clubs WHERE profile_id = ?")
|
||||
.bind(&profile_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
for club_id in &club_ids {
|
||||
sqlx::query(
|
||||
"DELETE FROM squad_players WHERE squad_id IN (SELECT id FROM squads WHERE club_id = ?)",
|
||||
)
|
||||
.bind(club_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
for table in ["squads", "owned_cards", "packs", "market_history"] {
|
||||
sqlx::query(&format!("DELETE FROM {table} WHERE club_id = ?"))
|
||||
.bind(club_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
for table in [
|
||||
/// Deletes every user-data table in dependency order. The schema tables
|
||||
/// (migrations) are left intact; calling POST /auth/local afterwards
|
||||
/// creates a new profile.
|
||||
pub async fn post_auth_reset(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
// Delete in reverse-dependency order to satisfy FK constraints
|
||||
// (SQLite FK enforcement is opt-in, but we follow the order anyway)
|
||||
let tables = [
|
||||
"player_achievements",
|
||||
"notifications",
|
||||
"fut_champs_sessions",
|
||||
"sbc_submissions",
|
||||
"objective_progress",
|
||||
"position_goals",
|
||||
"statistics",
|
||||
"seasons",
|
||||
"season_history",
|
||||
"matches",
|
||||
"market_listings",
|
||||
"squad_players",
|
||||
"squads",
|
||||
"owned_cards",
|
||||
"packs",
|
||||
"events",
|
||||
"draft_sessions",
|
||||
"daily_checkins",
|
||||
] {
|
||||
sqlx::query(&format!("DELETE FROM {table} WHERE profile_id = ?"))
|
||||
.bind(&profile_id)
|
||||
"settings",
|
||||
"clubs",
|
||||
"profiles",
|
||||
];
|
||||
|
||||
for table in &tables {
|
||||
sqlx::query(&format!("DELETE FROM {table}"))
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM clubs WHERE profile_id = ?")
|
||||
.bind(&profile_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM profiles WHERE id = ?")
|
||||
.bind(&profile_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// NOTE (follow-up): settings (global key/value), events (shared content),
|
||||
// market_listings (keyed by seller_name, includes the shared NPC market),
|
||||
// notifications and player_achievements are not yet game-scoped and are left
|
||||
// intact. They need a game/profile key before a per-game reset can cover them.
|
||||
tracing::info!(game = %game.as_str(), "Per-game reset performed");
|
||||
tracing::info!("Full game reset performed");
|
||||
Ok(Json(json!({
|
||||
"reset": true,
|
||||
"message": "All progress for this game has been wiped. Call POST /auth/local to start a new club."
|
||||
"message": "All progress has been wiped. Call POST /auth/local to start a new club."
|
||||
})))
|
||||
}
|
||||
|
||||
+3
-6
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
Json,
|
||||
@@ -61,7 +60,7 @@ pub async fn get_cards(
|
||||
let rarity_ok = query
|
||||
.rarity
|
||||
.as_ref()
|
||||
.map(|r| c.rarity.as_str().eq_ignore_ascii_case(r))
|
||||
.map(|r| format!("{:?}", c.rarity).to_lowercase() == r.to_lowercase())
|
||||
.unwrap_or(true);
|
||||
let pos_ok = query
|
||||
.position
|
||||
@@ -100,10 +99,9 @@ pub async fn get_cards(
|
||||
|
||||
pub async fn get_collection(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Query(query): Query<OwnedItemQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let owned = sqlx::query_as::<_, OwnedCard>(
|
||||
@@ -160,10 +158,9 @@ pub async fn get_collection(
|
||||
/// Quick-sell an owned card for instant coins. The card is removed from the collection.
|
||||
pub async fn delete_owned_card(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(owned_card_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let owned = sqlx::query_as::<_, OwnedCard>(
|
||||
|
||||
+9
-11
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::AppResult,
|
||||
@@ -9,8 +8,8 @@ use axum::{extract::State, Json};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub async fn get_club(State(state): State<AppState>, game: GameId) -> AppResult<Json<Club>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_club(State(state): State<AppState>) -> AppResult<Json<Club>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
Ok(Json(club))
|
||||
}
|
||||
@@ -23,10 +22,9 @@ pub struct UpdateClubRequest {
|
||||
|
||||
pub async fn put_club(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<UpdateClubRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let updated = club_svc::update_club(
|
||||
&state.pool,
|
||||
@@ -38,8 +36,8 @@ pub async fn put_club(
|
||||
Ok(Json(json!({ "club": updated })))
|
||||
}
|
||||
|
||||
pub async fn get_checkin_status(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_checkin_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let status = checkin_svc::get_status(&state.pool, &profile.id).await?;
|
||||
Ok(Json(json!({
|
||||
"available": status.available,
|
||||
@@ -50,8 +48,8 @@ pub async fn get_checkin_status(State(state): State<AppState>, game: GameId) ->
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn post_checkin(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn post_checkin(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let r = checkin_svc::claim(&state.pool, &profile.id, &club.id).await?;
|
||||
Ok(Json(json!({
|
||||
@@ -62,8 +60,8 @@ pub async fn post_checkin(State(state): State<AppState>, game: GameId) -> AppRes
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn get_milestones(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_milestones(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{extract::State, Json};
|
||||
use rand::{Rng, SeedableRng};
|
||||
use serde_json::{json, Value};
|
||||
@@ -10,8 +9,8 @@ use crate::{
|
||||
services::{club as club_svc, profile as profile_svc, season as season_svc},
|
||||
};
|
||||
|
||||
pub async fn get_division(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_division(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let _club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let season = season_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||
|
||||
@@ -37,14 +36,14 @@ pub async fn get_division(State(state): State<AppState>, game: GameId) -> AppRes
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn get_division_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_division_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let history = season_svc::get_history(&state.pool, &profile.id).await?;
|
||||
Ok(Json(json!({ "history": history, "total": history.len() })))
|
||||
}
|
||||
|
||||
pub async fn get_division_leaderboard(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_division_leaderboard(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let season = season_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||
|
||||
|
||||
+4
-9
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
Json,
|
||||
@@ -40,10 +39,9 @@ pub async fn get_draft_squad(
|
||||
/// until all 11 slots are filled.
|
||||
pub async fn post_draft_start(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Query(query): Query<DraftQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let difficulty = query.difficulty.as_deref().unwrap_or("professional");
|
||||
let session = draft_svc::start_draft(&state.pool, &state.card_db, &profile.id, difficulty).await?;
|
||||
Ok(Json(session))
|
||||
@@ -52,10 +50,9 @@ pub async fn post_draft_start(
|
||||
/// Get the current state of a draft session.
|
||||
pub async fn get_draft_session(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(session_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let session = draft_svc::get_draft(&state.pool, &state.card_db, &profile.id, &session_id).await?;
|
||||
Ok(Json(session))
|
||||
}
|
||||
@@ -72,11 +69,10 @@ pub struct PickRequest {
|
||||
/// and rewards (coins + optional pack) are granted automatically.
|
||||
pub async fn post_draft_pick(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(session_id): Path<String>,
|
||||
Json(req): Json<PickRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let session = draft_svc::pick_card(
|
||||
&state.pool,
|
||||
@@ -93,10 +89,9 @@ pub async fn post_draft_pick(
|
||||
/// Abandon an active draft session. No rewards are granted.
|
||||
pub async fn post_draft_abandon(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(session_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let result = draft_svc::abandon_draft(&state.pool, &profile.id, &session_id).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
+10
-13
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
@@ -13,8 +12,8 @@ use crate::{
|
||||
};
|
||||
|
||||
/// GET /fut-champs — current active session, or null if none.
|
||||
pub async fn get_fut_champs(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_fut_champs(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let session = champs_svc::get_active_session(&state.pool, &profile.id).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -24,8 +23,8 @@ pub async fn get_fut_champs(State(state): State<AppState>, game: GameId) -> AppR
|
||||
}
|
||||
|
||||
/// POST /fut-champs/start — open a new FUT Champions week.
|
||||
pub async fn post_start_fut_champs(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn post_start_fut_champs(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let session = champs_svc::start_session(&state.pool, &profile.id).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -43,11 +42,10 @@ pub struct ChampsMatchRequest {
|
||||
/// POST /fut-champs/:session_id/result — record a match in this session.
|
||||
pub async fn post_champs_result(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(session_id): Path<String>,
|
||||
Json(req): Json<ChampsMatchRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
|
||||
let session = champs_svc::record_match(
|
||||
&state.pool,
|
||||
@@ -77,10 +75,9 @@ pub async fn post_champs_result(
|
||||
/// POST /fut-champs/:session_id/claim — claim end-of-week rewards.
|
||||
pub async fn post_claim_champs_rewards(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(session_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let result = champs_svc::claim_rewards(
|
||||
@@ -96,8 +93,8 @@ pub async fn post_claim_champs_rewards(
|
||||
}
|
||||
|
||||
/// GET /fut-champs/history — past sessions, newest first.
|
||||
pub async fn get_champs_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_champs_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let history = champs_svc::get_history(&state.pool, &profile.id).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -107,8 +104,8 @@ pub async fn get_champs_history(State(state): State<AppState>, game: GameId) ->
|
||||
}
|
||||
|
||||
/// POST /rivals/claim-weekly — claim weekly Division Rivals reward.
|
||||
pub async fn post_claim_rivals_reward(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn post_claim_rivals_reward(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
// Ensure a season row exists
|
||||
|
||||
+7
-11
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
Json,
|
||||
@@ -13,8 +12,8 @@ use crate::{
|
||||
services::{club as club_svc, market as market_svc, profile as profile_svc},
|
||||
};
|
||||
|
||||
pub async fn get_trade_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_trade_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let trades = market_svc::get_trade_history(&state.pool, &club.id).await?;
|
||||
Ok(Json(json!({ "trades": trades, "total": trades.len() })))
|
||||
@@ -53,10 +52,9 @@ pub async fn get_market(
|
||||
|
||||
pub async fn post_market_buy(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<BuyListingRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let card = market_svc::buy_listing(&state.pool, &state.card_db, &club.id, &req).await?;
|
||||
@@ -67,10 +65,9 @@ pub async fn post_market_buy(
|
||||
|
||||
pub async fn post_market_sell(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<SellCardRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let new_balance = market_svc::sell_card(&state.pool, &state.card_db, &club.id, &req).await?;
|
||||
@@ -86,8 +83,8 @@ pub async fn post_market_refresh(State(state): State<AppState>) -> AppResult<Jso
|
||||
}
|
||||
|
||||
/// Return all active market listings posted by the current player's club.
|
||||
pub async fn get_my_listings(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_my_listings(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let listings = market_svc::get_listings_by_seller(&state.pool, &state.card_db, &club.id).await?;
|
||||
Ok(Json(json!({ "listings": listings, "total": listings.len() })))
|
||||
@@ -96,10 +93,9 @@ pub async fn get_my_listings(State(state): State<AppState>, game: GameId) -> App
|
||||
/// Cancel a player-posted listing and return the card to the collection.
|
||||
pub async fn delete_market_listing(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(listing_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
market_svc::cancel_listing(&state.pool, &club.id, &listing_id).await?;
|
||||
Ok(Json(json!({ "cancelled": listing_id })))
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
Json,
|
||||
@@ -21,10 +20,9 @@ pub struct MatchHistoryQuery {
|
||||
|
||||
pub async fn get_matches(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Query(query): Query<MatchHistoryQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
||||
|
||||
let matches = if let Some(mode) = &query.mode {
|
||||
@@ -65,10 +63,9 @@ pub async fn get_opponent(
|
||||
|
||||
pub async fn post_match_result(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<SubmitMatchRequest>,
|
||||
) -> AppResult<Json<MatchRewardResult>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let result =
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
@@ -17,8 +16,8 @@ use crate::{
|
||||
/// notifications (unclaimed objectives, expiring loans, season ending soon).
|
||||
/// Persistent notifications carry an `id` and `is_read` flag; dynamic ones
|
||||
/// have `id: null` and are always considered unread.
|
||||
pub async fn get_notifications(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_notifications(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
// ── Persistent notifications ─────────────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
@@ -12,8 +11,8 @@ use crate::{
|
||||
services::{club as club_svc, objective as obj_svc, profile as profile_svc},
|
||||
};
|
||||
|
||||
pub async fn get_objectives(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_objectives(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let objectives =
|
||||
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
|
||||
Ok(Json(json!({ "objectives": objectives })))
|
||||
@@ -21,10 +20,9 @@ pub async fn get_objectives(State(state): State<AppState>, game: GameId) -> AppR
|
||||
|
||||
pub async fn get_objective(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(objective_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let all =
|
||||
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
|
||||
let obj = all
|
||||
@@ -36,10 +34,9 @@ pub async fn get_objective(
|
||||
|
||||
pub async fn post_claim_objective_by_id(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(objective_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let reward = obj_svc::claim_objective(
|
||||
&state.pool,
|
||||
@@ -59,10 +56,9 @@ pub struct ClaimRequest {
|
||||
|
||||
pub async fn post_claim_objective(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<ClaimRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let reward = obj_svc::claim_objective(
|
||||
&state.pool,
|
||||
|
||||
+6
-9
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
@@ -20,10 +19,9 @@ pub struct BuyPackRequest {
|
||||
|
||||
pub async fn post_buy_pack(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<BuyPackRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let pack = pack_svc::buy_pack(
|
||||
&state.pool,
|
||||
@@ -56,8 +54,8 @@ pub async fn get_pack_store(State(state): State<AppState>) -> AppResult<Json<Val
|
||||
Ok(Json(json!({ "packs": store })))
|
||||
}
|
||||
|
||||
pub async fn get_packs(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_packs(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let packs = pack_svc::get_unopened_packs(&state.pool, &club.id).await?;
|
||||
|
||||
@@ -79,8 +77,8 @@ pub async fn get_packs(State(state): State<AppState>, game: GameId) -> AppResult
|
||||
}
|
||||
|
||||
/// Return recently opened packs with the card IDs they contained.
|
||||
pub async fn get_pack_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_pack_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let opened = sqlx::query_as::<_, crate::models::pack::Pack>(
|
||||
@@ -124,10 +122,9 @@ pub async fn get_pack_history(State(state): State<AppState>, game: GameId) -> Ap
|
||||
|
||||
pub async fn post_open_pack(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(pack_id): Path<String>,
|
||||
) -> AppResult<Json<PackOpenResult>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let result = pack_svc::open_pack(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::AppResult,
|
||||
@@ -8,8 +7,8 @@ use crate::{
|
||||
use axum::{extract::State, Json};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub async fn get_profile(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_profile(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let computed_level = level_for_xp(profile.xp);
|
||||
|
||||
let next_level = computed_level + 1;
|
||||
|
||||
+1
-3
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
@@ -30,10 +29,9 @@ pub async fn get_sbc(
|
||||
|
||||
pub async fn post_sbc_submit(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<SubmitSbcRequest>,
|
||||
) -> AppResult<Json<SbcResult>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let result = sbc_svc::submit_sbc(
|
||||
|
||||
+12
-153
@@ -1,25 +1,18 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::{AppError, AppResult},
|
||||
models::game_ext::OpaqueExtensionWrite,
|
||||
models::squad::{SaveSquadRequest, SlotAssignment, SquadReplacement},
|
||||
services::{
|
||||
club as club_svc, profile as profile_svc, squad as squad_svc,
|
||||
squad::SquadExtState,
|
||||
squad_rules::{ClientReportedEvaluation, DefaultSquadRules},
|
||||
},
|
||||
error::AppResult,
|
||||
models::squad::SaveSquadRequest,
|
||||
services::{club as club_svc, profile as profile_svc, squad as squad_svc},
|
||||
};
|
||||
|
||||
pub async fn get_squad(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_squad(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let (squad, players) = squad_svc::get_squad(&state.pool, &club.id).await?;
|
||||
@@ -28,8 +21,8 @@ pub async fn get_squad(State(state): State<AppState>, game: GameId) -> AppResult
|
||||
Ok(Json(squad_response(&squad, &players, chemistry)))
|
||||
}
|
||||
|
||||
pub async fn get_squads(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_squads(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let squads = squad_svc::list_squads(&state.pool, &club.id).await?;
|
||||
Ok(Json(json!({ "squads": squads })))
|
||||
@@ -37,10 +30,9 @@ pub async fn get_squads(State(state): State<AppState>, game: GameId) -> AppResul
|
||||
|
||||
pub async fn get_squad_by_id(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(squad_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let (squad, players) = squad_svc::get_squad_by_id(&state.pool, &club.id, &squad_id).await?;
|
||||
@@ -51,14 +43,13 @@ pub async fn get_squad_by_id(
|
||||
|
||||
pub async fn post_squad(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<SaveSquadRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
if !req.players.is_empty() {
|
||||
squad_svc::validate_formation(&state.pool, &state.card_db, &club.id, &req.players).await?;
|
||||
squad_svc::validate_formation(&state.pool, &state.card_db, &req.players).await?;
|
||||
}
|
||||
|
||||
let squad = squad_svc::save_squad(&state.pool, &state.card_db, &club.id, &req).await?;
|
||||
@@ -67,10 +58,9 @@ pub async fn post_squad(
|
||||
|
||||
pub async fn delete_squad(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(squad_id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
squad_svc::delete_squad(&state.pool, &club.id, &squad_id).await?;
|
||||
Ok(Json(json!({ "deleted": squad_id })))
|
||||
@@ -104,134 +94,3 @@ fn squad_response(
|
||||
"chemistry": chemistry,
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────── Game-extension-aware squad transport (host composition) ─────────
|
||||
//
|
||||
// These two routes expose the already-existing extension services
|
||||
// (`read_squad_with_ext` / `replace_squad_with_extension`) over HTTP so a game
|
||||
// host can read/write the canonical squad AND its opaque game extension in one
|
||||
// Core round-trip. They add no domain logic — Core still owns validation,
|
||||
// ownership, the atomic transaction, the server fingerprint, and staleness; it
|
||||
// never interprets the extension payload.
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ExtQuery {
|
||||
/// Opaque adapter namespace, e.g. `"fifa17.squad"`.
|
||||
pub namespace: String,
|
||||
}
|
||||
|
||||
/// `GET /squad/ext?namespace=…` — the active squad, its players, and its opaque
|
||||
/// extension with an explicit Fresh/Stale/Missing verdict. Never projects a
|
||||
/// stale blob; the caller decides policy.
|
||||
pub async fn get_squad_ext(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Query(q): Query<ExtQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let (squad, players, state_ext) =
|
||||
squad_svc::read_squad_with_ext(&state.pool, game.as_str(), &club.id, &q.namespace).await?;
|
||||
|
||||
let extension = match state_ext {
|
||||
SquadExtState::Fresh(row) => json!({
|
||||
"state": "fresh",
|
||||
"schema_version": row.schema_version,
|
||||
"payload": row.payload,
|
||||
"stored_fingerprint": row.canonical_fingerprint,
|
||||
}),
|
||||
SquadExtState::Stale {
|
||||
stored,
|
||||
current_fingerprint,
|
||||
} => json!({
|
||||
"state": "stale",
|
||||
"schema_version": stored.schema_version,
|
||||
"payload": stored.payload,
|
||||
"stored_fingerprint": stored.canonical_fingerprint,
|
||||
"current_fingerprint": current_fingerprint,
|
||||
}),
|
||||
SquadExtState::Missing => json!({ "state": "missing" }),
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
"squad": squad,
|
||||
"players": players,
|
||||
"extension": extension,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SlotReq {
|
||||
pub owned_card_id: String,
|
||||
pub slot: i64,
|
||||
#[serde(default)]
|
||||
pub is_captain: bool,
|
||||
#[serde(default)]
|
||||
pub is_on_bench: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ReplaceReq {
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub formation: Option<String>,
|
||||
pub slots: Vec<SlotReq>,
|
||||
#[serde(default)]
|
||||
pub client_reported: ClientReportedEvaluation,
|
||||
pub extension: OpaqueExtensionWrite,
|
||||
}
|
||||
|
||||
/// `PUT /squad/replace` — full-replacement of the active squad's canonical slots
|
||||
/// plus its opaque game extension, in ONE Core transaction. Resolves the active
|
||||
/// squad in place (creates one if none exists). Ownership, duplicate, and size
|
||||
/// validation happen inside the service before any write.
|
||||
pub async fn put_squad_replace(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<ReplaceReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
// Replace the club's active squad in place; if there is none yet, create it.
|
||||
let squad_id = match squad_svc::get_squad(&state.pool, &club.id).await {
|
||||
Ok((s, _)) => Some(s.id),
|
||||
Err(AppError::NotFound(_)) => None,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
let replacement = SquadReplacement {
|
||||
name: req.name,
|
||||
formation: req.formation,
|
||||
slots: req
|
||||
.slots
|
||||
.into_iter()
|
||||
.map(|s| SlotAssignment {
|
||||
owned_card_id: s.owned_card_id,
|
||||
slot: s.slot,
|
||||
is_captain: s.is_captain,
|
||||
is_on_bench: s.is_on_bench,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let out = squad_svc::replace_squad_with_extension(
|
||||
&state.pool,
|
||||
&state.card_db,
|
||||
&DefaultSquadRules,
|
||||
game.as_str(),
|
||||
&club.id,
|
||||
squad_id.as_deref(),
|
||||
&replacement,
|
||||
&req.client_reported,
|
||||
&req.extension,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"squad_id": out.squad.id,
|
||||
"canonical_fingerprint": out.canonical_fingerprint,
|
||||
"slots_written": out.slots_written,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
Json,
|
||||
@@ -13,8 +12,8 @@ use crate::{
|
||||
services::{profile as profile_svc, statistics as stats_svc},
|
||||
};
|
||||
|
||||
pub async fn get_statistics(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
pub async fn get_statistics(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||
let pos_goals = stats_svc::get_position_goals(&state.pool, &profile.id).await?;
|
||||
|
||||
@@ -37,10 +36,9 @@ pub struct HistoryQuery {
|
||||
|
||||
pub async fn get_statistics_history(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Query(query): Query<HistoryQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
||||
|
||||
let matches = sqlx::query_as::<_, Match>(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::extractors::GameId;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
@@ -28,11 +27,10 @@ pub struct ApplyChemStyleRequest {
|
||||
/// POST /collection/:owned_card_id/chemistry-style
|
||||
pub async fn post_apply_chemistry_style(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(owned_card_id): Path<String>,
|
||||
Json(req): Json<ApplyChemStyleRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let updated = upgrade_svc::apply_chemistry_style(
|
||||
@@ -65,11 +63,10 @@ pub struct ChangePositionRequest {
|
||||
/// POST /collection/:owned_card_id/position — costs 500 coins.
|
||||
pub async fn post_change_position(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(owned_card_id): Path<String>,
|
||||
Json(req): Json<ChangePositionRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let updated = upgrade_svc::change_position(
|
||||
@@ -100,11 +97,10 @@ pub struct ApplyTrainingRequest {
|
||||
/// POST /collection/:owned_card_id/training — applies a training boost (up to +3 OVR total).
|
||||
pub async fn post_apply_training(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Path(owned_card_id): Path<String>,
|
||||
Json(req): Json<ApplyTrainingRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
let updated =
|
||||
|
||||
+1
-186
@@ -1,23 +1,6 @@
|
||||
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 crate::{db::Pool, error::AppResult, models::pack::PackDefinition, services::pack as pack_svc};
|
||||
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.
|
||||
@@ -46,171 +29,3 @@ 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,
|
||||
})
|
||||
}
|
||||
|
||||
+11
-24
@@ -38,34 +38,21 @@ 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)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn by_rarity(&self, rarity: &str) -> Vec<&CardDefinition> {
|
||||
self.cards
|
||||
.values()
|
||||
.filter(|c| {
|
||||
let r = format!("{:?}", c.rarity).to_lowercase();
|
||||
r == rarity || rarity == "any"
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn all(&self) -> Vec<&CardDefinition> {
|
||||
self.cards.values().collect()
|
||||
}
|
||||
|
||||
+4
-14
@@ -88,18 +88,13 @@ pub async fn start_draft(
|
||||
let first_position = &pick_order[0];
|
||||
let candidates = pick_candidates(card_db, first_position, min_overall, CANDIDATES_PER_SLOT);
|
||||
|
||||
let pick_order_json = serde_json::to_string(&pick_order)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
|
||||
let candidates_json = serde_json::to_string(&candidates)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
|
||||
|
||||
let session = DraftSession {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
profile_id: profile_id.to_string(),
|
||||
difficulty: difficulty.to_string(),
|
||||
pick_order: pick_order_json,
|
||||
pick_order: serde_json::to_string(&pick_order).unwrap(),
|
||||
picks: "[]".to_string(),
|
||||
current_candidates: Some(candidates_json),
|
||||
current_candidates: Some(serde_json::to_string(&candidates).unwrap()),
|
||||
status: "active".to_string(),
|
||||
reward_coins: 0,
|
||||
reward_pack_id: None,
|
||||
@@ -190,22 +185,17 @@ pub async fn pick_card(
|
||||
let next_pos = &pick_order[next_index];
|
||||
let next_candidates =
|
||||
pick_candidates(card_db, next_pos, min_overall, CANDIDATES_PER_SLOT);
|
||||
{
|
||||
let candidates_json = serde_json::to_string(&next_candidates)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
|
||||
(
|
||||
Some(candidates_json),
|
||||
Some(serde_json::to_string(&next_candidates).unwrap()),
|
||||
"active".to_string(),
|
||||
0,
|
||||
None,
|
||||
0,
|
||||
None,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let picks_json = serde_json::to_string(&picks)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
|
||||
let picks_json = serde_json::to_string(&picks).unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE draft_sessions SET picks = ?, current_candidates = ?, status = ?, \
|
||||
|
||||
@@ -239,30 +239,16 @@ pub async fn claim_rivals_reward(
|
||||
pack_defs: &[PackDefinition],
|
||||
) -> AppResult<serde_json::Value> {
|
||||
// Fetch current season row (must exist)
|
||||
let row: Option<(i64, i64, i64, Option<String>)> = sqlx::query_as(
|
||||
"SELECT division, rivals_week_claimed, rivals_total_points, rivals_last_claimed_at \
|
||||
FROM seasons WHERE profile_id = ?",
|
||||
let row: Option<(i64, i64, i64)> = sqlx::query_as(
|
||||
"SELECT division, rivals_week_claimed, rivals_total_points FROM seasons WHERE profile_id = ?",
|
||||
)
|
||||
.bind(profile_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let (division, week_claimed, total_pts, last_claimed_at) =
|
||||
let (division, week_claimed, total_pts) =
|
||||
row.ok_or_else(|| AppError::NotFound("no season found — play a match first".into()))?;
|
||||
|
||||
// Enforce 24-hour cooldown between weekly reward claims
|
||||
if let Some(ref last_claimed) = last_claimed_at {
|
||||
if let Ok(last_time) = chrono::DateTime::parse_from_rfc3339(last_claimed) {
|
||||
let elapsed = chrono::Utc::now() - last_time.with_timezone(&chrono::Utc);
|
||||
if elapsed < chrono::Duration::hours(24) {
|
||||
let hours_remaining = 24 - elapsed.num_hours();
|
||||
return Err(AppError::Conflict(format!(
|
||||
"rivals weekly reward already claimed; try again in ~{hours_remaining}h"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let next_week = week_claimed + 1;
|
||||
let coins = rivals_weekly_coins(division);
|
||||
let new_balance = club_svc::add_coins(pool, club_id, coins).await?;
|
||||
@@ -285,13 +271,11 @@ pub async fn claim_rivals_reward(
|
||||
None
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100, \
|
||||
rivals_last_claimed_at = ? WHERE profile_id = ?",
|
||||
"UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100 \
|
||||
WHERE profile_id = ?",
|
||||
)
|
||||
.bind(next_week)
|
||||
.bind(&now)
|
||||
.bind(profile_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
//! Generic read/write for [`crate::models::game_ext`] opaque state.
|
||||
//!
|
||||
//! Core never interprets the payload. Writes happen INSIDE the owning entity's
|
||||
//! transaction (see `squad::replace_squad_with_extension`) so the canonical
|
||||
//! entity and its opaque extension commit atomically — there is deliberately no
|
||||
//! standalone "write extension" entry point that could desync the two.
|
||||
|
||||
use crate::db::Pool;
|
||||
use crate::error::AppResult;
|
||||
use crate::models::game_ext::GameEntityExt;
|
||||
|
||||
/// Fetch the stored opaque extension for a scoped entity, or `None`. The caller
|
||||
/// compares `canonical_fingerprint` against the entity's *current* fingerprint to
|
||||
/// decide freshness — this layer does not know how to fingerprint any entity.
|
||||
pub async fn get_ext(
|
||||
pool: &Pool,
|
||||
game_id: &str,
|
||||
entity_kind: &str,
|
||||
entity_id: &str,
|
||||
namespace: &str,
|
||||
) -> AppResult<Option<GameEntityExt>> {
|
||||
let row = sqlx::query_as::<_, GameEntityExt>(
|
||||
"SELECT game_id, entity_kind, entity_id, namespace, schema_version, \
|
||||
canonical_fingerprint, payload, updated_at FROM game_entity_ext \
|
||||
WHERE game_id = ? AND entity_kind = ? AND entity_id = ? AND namespace = ?",
|
||||
)
|
||||
.bind(game_id)
|
||||
.bind(entity_kind)
|
||||
.bind(entity_id)
|
||||
.bind(namespace)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
@@ -43,12 +43,11 @@ pub async fn refresh_npc_listings(
|
||||
card_db: &CardDb,
|
||||
event_defs: &[EventDefinition],
|
||||
) -> AppResult<usize> {
|
||||
// Clean up expired listings and previous NPC listings.
|
||||
// Player-posted listings (is_npc = 0) are intentionally preserved.
|
||||
// Clean up expired and unsold listings
|
||||
sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM market_listings WHERE sold = 0 AND is_npc = 1")
|
||||
sqlx::query("DELETE FROM market_listings WHERE sold = 0")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
@@ -108,8 +107,8 @@ pub async fn refresh_npc_listings(
|
||||
for listing in &listings_to_insert {
|
||||
sqlx::query(
|
||||
"INSERT INTO market_listings \
|
||||
(id, card_id, seller_name, price, listed_at, expires_at, sold, is_npc) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, 1)",
|
||||
(id, card_id, seller_name, price, listed_at, expires_at, sold) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0)",
|
||||
)
|
||||
.bind(&listing.id)
|
||||
.bind(&listing.card_id)
|
||||
@@ -191,10 +190,6 @@ pub async fn sell_card(
|
||||
club_id: &str,
|
||||
req: &SellCardRequest,
|
||||
) -> AppResult<i64> {
|
||||
if req.price < 0 {
|
||||
return Err(AppError::BadRequest("price must be non-negative".into()));
|
||||
}
|
||||
|
||||
let owned = sqlx::query_as::<_, crate::models::card::OwnedCard>(
|
||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
|
||||
chemistry_style, position_override, training_bonus \
|
||||
|
||||
@@ -94,12 +94,6 @@ pub async fn process_match(
|
||||
obj_defs: &[ObjectiveDefinition],
|
||||
ach_defs: &[AchievementDefinition],
|
||||
) -> AppResult<MatchRewardResult> {
|
||||
if req.goals_for < 0 || req.goals_against < 0 || req.goals_for > 99 || req.goals_against > 99 {
|
||||
return Err(crate::error::AppError::BadRequest(
|
||||
"goals_for and goals_against must each be between 0 and 99".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let outcome = if req.goals_for > req.goals_against {
|
||||
"win"
|
||||
} else if req.goals_for == req.goals_against {
|
||||
@@ -190,13 +184,9 @@ pub async fn process_match(
|
||||
objectives_updated.append(&mut c);
|
||||
|
||||
for obj_id in &objectives_updated {
|
||||
let display_name = obj_defs
|
||||
.iter()
|
||||
.find(|d| &d.id == obj_id)
|
||||
.map(|d| d.title.as_str())
|
||||
.unwrap_or(obj_id.as_str());
|
||||
let body = format!("\"{}\" is now complete. Claim your reward in Objectives.", display_name);
|
||||
let _ = notification::create(pool, "objective_complete", "Objective complete!", &body).await;
|
||||
let title = "Objective complete!";
|
||||
let body = format!("\"{}\" is now complete. Claim your reward in Objectives.", obj_id);
|
||||
let _ = notification::create(pool, "objective_complete", title, &body).await;
|
||||
}
|
||||
|
||||
// Decrement loan matches remaining for squad starters; collect expired loan IDs.
|
||||
|
||||
@@ -6,7 +6,6 @@ pub mod notification;
|
||||
pub mod draft;
|
||||
pub mod event;
|
||||
pub mod fut_champs;
|
||||
pub mod game_ext;
|
||||
pub mod inventory;
|
||||
pub mod season;
|
||||
pub mod market;
|
||||
|
||||
@@ -69,7 +69,7 @@ pub async fn increment_metric(
|
||||
|
||||
for def in defs
|
||||
.iter()
|
||||
.filter(|d| d.metric.as_str() == metric)
|
||||
.filter(|d| format!("{:?}", d.metric).to_lowercase() == metric)
|
||||
{
|
||||
let existing = sqlx::query_as::<_, ObjectiveProgress>(
|
||||
"SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?"
|
||||
|
||||
@@ -90,8 +90,8 @@ pub async fn open_pack(
|
||||
.all()
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
let r = c.rarity.as_str();
|
||||
rarities.contains(&r.to_string())
|
||||
let r = format!("{:?}", c.rarity).to_lowercase();
|
||||
rarities.contains(&r)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
|
||||
+8
-22
@@ -5,41 +5,33 @@ use crate::{
|
||||
};
|
||||
use chrono::Utc;
|
||||
|
||||
/// Fetch the active profile for a game. Single-profile-per-game: there is exactly
|
||||
/// one profile row per `game_id`, so we take the earliest for that game.
|
||||
pub async fn get_active_profile(pool: &Pool, game_id: &str) -> AppResult<Profile> {
|
||||
pub async fn get_active_profile(pool: &Pool) -> AppResult<Profile> {
|
||||
sqlx::query_as::<_, Profile>(
|
||||
"SELECT id, username, level, xp, game_id, created_at, updated_at \
|
||||
FROM profiles WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
|
||||
"SELECT id, username, level, xp, created_at, updated_at FROM profiles ORDER BY created_at ASC LIMIT 1"
|
||||
)
|
||||
.bind(game_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("no profile exists; call POST /auth/local first".into()))
|
||||
}
|
||||
|
||||
pub async fn create_profile(pool: &Pool, username: &str, game_id: &str) -> AppResult<Profile> {
|
||||
// Single-player per game: one profile per game_id, not one globally.
|
||||
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles WHERE game_id = ?")
|
||||
.bind(game_id)
|
||||
pub async fn create_profile(pool: &Pool, username: &str) -> AppResult<Profile> {
|
||||
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
if existing > 0 {
|
||||
return Err(AppError::Conflict(
|
||||
"a profile already exists for this game; OpenFUT is single-player per game".into(),
|
||||
"a profile already exists; OpenFUT is single-player only".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let profile = Profile::new(username, game_id);
|
||||
let profile = Profile::new(username);
|
||||
sqlx::query(
|
||||
"INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO profiles (id, username, level, xp, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&profile.id)
|
||||
.bind(&profile.username)
|
||||
.bind(profile.level)
|
||||
.bind(profile.xp)
|
||||
.bind(&profile.game_id)
|
||||
.bind(profile.created_at)
|
||||
.bind(profile.updated_at)
|
||||
.execute(pool)
|
||||
@@ -67,13 +59,7 @@ pub async fn add_xp_with_levelup(
|
||||
club_id: &str,
|
||||
xp_to_add: i64,
|
||||
) -> AppResult<Vec<LevelUpEvent>> {
|
||||
let profile = sqlx::query_as::<_, Profile>(
|
||||
"SELECT id, username, level, xp, game_id, created_at, updated_at FROM profiles WHERE id = ?",
|
||||
)
|
||||
.bind(profile_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("profile '{profile_id}' not found")))?;
|
||||
let profile = get_active_profile(pool).await?;
|
||||
let old_level = level_for_xp(profile.xp);
|
||||
let new_total_xp = profile.xp + xp_to_add;
|
||||
let new_level = level_for_xp(new_total_xp);
|
||||
|
||||
@@ -101,6 +101,9 @@ pub async fn record_match(
|
||||
// Grant rewards
|
||||
club::add_coins(pool, club_id, coins).await?;
|
||||
if let Some(pack_def) = pack_id {
|
||||
let dummy_pack_id = Uuid::new_v4().to_string();
|
||||
// Grant via pack system so it shows in inventory
|
||||
let _ = dummy_pack_id; // will use grant_pack instead
|
||||
pack::grant_pack(pool, club_id, pack_def).await?;
|
||||
}
|
||||
|
||||
|
||||
+3
-413
@@ -3,7 +3,6 @@ use crate::{
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
card::{CardDefinition, OwnedCard},
|
||||
game_ext::{GameEntityExt, OpaqueExtensionWrite},
|
||||
squad::{
|
||||
SaveSquadRequest, SlotAssignment, Squad, SquadPlayer, SquadPlayerInput, SquadReplaced,
|
||||
SquadReplacement,
|
||||
@@ -11,7 +10,6 @@ use crate::{
|
||||
},
|
||||
services::{
|
||||
card_db::CardDb,
|
||||
game_ext,
|
||||
squad_rules::{
|
||||
ClientReportedEvaluation, DefaultSquadRules, SquadPlayerCard, SquadRules, SquadSnapshot,
|
||||
},
|
||||
@@ -74,7 +72,6 @@ async fn get_players(pool: &Pool, squad_id: &str) -> AppResult<Vec<SquadPlayer>>
|
||||
pub async fn validate_formation(
|
||||
pool: &Pool,
|
||||
card_db: &CardDb,
|
||||
club_id: &str,
|
||||
players: &[SquadPlayerInput],
|
||||
) -> AppResult<()> {
|
||||
let starters: Vec<&SquadPlayerInput> = players.iter().filter(|p| !p.is_on_bench).collect();
|
||||
@@ -89,13 +86,12 @@ pub async fn validate_formation(
|
||||
let mut gk_count = 0usize;
|
||||
for sp in &starters {
|
||||
let owned = sqlx::query_as::<_, OwnedCard>(
|
||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ? AND club_id = ?",
|
||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ?",
|
||||
)
|
||||
.bind(&sp.owned_card_id)
|
||||
.bind(club_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("owned card {} not found or does not belong to this club", sp.owned_card_id)))?;
|
||||
.ok_or_else(|| AppError::NotFound(format!("owned card {} not found", sp.owned_card_id)))?;
|
||||
|
||||
if let Some(card) = card_db.get(&owned.card_id) {
|
||||
if card.position == "GK" {
|
||||
@@ -224,23 +220,15 @@ pub async fn calculate_chemistry(
|
||||
/// Validation happens BEFORE any write, so a rejected replacement leaves the
|
||||
/// existing squad exactly as it was. Everything that does write happens inside
|
||||
/// one transaction.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn replace_squad_inner(
|
||||
pub async fn replace_squad(
|
||||
pool: &Pool,
|
||||
card_db: &CardDb,
|
||||
rules: &dyn SquadRules,
|
||||
game_id: Option<&str>,
|
||||
club_id: &str,
|
||||
squad_id: Option<&str>,
|
||||
replacement: &SquadReplacement,
|
||||
client_reported: &ClientReportedEvaluation,
|
||||
ext: Option<&OpaqueExtensionWrite>,
|
||||
) -> AppResult<SquadReplaced> {
|
||||
// Generic bounds on the opaque extension, before any write (fail fast, no
|
||||
// partial state). Core guards size only — the adapter owns payload meaning.
|
||||
if let Some(ext) = ext {
|
||||
ext.validate().map_err(AppError::BadRequest)?;
|
||||
}
|
||||
// ── validate before touching anything ────────────────────────────────
|
||||
let mut seen: HashSet<&str> = HashSet::new();
|
||||
let mut slots_seen: HashSet<i64> = HashSet::new();
|
||||
@@ -369,35 +357,6 @@ async fn replace_squad_inner(
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Fingerprint the COMMITTED canonical state (server-computed; never a
|
||||
// client/adapter value) and, atomically in this same tx, persist the opaque
|
||||
// game extension anchored to it. Canonical squad + extension commit together
|
||||
// or not at all — no split-brain, no distributed protocol.
|
||||
let canonical_fingerprint = squad_fingerprint(
|
||||
&squad_id,
|
||||
&squad.formation,
|
||||
resolved
|
||||
.iter()
|
||||
.map(|(s, o)| (s.slot, o.id.as_str(), s.is_captain, s.is_on_bench)),
|
||||
);
|
||||
if let Some(ext) = ext {
|
||||
let gid = game_id.expect("game_id is required whenever an extension is written");
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO game_entity_ext \
|
||||
(game_id, entity_kind, entity_id, namespace, schema_version, canonical_fingerprint, payload, updated_at) \
|
||||
VALUES (?, 'squad', ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(gid)
|
||||
.bind(&squad_id)
|
||||
.bind(&ext.namespace)
|
||||
.bind(ext.schema_version)
|
||||
.bind(&canonical_fingerprint)
|
||||
.bind(&ext.payload)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
// ── evaluate with the game's rules, never with the client's numbers ──
|
||||
@@ -429,7 +388,6 @@ async fn replace_squad_inner(
|
||||
slots_written: resolved.len(),
|
||||
evaluation,
|
||||
client_disagreements,
|
||||
canonical_fingerprint,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -439,125 +397,6 @@ struct SlotAssignmentRef {
|
||||
is_on_bench: bool,
|
||||
}
|
||||
|
||||
/// Replace a squad's slots atomically (no game extension).
|
||||
pub async fn replace_squad(
|
||||
pool: &Pool,
|
||||
card_db: &CardDb,
|
||||
rules: &dyn SquadRules,
|
||||
club_id: &str,
|
||||
squad_id: Option<&str>,
|
||||
replacement: &SquadReplacement,
|
||||
client_reported: &ClientReportedEvaluation,
|
||||
) -> AppResult<SquadReplaced> {
|
||||
replace_squad_inner(
|
||||
pool,
|
||||
card_db,
|
||||
rules,
|
||||
None,
|
||||
club_id,
|
||||
squad_id,
|
||||
replacement,
|
||||
client_reported,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Replace a squad AND persist an opaque game extension in ONE transaction, so
|
||||
/// the canonical squad and its game-only round-trip state can never split-brain.
|
||||
/// The extension is anchored to the committed squad by a server-computed
|
||||
/// fingerprint; Core never interprets the payload.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn replace_squad_with_extension(
|
||||
pool: &Pool,
|
||||
card_db: &CardDb,
|
||||
rules: &dyn SquadRules,
|
||||
game_id: &str,
|
||||
club_id: &str,
|
||||
squad_id: Option<&str>,
|
||||
replacement: &SquadReplacement,
|
||||
client_reported: &ClientReportedEvaluation,
|
||||
ext: &OpaqueExtensionWrite,
|
||||
) -> AppResult<SquadReplaced> {
|
||||
replace_squad_inner(
|
||||
pool,
|
||||
card_db,
|
||||
rules,
|
||||
Some(game_id),
|
||||
club_id,
|
||||
squad_id,
|
||||
replacement,
|
||||
client_reported,
|
||||
Some(ext),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Deterministic, order-stable fingerprint of a squad's canonical state. Server-
|
||||
/// computed; non-cryptographic (FNV-1a-64) — a stale-extension guard, not a
|
||||
/// security boundary. The encoding is sorted + delimited so it never depends on
|
||||
/// row/iteration order.
|
||||
fn squad_fingerprint<'a>(
|
||||
squad_id: &str,
|
||||
formation: &str,
|
||||
slots: impl Iterator<Item = (i64, &'a str, bool, bool)>,
|
||||
) -> String {
|
||||
let mut items: Vec<String> = slots
|
||||
.map(|(slot, owned, cap, bench)| format!("{slot}:{owned}:{}:{}", cap as u8, bench as u8))
|
||||
.collect();
|
||||
items.sort();
|
||||
let canon = format!("v1|{squad_id}|{formation}|{}", items.join(";"));
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for b in canon.as_bytes() {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
format!("{h:016x}")
|
||||
}
|
||||
|
||||
/// Freshness of a squad's opaque extension vs the current canonical squad.
|
||||
pub enum SquadExtState {
|
||||
Fresh(GameEntityExt),
|
||||
Stale {
|
||||
stored: GameEntityExt,
|
||||
current_fingerprint: String,
|
||||
},
|
||||
Missing,
|
||||
}
|
||||
|
||||
/// Read a club's active squad, its players, and its opaque game extension for
|
||||
/// `namespace`, with an explicit freshness verdict. NEVER silently projects a
|
||||
/// stale blob — the caller decides policy on `Stale`/`Missing`.
|
||||
pub async fn read_squad_with_ext(
|
||||
pool: &Pool,
|
||||
game_id: &str,
|
||||
club_id: &str,
|
||||
namespace: &str,
|
||||
) -> AppResult<(Squad, Vec<SquadPlayer>, SquadExtState)> {
|
||||
let (squad, players) = get_squad(pool, club_id).await?;
|
||||
let current = squad_fingerprint(
|
||||
&squad.id,
|
||||
&squad.formation,
|
||||
players.iter().map(|p| {
|
||||
(
|
||||
p.position_index,
|
||||
p.owned_card_id.as_str(),
|
||||
p.is_captain,
|
||||
p.is_on_bench,
|
||||
)
|
||||
}),
|
||||
);
|
||||
let state = match game_ext::get_ext(pool, game_id, "squad", &squad.id, namespace).await? {
|
||||
None => SquadExtState::Missing,
|
||||
Some(row) if row.canonical_fingerprint == current => SquadExtState::Fresh(row),
|
||||
Some(row) => SquadExtState::Stale {
|
||||
stored: row,
|
||||
current_fingerprint: current,
|
||||
},
|
||||
};
|
||||
Ok((squad, players, state))
|
||||
}
|
||||
|
||||
/// Compatibility wrapper over [`replace_squad`].
|
||||
///
|
||||
/// Kept so the existing Core REST route keeps working, but it no longer has its
|
||||
@@ -888,253 +727,4 @@ mod tests {
|
||||
);
|
||||
assert_eq!(out.evaluation.rules, "openfut-default-v2");
|
||||
}
|
||||
|
||||
// ── opaque game-extension (co-located, single-transaction) ──────────────
|
||||
|
||||
const NS: &str = "fifa17.squad.v1";
|
||||
|
||||
fn ext(payload: &str) -> OpaqueExtensionWrite {
|
||||
OpaqueExtensionWrite {
|
||||
namespace: NS.into(),
|
||||
schema_version: 1,
|
||||
payload: payload.into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn replace_ext(
|
||||
pool: &Pool,
|
||||
db: &CardDb,
|
||||
game: &str,
|
||||
club: &str,
|
||||
id: Option<&str>,
|
||||
slots: Vec<SlotAssignment>,
|
||||
payload: &str,
|
||||
) -> AppResult<SquadReplaced> {
|
||||
replace_squad_with_extension(
|
||||
pool,
|
||||
db,
|
||||
&DefaultSquadRules,
|
||||
game,
|
||||
club,
|
||||
id,
|
||||
&SquadReplacement {
|
||||
name: Some("S".into()),
|
||||
formation: Some("4-4-2".into()),
|
||||
slots,
|
||||
},
|
||||
&ClientReportedEvaluation::default(),
|
||||
&ext(payload),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn squad_and_extension_commit_atomically_and_read_fresh() {
|
||||
let (pool, db) = fixture().await;
|
||||
let out = replace_ext(
|
||||
&pool,
|
||||
&db,
|
||||
"fifa17",
|
||||
"club-a",
|
||||
None,
|
||||
vec![slot("card-1", 0)],
|
||||
"{\"custom\":[1,2,3]}",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!out.canonical_fingerprint.is_empty());
|
||||
|
||||
let (_s, _p, state) = read_squad_with_ext(&pool, "fifa17", "club-a", NS)
|
||||
.await
|
||||
.unwrap();
|
||||
match state {
|
||||
SquadExtState::Fresh(row) => {
|
||||
assert_eq!(row.payload, "{\"custom\":[1,2,3]}");
|
||||
assert_eq!(row.schema_version, 1);
|
||||
assert_eq!(row.canonical_fingerprint, out.canonical_fingerprint);
|
||||
}
|
||||
_ => panic!("expected Fresh extension"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversized_extension_rejected_with_no_partial_write() {
|
||||
let (pool, db) = fixture().await;
|
||||
let big = "x".repeat(crate::models::game_ext::MAX_EXT_PAYLOAD_BYTES + 1);
|
||||
let err = replace_ext(
|
||||
&pool,
|
||||
&db,
|
||||
"fifa17",
|
||||
"club-a",
|
||||
None,
|
||||
vec![slot("card-1", 0)],
|
||||
&big,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(err, Err(AppError::BadRequest(_))),
|
||||
"oversized payload must be rejected"
|
||||
);
|
||||
// Fail-fast before the tx: no squad was created.
|
||||
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM squads WHERE club_id = 'club-a'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(n, 0, "rejected replacement leaves no partial squad");
|
||||
let e: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM game_entity_ext")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(e, 0, "no extension row written");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fingerprint_is_deterministic_and_placement_sensitive() {
|
||||
let (pool, db) = fixture().await;
|
||||
let a = replace_ext(
|
||||
&pool,
|
||||
&db,
|
||||
"fifa17",
|
||||
"club-a",
|
||||
None,
|
||||
vec![slot("card-1", 0), slot("card-2", 1)],
|
||||
"p",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Same placement again → identical fingerprint (idempotent, deterministic).
|
||||
let b = replace_ext(
|
||||
&pool,
|
||||
&db,
|
||||
"fifa17",
|
||||
"club-a",
|
||||
Some(&a.squad.id),
|
||||
vec![slot("card-1", 0), slot("card-2", 1)],
|
||||
"p",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(a.canonical_fingerprint, b.canonical_fingerprint);
|
||||
// Different placement (swap the two slots) → different fingerprint.
|
||||
let c = replace_ext(
|
||||
&pool,
|
||||
&db,
|
||||
"fifa17",
|
||||
"club-a",
|
||||
Some(&a.squad.id),
|
||||
vec![slot("card-1", 1), slot("card-2", 0)],
|
||||
"p",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(a.canonical_fingerprint, c.canonical_fingerprint);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_extension_is_detected_never_silently_fresh() {
|
||||
let (pool, db) = fixture().await;
|
||||
let first = replace_ext(
|
||||
&pool,
|
||||
&db,
|
||||
"fifa17",
|
||||
"club-a",
|
||||
None,
|
||||
vec![slot("card-1", 0)],
|
||||
"p1",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// A later plain replace (no extension) changes the canonical squad.
|
||||
replace(
|
||||
&pool,
|
||||
&db,
|
||||
"club-a",
|
||||
Some(&first.squad.id),
|
||||
vec![slot("card-2", 0)],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let (_s, _p, state) = read_squad_with_ext(&pool, "fifa17", "club-a", NS)
|
||||
.await
|
||||
.unwrap();
|
||||
match state {
|
||||
SquadExtState::Stale {
|
||||
stored,
|
||||
current_fingerprint,
|
||||
} => {
|
||||
assert_eq!(stored.canonical_fingerprint, first.canonical_fingerprint);
|
||||
assert_ne!(current_fingerprint, first.canonical_fingerprint);
|
||||
}
|
||||
_ => panic!("expected Stale extension after canonical squad changed"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idempotent_repeat_does_not_duplicate_extension() {
|
||||
let (pool, db) = fixture().await;
|
||||
let a = replace_ext(
|
||||
&pool,
|
||||
&db,
|
||||
"fifa17",
|
||||
"club-a",
|
||||
None,
|
||||
vec![slot("card-1", 0)],
|
||||
"same",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
replace_ext(
|
||||
&pool,
|
||||
&db,
|
||||
"fifa17",
|
||||
"club-a",
|
||||
Some(&a.squad.id),
|
||||
vec![slot("card-1", 0)],
|
||||
"same",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let rows: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM game_entity_ext WHERE entity_id = ?")
|
||||
.bind(&a.squad.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, 1, "identical repeat converges on one extension row");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extension_is_scoped_by_game_and_namespace() {
|
||||
let (pool, db) = fixture().await;
|
||||
let out = replace_ext(
|
||||
&pool,
|
||||
&db,
|
||||
"fifa17",
|
||||
"club-a",
|
||||
None,
|
||||
vec![slot("card-1", 0)],
|
||||
"p",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let sid = &out.squad.id;
|
||||
assert!(game_ext::get_ext(&pool, "fifa17", "squad", sid, NS)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some());
|
||||
assert!(
|
||||
game_ext::get_ext(&pool, "fifa17", "squad", sid, "other.ns")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"wrong namespace"
|
||||
);
|
||||
assert!(
|
||||
game_ext::get_ext(&pool, "fifa23", "squad", sid, NS)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"wrong game"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
//! 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::<i64>().is_err(),
|
||||
"nation must be a name, got {:?}",
|
||||
c.nation
|
||||
);
|
||||
assert!(
|
||||
c.league.parse::<i64>().is_err(),
|
||||
"league must be a name: {:?}",
|
||||
c.league
|
||||
);
|
||||
assert!(
|
||||
c.club.parse::<i64>().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");
|
||||
}
|
||||
@@ -675,7 +675,7 @@ async fn test_draft_pick_advances_session() {
|
||||
assert_eq!(pick1["status"], "active");
|
||||
assert_eq!(pick1["progress"]["filled"], 1);
|
||||
assert_eq!(pick1["current_position"], "RB");
|
||||
assert!(!pick1["candidates"].as_array().unwrap().is_empty());
|
||||
assert!(pick1["candidates"].as_array().unwrap().len() >= 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1404,14 +1404,10 @@ async fn test_rivals_reward_increments_week_counter() {
|
||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||
})).await;
|
||||
|
||||
// First claim succeeds
|
||||
json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await;
|
||||
let (s, json) = json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await;
|
||||
assert_eq!(s, StatusCode::OK, "{json}");
|
||||
assert_eq!(json["week_number"], 1, "first claim should be week 1");
|
||||
|
||||
// Immediate re-claim is blocked by the 24-hour cooldown
|
||||
let (s, _) = json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await;
|
||||
assert_eq!(s, StatusCode::CONFLICT, "re-claim within 24h should be rejected");
|
||||
assert_eq!(json["week_number"], 2, "second claim should be week 2");
|
||||
}
|
||||
|
||||
// ── Phase 15: Pack Store ──────────────────────────────────────────────────────
|
||||
@@ -1715,7 +1711,7 @@ async fn test_auth_reset_clears_profile() {
|
||||
})).await;
|
||||
|
||||
// Reset
|
||||
let (status, json) = json_post(&app, "/auth/reset", serde_json::json!({ "confirm": "reset" })).await;
|
||||
let (status, json) = json_post(&app, "/auth/reset", serde_json::json!({})).await;
|
||||
assert_eq!(status, StatusCode::OK, "{json}");
|
||||
assert_eq!(json["reset"], true);
|
||||
|
||||
@@ -1733,7 +1729,7 @@ async fn test_auth_reset_allows_new_profile() {
|
||||
let app = build_test_app().await;
|
||||
auth(&app, "FirstProfile").await;
|
||||
|
||||
json_post(&app, "/auth/reset", serde_json::json!({ "confirm": "reset" })).await;
|
||||
json_post(&app, "/auth/reset", serde_json::json!({})).await;
|
||||
|
||||
// Should be able to create a new profile after reset
|
||||
let (status, json) = json_post(&app, "/auth/local",
|
||||
@@ -2279,84 +2275,3 @@ async fn test_owned_query_parameter_order_invariance() {
|
||||
);
|
||||
assert_eq!(a["total"], b["total"]);
|
||||
}
|
||||
|
||||
|
||||
async fn json_put(app: &axum::Router, uri: &str, payload: Value) -> (StatusCode, Value) {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri(uri)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(payload.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = resp.status();
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
(status, serde_json::from_slice(&body).unwrap())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_squad_ext_replace_read_roundtrip_and_idempotency() {
|
||||
let app = build_test_app().await;
|
||||
auth(&app, "SquadExtUser").await;
|
||||
|
||||
// Owned cards from the starter pack.
|
||||
let (_, packs) = json_get(&app, "/packs").await;
|
||||
let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap().to_string();
|
||||
json_post(&app, &format!("/packs/open/{pack_id}"), serde_json::json!({})).await;
|
||||
let (_, coll) = json_get(&app, "/collection").await;
|
||||
let ids: Vec<String> = coll["collection"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.take(2)
|
||||
.map(|c| c["owned_card_id"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
assert!(ids.len() >= 2, "starter pack should yield >=2 owned cards");
|
||||
|
||||
let payload = "{\"custom\":\"[1,2,3]\",\"kit_numbers\":{}}";
|
||||
let body = serde_json::json!({
|
||||
"name": "OpenFUT",
|
||||
"formation": "f442",
|
||||
"slots": [
|
||||
{"owned_card_id": ids[0], "slot": 0, "is_captain": true, "is_on_bench": false},
|
||||
{"owned_card_id": ids[1], "slot": 1, "is_captain": false, "is_on_bench": false},
|
||||
],
|
||||
"client_reported": {
|
||||
"client_reported_chemistry": 52,
|
||||
"client_reported_rating": 90,
|
||||
"client_reported_star_rating": 90
|
||||
},
|
||||
"extension": {"namespace": "fifa17.squad", "schema_version": 1, "payload": payload},
|
||||
});
|
||||
|
||||
let (s, put) = json_put(&app, "/squad/replace", body.clone()).await;
|
||||
assert_eq!(s, StatusCode::OK, "{put}");
|
||||
assert_eq!(put["slots_written"], 2);
|
||||
let fp = put["canonical_fingerprint"].as_str().unwrap().to_string();
|
||||
|
||||
// Read the canonical squad + opaque extension back: Fresh, payload verbatim.
|
||||
let (s, ext) = json_get(&app, "/squad/ext?namespace=fifa17.squad").await;
|
||||
assert_eq!(s, StatusCode::OK, "{ext}");
|
||||
assert_eq!(ext["extension"]["state"], "fresh");
|
||||
assert_eq!(ext["extension"]["payload"], payload, "opaque payload round-trips verbatim");
|
||||
assert_eq!(ext["extension"]["schema_version"], 1);
|
||||
assert_eq!(ext["extension"]["stored_fingerprint"], fp);
|
||||
assert_eq!(ext["squad"]["formation"], "f442");
|
||||
assert_eq!(ext["players"].as_array().unwrap().len(), 2);
|
||||
|
||||
// Idempotent: an identical replacement converges to the same fingerprint.
|
||||
let (s2, put2) = json_put(&app, "/squad/replace", body).await;
|
||||
assert_eq!(s2, StatusCode::OK);
|
||||
assert_eq!(put2["canonical_fingerprint"], fp, "identical PUT is idempotent");
|
||||
|
||||
// A different namespace has no stored extension: Missing, never fabricated.
|
||||
let (_, other) = json_get(&app, "/squad/ext?namespace=other.ns").await;
|
||||
assert_eq!(other["extension"]["state"], "missing");
|
||||
}
|
||||
Reference in New Issue
Block a user