2 Commits

Author SHA1 Message Date
funman300 33b5ac1908 feat(club): semantic owned-item query + FIFA17 filter/pagination fix
Game-independent owned-inventory query (services::inventory::{OwnedItemQuery,
apply_query} + a Quality tier) that filters (AND) -> orders deterministically
(effective_overall desc, owned_card_id asc) -> paginates, wired into
GET /collection. Fixes the FIFA17 My Squad search: the Python oracle applied
only league+team and ignored level/rare/position/nation/start/count (proven by
response sha256 identity across pages -> the request-amplification bug); Core
now applies all proven filters and paginates. rare=SP left UNKNOWN.

Tests: +9 inventory unit, +14 /collection integration (full matrix incl. the
repeated-first-page regression); 9 mutations killed.

Isolated from an unrelated dirty working tree via a clean worktree at eab522a;
touches only the 5 slice files, no unrelated reformatting.
2026-08-11 21:22:57 +00:00
funman300 eab522a1eb squad: transactional replace_squad + a game-rules boundary for evaluation
Driven by a retail FIFA 17 capture: the client sends the WHOLE squad on
every save (~2KB, every slot/item/kit number), and a user swapping two
players produced nine changed slots across two saves. Slot deltas
therefore do not describe intent, so the only honest semantic operation
is "this is the squad now".

replace_squad, and why save_squad no longer has its own write path
------------------------------------------------------------------
save_squad UPDATEd the squad, DELETEd every squad_players row, then
INSERTed the new ones one at a time -- all outside a transaction. A
failure part-way through left a squad with some old players deleted and
only some new ones written: a state nobody asked for and no client can
detect. It also never checked that the cards being placed belonged to the
club, and accepted the same card in two slots.

replace_squad validates BEFORE any write (so a rejection leaves the
stored squad untouched) and performs every write in one transaction:

  - card must exist AND belong to this club
  - a card may occupy at most one slot
  - a slot may hold at most one card
  - slot indices must not be negative
  - the squad being replaced must belong to this club

save_squad is now a thin wrapper over it. That deliberately tightens the
existing Core REST route -- it now validates ownership and rejects
duplicates. Those were bugs, and two write paths with different
guarantees is how the stricter one gets bypassed.

A cross-club card is reported as NotFound, not Forbidden: whether a card
exists in someone else's club is not the caller's business.

Game-rules boundary
-------------------
Core contained calculate_chemistry -- a full FUT-style link-scoring
formula. Chemistry is game-specific and changed between FIFA
generations, so a formula compiled into generic Core quietly makes Core a
FIFA-something server.

It now sits behind SquadRules, with the existing implementation preserved
byte-for-byte in behaviour as DefaultSquadRules ("openfut-default-v2").
Rules take a resolved SquadSnapshot of pure data rather than a pool and a
card database, so they are synchronous, testable without fixtures, and
cannot reach Core's storage. Fifa17SquadRules is deliberately NOT
written: the algorithm is unproven and inventing one is worse than having
none.

Client-reported values
----------------------
FIFA sends its own chemistry/rating/starRating. ClientReportedEvaluation
is a DIFFERENT TYPE from SquadEvaluation, so assigning one where the
other belongs does not compile. Disagreement is reported through
EvaluationComparison and never reconciled in either direction -- the
server's value stands and the mismatch is surfaced for investigation
against the exact squad that produced it.

Evidence
--------
17 unit tests, 113 in the crate, 7/7 mutations killed including
"ownership check removed", "replacement becomes a merge" and
"client-reported chemistry becomes the server value".

Scope note: `cargo fmt` without -p reformatted ~27 unrelated files; those
were reverted so this commit touches only the squad path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:56:33 +00:00
59 changed files with 377 additions and 4497 deletions
-546
View File
@@ -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
}
]
-5
View File
@@ -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);
-3
View File
@@ -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;
-14
View File
@@ -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);
-23
View File
@@ -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,10 +0,0 @@
-- Generic provenance/rerun-identity token for a transactionally imported profile.
--
-- Set by the generic profile-import path (services::import). A NULL value means
-- the profile was created by normal gameplay / dev seeding, not an import, and
-- MUST NOT be silently clobbered by an import targeting the same game. A
-- matching token on a re-run is an idempotent no-op; a differing token against
-- an already-imported game fails until an explicit update mode exists.
--
-- Core never interprets the token's structure; the importer adapter chooses it.
ALTER TABLE profiles ADD COLUMN import_fingerprint TEXT;
+10 -96
View File
@@ -42,38 +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)?;
}
for pack in &cfg.content_packs {
card_db.load_pack(pack)?;
}
let card_db = Arc::new(card_db);
// Content preflight: every owned card MUST reference a loaded CardDefinition.
// A real profile with owned players but missing definitions fails LOUDLY here
// rather than silently serving an empty /collection. Empty owned_cards (fresh
// DB, tests) passes. A SINGLE missing definition is caught, not only the
// zero-loaded case.
{
let referenced: Vec<String> =
sqlx::query_scalar("SELECT DISTINCT card_id FROM owned_cards")
.fetch_all(&pool)
.await?;
let missing: Vec<String> = referenced
.into_iter()
.filter(|id| card_db.get(id).is_none())
.collect();
if !missing.is_empty() {
let sample: Vec<&String> = missing.iter().take(5).collect();
anyhow::bail!(
"content preflight failed: {} owned card(s) reference CardDefinitionId(s) not loaded (e.g. {:?}). Load the production content pack via OPENFUT_CONTENT_PACKS.",
missing.len(),
sample
);
}
}
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,32 +141,6 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/cards", get(routes::cards::get_cards))
.route("/cards/:card_id", get(routes::cards::get_card))
.route("/collection", get(routes::cards::get_collection))
.route("/economy/balance", get(routes::economy::get_balance))
.route(
"/economy/entitlements",
get(routes::economy::get_entitlements),
)
.route(
"/economy/purchase-entitlement",
post(routes::economy::post_purchase_entitlement),
)
.route(
"/economy/redeem-entitlement",
post(routes::economy::post_redeem_entitlement),
)
.route("/economy/sell-item", post(routes::economy::post_sell_item))
.route(
"/economy/grant-reward",
post(routes::economy::post_grant_reward),
)
.route(
"/economy/purchase-item",
post(routes::economy::post_purchase_item),
)
.route(
"/economy/purchase-items",
post(routes::economy::post_purchase_items),
)
.route(
"/collection/:owned_card_id",
delete(routes::cards::delete_owned_card),
@@ -225,16 +168,11 @@ 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))
.route("/objectives", get(routes::objectives::get_objectives))
.route(
"/objectives/:objective_id",
get(routes::objectives::get_objective),
)
.route("/objectives/:objective_id", get(routes::objectives::get_objective))
.route(
"/objectives/claim",
post(routes::objectives::post_claim_objective),
@@ -252,10 +190,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/market", get(routes::market::get_market))
.route("/market/buy", post(routes::market::post_market_buy))
.route("/market/sell", post(routes::market::post_market_sell))
.route(
"/market/trade-history",
get(routes::market::get_trade_history),
)
.route("/market/trade-history", get(routes::market::get_trade_history))
.route("/market/refresh", post(routes::market::post_market_refresh))
.route("/market/my-listings", get(routes::market::get_my_listings))
.route(
@@ -270,36 +205,15 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/settings", get(routes::settings::get_settings))
.route("/settings", put(routes::settings::put_settings))
.route("/division", get(routes::division::get_division))
.route(
"/division/history",
get(routes::division::get_division_history),
)
.route(
"/division/leaderboard",
get(routes::division::get_division_leaderboard),
)
.route("/division/history", get(routes::division::get_division_history))
.route("/division/leaderboard", get(routes::division::get_division_leaderboard))
.route("/achievements", get(routes::achievements::get_achievements))
.route(
"/notifications",
get(routes::notifications::get_notifications),
)
.route(
"/notifications/read-all",
post(routes::notifications::mark_all_notifications_read),
)
.route(
"/notifications/:id/read",
patch(routes::notifications::mark_notification_read),
)
.route("/notifications", get(routes::notifications::get_notifications))
.route("/notifications/read-all", post(routes::notifications::mark_all_notifications_read))
.route("/notifications/:id/read", patch(routes::notifications::mark_notification_read))
.route("/fut-champs", get(routes::fut_champs::get_fut_champs))
.route(
"/fut-champs/start",
post(routes::fut_champs::post_start_fut_champs),
)
.route(
"/fut-champs/history",
get(routes::fut_champs::get_champs_history),
)
.route("/fut-champs/start", post(routes::fut_champs::post_start_fut_champs))
.route("/fut-champs/history", get(routes::fut_champs::get_champs_history))
.route(
"/fut-champs/:session_id/result",
post(routes::fut_champs::post_champs_result),
+1 -30
View File
@@ -1,21 +1,12 @@
use anyhow::Result;
use std::path::PathBuf;
#[derive(Debug, Clone)]
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>,
/// Explicit PRODUCTION content pack file paths (each a `CardDefinition[]`
/// JSON), loaded IN ADDITION to `data/cards` and any dev pack. This is the
/// production real-profile content path — deliberately NOT gated behind the
/// dev-only `dev_content_games`.
pub content_packs: Vec<PathBuf>,
}
impl Config {
@@ -29,26 +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(),
content_packs: std::env::var("OPENFUT_CONTENT_PACKS")
.ok()
.map(|v| {
v.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.collect()
})
.unwrap_or_default(),
})
}
}
+9 -25
View File
@@ -1,40 +1,24 @@
use anyhow::Result;
use sqlx::{
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
ConnectOptions, Connection, SqlitePool,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
SqlitePool,
};
use std::str::FromStr;
use std::time::Duration;
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);
// Per-connection options so EVERY pooled connection gets them: WAL for
// reader/writer concurrency, foreign keys on, and a busy_timeout so a
// transient SQLITE_BUSY under concurrent access waits-and-retries.
let opts = SqliteConnectOptions::from_str(database_url)?
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.foreign_keys(true)
.busy_timeout(Duration::from_secs(5));
// Establish WAL on the file via ONE connection BEFORE the pool opens.
// Switching a fresh DB to WAL is a one-time file-level change; letting
// several pooled connections do it concurrently at warm-up races that
// switch and can surface a spurious lock. Serialize it here so every
// pooled connection thereafter only re-asserts an already-WAL file.
{
let mut conn = opts.clone().connect().await?;
sqlx::query("PRAGMA journal_mode=WAL")
.execute(&mut conn)
.await?;
conn.close().await?;
}
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")
.execute(&pool)
.await?;
sqlx::query("PRAGMA foreign_keys=ON").execute(&pool).await?;
Ok(pool)
}
-51
View File
@@ -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))
}
}
-3
View File
@@ -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,8 +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(),
content_packs: Vec::new(),
};
app::build(pool, cfg).await
}
+3 -44
View File
@@ -1,4 +1,4 @@
use anyhow::{Context, Result};
use anyhow::Result;
use openfut_core::{config, db, seed};
use tracing::info;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
@@ -12,54 +12,13 @@ async fn main() -> Result<()> {
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "openfut_core=debug,tower_http=debug".into()),
)
// Diagnostics on stderr so stdout carries only machine output (the
// `import`/`seed-dev` subcommands print a clean JSON result there).
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
.with(tracing_subscriber::fmt::layer())
.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(());
}
// Opt-in generic import subcommand: `openfut-core import <request.json>`.
// Reads a GAME-AGNOSTIC ProfileImportRequest (the importer adapter translates
// FIFA17 source data into it), loads production content packs, runs preflight,
// and applies one all-or-nothing transaction. FIFA17 semantics live entirely
// in the adapter; Core only sees opaque ids + opaque extension bytes.
if std::env::args().nth(1).as_deref() == Some("import") {
let path = std::env::args()
.nth(2)
.context("usage: openfut-core import <request.json>")?;
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)?;
for pack in &cfg.content_packs {
card_db.load_pack(pack)?;
}
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("read import request {path}"))?;
let req: openfut_core::services::import::ProfileImportRequest =
serde_json::from_str(&raw).context("parse import request JSON")?;
let outcome =
openfut_core::services::import::apply_profile_import(&pool, &card_db, &req).await?;
println!("{}", serde_json::to_string_pretty(&outcome)?);
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
View File
@@ -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)
}
+2
View File
@@ -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;
-14
View File
@@ -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
-60
View File
@@ -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,
}
-1
View File
@@ -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;
-13
View File
@@ -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 {
+1 -5
View File
@@ -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,
}
-3
View File
@@ -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,
}
+2 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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?;
+6 -7
View File
@@ -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
View File
@@ -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))
}
-163
View File
@@ -1,163 +0,0 @@
//! Generic economy HTTP boundary.
//!
//! Exposes [`crate::services::economy`] over the same game-scoped active-profile
//! resolution every other Core route uses ([`GameId`] header → active profile →
//! club). The caller (a game host) never supplies a club id; Core maps the game
//! to its authoritative club, so there is no cross-club economy access. Every
//! op is a single durable SQLite transaction in the service layer.
//!
//! This surface is deliberately game-neutral: no currency names, pack ids, or
//! wire semantics — those live in the game host/adapter.
use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
use crate::{
app::AppState,
error::AppResult,
extractors::GameId,
services::{club as club_svc, economy, economy::GrantedItem, profile as profile_svc},
};
/// Resolve the game-scoped active profile's club id.
async fn resolve_club(state: &AppState, game: &GameId) -> AppResult<String> {
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?;
Ok(club.id)
}
#[derive(Serialize)]
pub struct BalanceResponse {
pub balance: i64,
}
/// `GET /economy/balance` — the club's currency balance.
pub async fn get_balance(
State(state): State<AppState>,
game: GameId,
) -> AppResult<Json<BalanceResponse>> {
let club = resolve_club(&state, &game).await?;
let balance = economy::balance(&state.pool, &club).await?;
Ok(Json(BalanceResponse { balance }))
}
/// `GET /economy/entitlements` — the club's unconsumed entitlements.
pub async fn get_entitlements(
State(state): State<AppState>,
game: GameId,
) -> AppResult<Json<Vec<economy::Entitlement>>> {
let club = resolve_club(&state, &game).await?;
Ok(Json(
economy::list_unopened_entitlements(&state.pool, &club).await?,
))
}
#[derive(Deserialize)]
pub struct PurchaseEntitlementRequest {
pub cost: i64,
pub definition_id: String,
}
/// `POST /economy/purchase-entitlement` — atomic debit + grant.
pub async fn post_purchase_entitlement(
State(state): State<AppState>,
game: GameId,
Json(req): Json<PurchaseEntitlementRequest>,
) -> AppResult<Json<economy::PurchaseReceipt>> {
let club = resolve_club(&state, &game).await?;
Ok(Json(
economy::purchase_entitlement(&state.pool, &club, req.cost, &req.definition_id).await?,
))
}
#[derive(Deserialize)]
pub struct RedeemEntitlementRequest {
pub entitlement_id: String,
pub items: Vec<GrantedItem>,
}
#[derive(Serialize)]
pub struct RedeemEntitlementResponse {
pub definition_id: String,
}
/// `POST /economy/redeem-entitlement` — atomic consume-once + add items.
pub async fn post_redeem_entitlement(
State(state): State<AppState>,
game: GameId,
Json(req): Json<RedeemEntitlementRequest>,
) -> AppResult<Json<RedeemEntitlementResponse>> {
let club = resolve_club(&state, &game).await?;
let definition_id =
economy::redeem_entitlement(&state.pool, &club, &req.entitlement_id, &req.items).await?;
Ok(Json(RedeemEntitlementResponse { definition_id }))
}
#[derive(Deserialize)]
pub struct SellItemRequest {
pub item_id: String,
pub price: i64,
}
/// `POST /economy/sell-item` — atomic remove + credit.
pub async fn post_sell_item(
State(state): State<AppState>,
game: GameId,
Json(req): Json<SellItemRequest>,
) -> AppResult<Json<BalanceResponse>> {
let club = resolve_club(&state, &game).await?;
let balance = economy::sell_item(&state.pool, &club, &req.item_id, req.price).await?;
Ok(Json(BalanceResponse { balance }))
}
#[derive(Deserialize)]
pub struct GrantRewardRequest {
pub amount: i64,
}
/// `POST /economy/grant-reward` — atomic credit.
pub async fn post_grant_reward(
State(state): State<AppState>,
game: GameId,
Json(req): Json<GrantRewardRequest>,
) -> AppResult<Json<BalanceResponse>> {
let club = resolve_club(&state, &game).await?;
let balance = economy::grant_reward(&state.pool, &club, req.amount).await?;
Ok(Json(BalanceResponse { balance }))
}
#[derive(Deserialize)]
pub struct PurchaseItemRequest {
pub cost: i64,
pub item_id: String,
pub card_id: String,
}
/// `POST /economy/purchase-item` — atomic debit + mint item.
pub async fn post_purchase_item(
State(state): State<AppState>,
game: GameId,
Json(req): Json<PurchaseItemRequest>,
) -> AppResult<Json<BalanceResponse>> {
let club = resolve_club(&state, &game).await?;
let balance =
economy::purchase_item(&state.pool, &club, req.cost, &req.item_id, &req.card_id).await?;
Ok(Json(BalanceResponse { balance }))
}
#[derive(Deserialize)]
pub struct PurchaseItemsRequest {
pub cost: i64,
pub items: Vec<GrantedItem>,
}
/// `POST /economy/purchase-items` — atomic debit + mint several items.
pub async fn post_purchase_items(
State(state): State<AppState>,
game: GameId,
Json(req): Json<PurchaseItemsRequest>,
) -> AppResult<Json<BalanceResponse>> {
let club = resolve_club(&state, &game).await?;
let balance = economy::purchase_items(&state.pool, &club, req.cost, &req.items).await?;
Ok(Json(BalanceResponse { balance }))
}
+10 -13
View File
@@ -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
View File
@@ -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 })))
+2 -5
View File
@@ -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
View File
@@ -4,7 +4,6 @@ pub mod cards;
pub mod club;
pub mod division;
pub mod draft;
pub mod economy;
pub mod fut_champs;
pub mod events;
pub mod health;
+2 -3
View File
@@ -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 ─────────────────────────────────────────────
+5 -9
View File
@@ -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
View File
@@ -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(
+2 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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,
})))
}
+3 -5
View File
@@ -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>(
+3 -7
View File
@@ -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
View File
@@ -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 -41
View File
@@ -38,51 +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)
}
/// Merge an explicit PRODUCTION content pack file (a single
/// `CardDefinition[]`). Unlike [`CardDb::load_game_dev`] this takes a direct
/// path (the real-profile import emits one) and is the production content
/// path — not gated behind dev content. Returns the number merged.
pub fn load_pack(&mut self, path: &Path) -> Result<usize> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("reading 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 {} production card definitions from {:?}", n, path);
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
View File
@@ -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 = ?, \
-610
View File
@@ -1,610 +0,0 @@
//! Generic, game-agnostic economy authority.
//!
//! Exposes atomic, fail-closed economy operations over Core's existing durable
//! tables — it does **not** introduce a parallel persistence stack:
//!
//! * currency ledger -> `clubs.coins`
//! * owned inventory -> `owned_cards`
//! * entitlements -> `packs` (opaque `definition_id` + consume-once `opened`)
//!
//! Every compound operation (purchase, redeem, sell) runs inside a single SQLite
//! transaction, so a partial failure leaves no balance or inventory drift — the
//! pool-scoped helpers in [`crate::services::club`] cannot offer that guarantee
//! because their read/modify/write spans multiple pool round-trips.
//!
//! This module is deliberately game-neutral: currency names, entitlement/pack
//! ids, and per-save item-id sequences are per-game concerns that live in the
//! adapter which drives these primitives, never here.
use crate::{
db::Pool,
error::{AppError, AppResult},
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sqlx::SqliteConnection;
use uuid::Uuid;
/// An instance to place into a club's inventory when an entitlement is redeemed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GrantedItem {
/// Caller-minted opaque instance id. The adapter owns the id scheme; Core
/// treats it as an opaque unique key.
pub item_id: String,
/// Definition reference this instance resolves against.
pub card_id: String,
}
/// Outcome of a purchase: the post-debit balance and the new entitlement id.
#[derive(Debug, Clone, Serialize)]
pub struct PurchaseReceipt {
pub balance: i64,
pub entitlement_id: String,
}
// ---- transaction-scoped primitives -------------------------------------------
// Each takes a live connection (a transaction, reborrowed) so callers can compose
// several into one atomic unit. They never commit; the composed public op does.
async fn read_balance(conn: &mut SqliteConnection, club_id: &str) -> AppResult<i64> {
sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
.bind(club_id)
.fetch_optional(&mut *conn)
.await?
.ok_or_else(|| AppError::NotFound(format!("club not found: {club_id}")))
}
async fn debit(conn: &mut SqliteConnection, club_id: &str, amount: i64) -> AppResult<i64> {
if amount < 0 {
return Err(AppError::BadRequest(
"debit amount must be non-negative".into(),
));
}
let balance = read_balance(conn, club_id).await?;
if balance < amount {
return Err(AppError::BadRequest(format!(
"insufficient balance: have {balance}, need {amount}"
)));
}
let now = Utc::now().to_rfc3339();
sqlx::query("UPDATE clubs SET coins = coins - ?, updated_at = ? WHERE id = ?")
.bind(amount)
.bind(&now)
.bind(club_id)
.execute(&mut *conn)
.await?;
Ok(balance - amount)
}
async fn credit(conn: &mut SqliteConnection, club_id: &str, amount: i64) -> AppResult<i64> {
if amount < 0 {
return Err(AppError::BadRequest(
"credit amount must be non-negative".into(),
));
}
let balance = read_balance(conn, club_id).await?;
let now = Utc::now().to_rfc3339();
sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?")
.bind(amount)
.bind(&now)
.bind(club_id)
.execute(&mut *conn)
.await?;
Ok(balance + amount)
}
async fn grant_entitlement(
conn: &mut SqliteConnection,
club_id: &str,
definition_id: &str,
) -> AppResult<String> {
// Ensure the club exists so we never orphan an entitlement.
read_balance(conn, club_id).await?;
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, 0, ?)",
)
.bind(&id)
.bind(club_id)
.bind(definition_id)
.bind(&now)
.execute(&mut *conn)
.await?;
Ok(id)
}
/// Consume an unopened entitlement exactly once, returning its definition ref.
async fn consume_entitlement(
conn: &mut SqliteConnection,
club_id: &str,
entitlement_id: &str,
) -> AppResult<String> {
let row = sqlx::query_as::<_, (String, i64)>(
"SELECT definition_id, opened FROM packs WHERE id = ? AND club_id = ?",
)
.bind(entitlement_id)
.bind(club_id)
.fetch_optional(&mut *conn)
.await?;
let (definition_id, opened) =
row.ok_or_else(|| AppError::NotFound(format!("entitlement not found: {entitlement_id}")))?;
if opened != 0 {
return Err(AppError::Conflict(format!(
"entitlement already consumed: {entitlement_id}"
)));
}
sqlx::query("UPDATE packs SET opened = 1 WHERE id = ? AND club_id = ?")
.bind(entitlement_id)
.bind(club_id)
.execute(&mut *conn)
.await?;
Ok(definition_id)
}
async fn add_item(
conn: &mut SqliteConnection,
club_id: &str,
item_id: &str,
card_id: &str,
) -> AppResult<()> {
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \
VALUES (?, ?, ?, 0, NULL, ?)",
)
.bind(item_id)
.bind(club_id)
.bind(card_id)
.bind(&now)
.execute(&mut *conn)
.await?;
Ok(())
}
async fn remove_item(
conn: &mut SqliteConnection,
club_id: &str,
item_id: &str,
) -> AppResult<String> {
let card_id = sqlx::query_scalar::<_, String>(
"SELECT card_id FROM owned_cards WHERE id = ? AND club_id = ?",
)
.bind(item_id)
.bind(club_id)
.fetch_optional(&mut *conn)
.await?
.ok_or_else(|| AppError::NotFound(format!("item not owned by club: {item_id}")))?;
sqlx::query("DELETE FROM owned_cards WHERE id = ? AND club_id = ?")
.bind(item_id)
.bind(club_id)
.execute(&mut *conn)
.await?;
Ok(card_id)
}
// ---- composed atomic operations ----------------------------------------------
/// Read a club's current currency balance.
pub async fn balance(pool: &Pool, club_id: &str) -> AppResult<i64> {
sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("club not found: {club_id}")))
}
/// One unopened entitlement a club owns.
#[derive(Debug, Clone, Serialize)]
pub struct Entitlement {
pub id: String,
pub definition_id: String,
}
/// List a club's unconsumed entitlements (opened = 0), oldest first.
pub async fn list_unopened_entitlements(pool: &Pool, club_id: &str) -> AppResult<Vec<Entitlement>> {
let rows = sqlx::query_as::<_, (String, String)>(
"SELECT id, definition_id FROM packs WHERE club_id = ? AND opened = 0 ORDER BY created_at ASC, id ASC",
)
.bind(club_id)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(id, definition_id)| Entitlement { id, definition_id })
.collect())
}
/// Commit on `Ok`, roll back on `Err`. Paired with a `BEGIN IMMEDIATE` opened on
/// the same connection, so the write lock is held for the whole op and a
/// concurrent writer waits (honoring `busy_timeout`) instead of failing: a
/// DEFERRED `pool.begin()` upgrades to a write only at the first write, where
/// SQLite returns SQLITE_BUSY *immediately* (bypassing the busy handler to avoid
/// deadlock) — the fresh-DB multi-connection write failure.
async fn finish<T>(conn: &mut SqliteConnection, result: AppResult<T>) -> AppResult<T> {
match result {
Ok(v) => {
sqlx::query("COMMIT").execute(&mut *conn).await?;
Ok(v)
}
Err(e) => {
let _ = sqlx::query("ROLLBACK").execute(&mut *conn).await;
Err(e)
}
}
}
/// Debit `cost` and grant one entitlement, atomically. Fail-closed: if the club
/// cannot afford `cost`, nothing is debited and no entitlement is created.
pub async fn purchase_entitlement(
pool: &Pool,
club_id: &str,
cost: i64,
definition_id: &str,
) -> AppResult<PurchaseReceipt> {
let mut conn = pool.acquire().await?;
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
let result = async {
let balance = debit(&mut conn, club_id, cost).await?;
let entitlement_id = grant_entitlement(&mut conn, club_id, definition_id).await?;
Ok(PurchaseReceipt {
balance,
entitlement_id,
})
}
.await;
finish(&mut conn, result).await
}
/// Debit `cost` and mint one owned item, atomically. Fail-closed: if the club
/// cannot afford `cost`, nothing is debited and no item is added. This is the
/// "buy a specific item" primitive (a debit paired with an inventory add), for
/// synthetic-seller markets where the purchased item is minted rather than
/// transferred from another owner. Returns the post-debit balance.
pub async fn purchase_item(
pool: &Pool,
club_id: &str,
cost: i64,
item_id: &str,
card_id: &str,
) -> AppResult<i64> {
let mut conn = pool.acquire().await?;
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
let result = async {
let balance = debit(&mut conn, club_id, cost).await?;
add_item(&mut conn, club_id, item_id, card_id).await?;
Ok(balance)
}
.await;
finish(&mut conn, result).await
}
/// Debit `cost` and mint several owned items, atomically. Fail-closed: if the
/// club cannot afford `cost`, nothing is debited and no items are added; if any
/// item insert fails the whole purchase rolls back. This is the "buy + open"
/// primitive (Store packs that open on purchase): one debit paired with the
/// minted pack contents. Returns the post-debit balance.
pub async fn purchase_items(
pool: &Pool,
club_id: &str,
cost: i64,
items: &[GrantedItem],
) -> AppResult<i64> {
let mut conn = pool.acquire().await?;
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
let result = async {
let balance = debit(&mut conn, club_id, cost).await?;
for item in items {
add_item(&mut conn, club_id, &item.item_id, &item.card_id).await?;
}
Ok(balance)
}
.await;
finish(&mut conn, result).await
}
/// Consume an entitlement once and add its granted items, atomically. If any
/// item insert fails (e.g. a colliding instance id) the whole redemption rolls
/// back — the entitlement stays unconsumed and no items are persisted.
pub async fn redeem_entitlement(
pool: &Pool,
club_id: &str,
entitlement_id: &str,
items: &[GrantedItem],
) -> AppResult<String> {
let mut conn = pool.acquire().await?;
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
let result = async {
let definition_id = consume_entitlement(&mut conn, club_id, entitlement_id).await?;
for item in items {
add_item(&mut conn, club_id, &item.item_id, &item.card_id).await?;
}
Ok(definition_id)
}
.await;
finish(&mut conn, result).await
}
/// Remove an owned item and credit `price`, atomically. Fail-closed: if the item
/// is not owned by the club nothing is credited.
pub async fn sell_item(pool: &Pool, club_id: &str, item_id: &str, price: i64) -> AppResult<i64> {
let mut conn = pool.acquire().await?;
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
let result = async {
remove_item(&mut conn, club_id, item_id).await?;
credit(&mut conn, club_id, price).await
}
.await;
finish(&mut conn, result).await
}
/// Credit a reward to a club's balance atomically.
pub async fn grant_reward(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64> {
let mut conn = pool.acquire().await?;
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
let result = async { credit(&mut conn, club_id, amount).await }.await;
finish(&mut conn, result).await
}
#[cfg(test)]
mod tests {
use super::*;
const TS: &str = "2026-01-01T00:00:00Z";
/// In-memory pool with the real schema and one club (1000 coins) owning one
/// item (`item-x`). Mirrors the `squad` service test harness.
async fn fixture() -> Pool {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.connect("sqlite::memory:")
.await
.expect("in-memory sqlite");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrations");
sqlx::query(
"INSERT INTO profiles (id, username, created_at, updated_at) VALUES (?, ?, ?, ?)",
)
.bind("prof")
.bind("prof")
.bind(TS)
.bind(TS)
.execute(&pool)
.await
.expect("profile");
sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)")
.bind("club")
.bind("prof")
.bind("club")
.bind(1000i64)
.bind(TS)
.bind(TS)
.execute(&pool)
.await
.expect("club");
sqlx::query("INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) VALUES (?, ?, ?, 0, ?)")
.bind("item-x")
.bind("club")
.bind("def-x")
.bind(TS)
.execute(&pool)
.await
.expect("owned card");
pool
}
async fn pack_count(pool: &Pool) -> i64 {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM packs")
.fetch_one(pool)
.await
.unwrap()
}
async fn item_count(pool: &Pool, item_id: &str) -> i64 {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards WHERE id = ?")
.bind(item_id)
.fetch_one(pool)
.await
.unwrap()
}
#[tokio::test]
async fn balance_reads_seeded_value() {
let pool = fixture().await;
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
assert!(matches!(
balance(&pool, "ghost").await,
Err(AppError::NotFound(_))
));
}
#[tokio::test]
async fn purchase_debits_and_grants() {
let pool = fixture().await;
let receipt = purchase_entitlement(&pool, "club", 300, "def-pack")
.await
.unwrap();
assert_eq!(receipt.balance, 700);
assert_eq!(balance(&pool, "club").await.unwrap(), 700);
assert_eq!(pack_count(&pool).await, 1);
}
#[tokio::test]
async fn purchase_insufficient_funds_rolls_back() {
let pool = fixture().await;
let err = purchase_entitlement(&pool, "club", 5000, "def-pack")
.await
.unwrap_err();
assert!(matches!(err, AppError::BadRequest(_)));
// Nothing debited, no entitlement created.
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
assert_eq!(pack_count(&pool).await, 0);
}
#[tokio::test]
async fn negative_amount_is_rejected() {
let pool = fixture().await;
assert!(matches!(
purchase_entitlement(&pool, "club", -50, "def").await,
Err(AppError::BadRequest(_))
));
// sell with negative price hits the credit guard and rolls back the removal.
assert!(matches!(
sell_item(&pool, "club", "item-x", -1).await,
Err(AppError::BadRequest(_))
));
assert_eq!(item_count(&pool, "item-x").await, 1);
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
}
#[tokio::test]
async fn redeem_consumes_once_and_adds_items() {
let pool = fixture().await;
let ent = purchase_entitlement(&pool, "club", 100, "def-pack")
.await
.unwrap()
.entitlement_id;
let items = vec![GrantedItem {
item_id: "item-a".into(),
card_id: "def-a".into(),
}];
let def = redeem_entitlement(&pool, "club", &ent, &items)
.await
.unwrap();
assert_eq!(def, "def-pack");
assert_eq!(item_count(&pool, "item-a").await, 1);
// Second redeem of the same entitlement is rejected; inventory unchanged.
let err = redeem_entitlement(&pool, "club", &ent, &items)
.await
.unwrap_err();
assert!(matches!(err, AppError::Conflict(_)));
assert_eq!(item_count(&pool, "item-a").await, 1);
}
#[tokio::test]
async fn redeem_missing_entitlement_is_not_found() {
let pool = fixture().await;
assert!(matches!(
redeem_entitlement(&pool, "club", "no-such", &[]).await,
Err(AppError::NotFound(_))
));
}
#[tokio::test]
async fn redeem_partial_failure_rolls_back() {
let pool = fixture().await;
let ent = purchase_entitlement(&pool, "club", 100, "def-pack")
.await
.unwrap()
.entitlement_id;
// Second item collides with the first instance id -> PK violation mid-loop.
let items = vec![
GrantedItem {
item_id: "dup".into(),
card_id: "def-a".into(),
},
GrantedItem {
item_id: "dup".into(),
card_id: "def-b".into(),
},
];
let err = redeem_entitlement(&pool, "club", &ent, &items)
.await
.unwrap_err();
assert!(matches!(err, AppError::Database(_)));
// Whole redemption rolled back: entitlement still unconsumed, no items added.
assert_eq!(item_count(&pool, "dup").await, 0);
let def = redeem_entitlement(
&pool,
"club",
&ent,
&[GrantedItem {
item_id: "dup".into(),
card_id: "def-a".into(),
}],
)
.await
.unwrap();
assert_eq!(def, "def-pack");
assert_eq!(item_count(&pool, "dup").await, 1);
}
#[tokio::test]
async fn sell_removes_and_credits() {
let pool = fixture().await;
let new_balance = sell_item(&pool, "club", "item-x", 250).await.unwrap();
assert_eq!(new_balance, 1250);
assert_eq!(item_count(&pool, "item-x").await, 0);
// Selling it again fails; balance is unchanged.
assert!(matches!(
sell_item(&pool, "club", "item-x", 250).await,
Err(AppError::NotFound(_))
));
assert_eq!(balance(&pool, "club").await.unwrap(), 1250);
}
#[tokio::test]
async fn grant_reward_credits() {
let pool = fixture().await;
assert_eq!(grant_reward(&pool, "club", 500).await.unwrap(), 1500);
assert_eq!(balance(&pool, "club").await.unwrap(), 1500);
}
#[tokio::test]
async fn purchase_item_debits_and_mints() {
let pool = fixture().await;
let bal = purchase_item(&pool, "club", 400, "item-new", "def-new")
.await
.unwrap();
assert_eq!(bal, 600);
assert_eq!(balance(&pool, "club").await.unwrap(), 600);
assert_eq!(item_count(&pool, "item-new").await, 1);
}
#[tokio::test]
async fn purchase_item_insufficient_funds_rolls_back() {
let pool = fixture().await;
let err = purchase_item(&pool, "club", 9000, "item-new", "def-new")
.await
.unwrap_err();
assert!(matches!(err, AppError::BadRequest(_)));
// Nothing debited, no item minted.
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
assert_eq!(item_count(&pool, "item-new").await, 0);
}
#[tokio::test]
async fn purchase_items_debits_and_mints_all() {
let pool = fixture().await;
let items = vec![
GrantedItem {
item_id: "p-1".into(),
card_id: "d-1".into(),
},
GrantedItem {
item_id: "p-2".into(),
card_id: "d-2".into(),
},
];
let bal = purchase_items(&pool, "club", 700, &items).await.unwrap();
assert_eq!(bal, 300);
assert_eq!(item_count(&pool, "p-1").await, 1);
assert_eq!(item_count(&pool, "p-2").await, 1);
}
#[tokio::test]
async fn purchase_items_insufficient_funds_rolls_back() {
let pool = fixture().await;
let items = vec![GrantedItem {
item_id: "p-1".into(),
card_id: "d-1".into(),
}];
let err = purchase_items(&pool, "club", 9000, &items)
.await
.unwrap_err();
assert!(matches!(err, AppError::BadRequest(_)));
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
assert_eq!(item_count(&pool, "p-1").await, 0);
}
}
+5 -21
View File
@@ -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?;
-34
View File
@@ -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)
}
-341
View File
@@ -1,341 +0,0 @@
//! Generic, game-agnostic transactional profile import.
//!
//! Core installs a profile + club + owned cards + canonical squad + one opaque
//! game extension in a SINGLE all-or-nothing SQLite transaction, stamped with a
//! generic `source_fingerprint` provenance token. Core NEVER interprets FIFA17
//! wire ids, resourceIds, `nextItemId`, or the extension payload — the
//! `openfut-import-fifa17` adapter reads the Python profile, chooses every
//! `CardDefinitionId` and every opaque `OwnedItemId`, builds the squad
//! extension bytes, and hands Core this generic request.
//!
//! Invariants enforced here:
//! - Definition preflight: every incoming `card_id` MUST already resolve in the
//! loaded production content, so the transaction never creates ownership
//! pointing at absent content.
//! - Squad all-or-nothing: every active-squad `owned_item_id` MUST be among the
//! imported ownership set before the transaction begins.
//! - Rerun identity: identical `source_fingerprint` against an already-imported
//! game is an idempotent no-op; a differing token fails; a pre-existing
//! non-imported profile is never clobbered.
//! - The whole thing commits together or not at all.
use crate::db::Pool;
use crate::models::game_ext::{MAX_EXT_NAMESPACE_LEN, MAX_EXT_PAYLOAD_BYTES};
use crate::services::card_db::CardDb;
use crate::services::squad::squad_fingerprint;
use anyhow::{bail, Context, Result};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct ImportProfile {
pub username: String,
pub game_id: String,
}
#[derive(Debug, Deserialize)]
pub struct ImportClub {
pub name: String,
#[serde(default)]
pub coins: i64,
}
#[derive(Debug, Deserialize)]
pub struct ImportOwnedCard {
/// Opaque, stable Core OwnedItemId chosen by the adapter. Core never parses
/// why it is stable — it is a primary key, nothing more.
pub owned_item_id: String,
/// CardDefinitionId that MUST resolve in loaded production content.
pub card_id: String,
}
#[derive(Debug, Deserialize)]
pub struct ImportEntitlement {
/// Opaque definition reference for one unconsumed entitlement (e.g. a pack
/// id as text). Core stores it verbatim; it never interprets the value.
pub definition_id: String,
}
#[derive(Debug, Deserialize)]
pub struct ImportSlot {
pub owned_item_id: String,
pub position_index: i64,
#[serde(default)]
pub is_captain: bool,
#[serde(default)]
pub is_on_bench: bool,
}
#[derive(Debug, Deserialize)]
pub struct ImportExtension {
/// Opaque adapter key, e.g. "fifa17.squad.v1".
pub namespace: String,
/// Adapter payload version (distinct from DB storage schema).
pub schema_version: i64,
/// Uninterpreted bytes-as-text. Core enforces only generic size bounds.
pub payload: String,
}
#[derive(Debug, Deserialize)]
pub struct ImportSquad {
pub formation: String,
#[serde(default = "default_squad_name")]
pub name: String,
pub slots: Vec<ImportSlot>,
pub extension: ImportExtension,
}
fn default_squad_name() -> String {
"My Squad".to_string()
}
#[derive(Debug, Deserialize)]
pub struct ProfileImportRequest {
/// Generic provenance/rerun-identity token. Core stores it verbatim.
pub source_fingerprint: String,
pub profile: ImportProfile,
pub club: ImportClub,
pub owned: Vec<ImportOwnedCard>,
#[serde(default)]
pub squad: Option<ImportSquad>,
/// Unconsumed entitlements to seed (e.g. from a source's unopened packs).
#[serde(default)]
pub entitlements: Vec<ImportEntitlement>,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum ImportOutcome {
/// A fresh import committed.
Imported { owned: usize, squad_slots: usize },
/// The same fingerprint was already imported for this game — no-op.
AlreadyImported,
}
/// Apply a generic transactional profile import. See module docs for invariants.
pub async fn apply_profile_import(
pool: &Pool,
card_db: &CardDb,
req: &ProfileImportRequest,
) -> Result<ImportOutcome> {
// ── 0. generic input validation (no writes) ──
if req.source_fingerprint.trim().is_empty() {
bail!("source_fingerprint must be non-empty");
}
if req.owned.is_empty() {
bail!("import request has zero owned cards; refusing to import an empty profile");
}
// ── 1. rerun identity / single-profile-per-game ──
let existing: Option<(String, Option<String>)> = sqlx::query_as(
"SELECT id, import_fingerprint FROM profiles \
WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
)
.bind(&req.profile.game_id)
.fetch_optional(pool)
.await?;
if let Some((_id, fp)) = existing {
match fp {
Some(fp) if fp == req.source_fingerprint => return Ok(ImportOutcome::AlreadyImported),
Some(fp) => bail!(
"game '{}' already imported from a different source (stored fingerprint {fp}, \
incoming {}); refusing to overwrite without an explicit update mode",
req.profile.game_id,
req.source_fingerprint
),
None => bail!(
"game '{}' already has a non-imported profile; refusing to clobber it",
req.profile.game_id
),
}
}
// ── 2. definition preflight: every card_id MUST resolve in loaded content ──
let mut missing: Vec<&str> = req
.owned
.iter()
.filter(|o| card_db.get(&o.card_id).is_none())
.map(|o| o.card_id.as_str())
.collect();
if !missing.is_empty() {
missing.sort_unstable();
missing.dedup();
let sample = &missing[..missing.len().min(5)];
bail!(
"definition preflight failed: {} owned card(s) reference CardDefinitionId(s) not in \
loaded content (e.g. {sample:?}); refusing to create ownership pointing at absent content",
missing.len()
);
}
// ── 3. owned-item-id uniqueness ──
let mut owned_ids: HashSet<&str> = HashSet::with_capacity(req.owned.len());
for o in &req.owned {
if !owned_ids.insert(o.owned_item_id.as_str()) {
bail!(
"duplicate OwnedItemId in import request: {}",
o.owned_item_id
);
}
}
// ── 4. squad all-or-nothing + generic extension bounds (no writes) ──
if let Some(sq) = &req.squad {
let ns_len = sq.extension.namespace.len();
if ns_len == 0 || ns_len > MAX_EXT_NAMESPACE_LEN {
bail!(
"extension namespace length {ns_len} out of bounds (1..={MAX_EXT_NAMESPACE_LEN})"
);
}
if sq.extension.payload.len() > MAX_EXT_PAYLOAD_BYTES {
bail!(
"extension payload {} bytes exceeds MAX_EXT_PAYLOAD_BYTES ({MAX_EXT_PAYLOAD_BYTES})",
sq.extension.payload.len()
);
}
for slot in &sq.slots {
if !owned_ids.contains(slot.owned_item_id.as_str()) {
bail!(
"active squad references OwnedItemId {} not present in imported ownership set; \
squad import is all-or-nothing",
slot.owned_item_id
);
}
}
}
// ── 5. single transaction: everything commits together or not at all ──
let now = Utc::now().to_rfc3339();
let profile_id = Uuid::new_v4().to_string();
let club_id = Uuid::new_v4().to_string();
let mut tx = pool.begin().await?;
sqlx::query(
"INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at, import_fingerprint) \
VALUES (?, ?, 1, 0, ?, ?, ?, ?)",
)
.bind(&profile_id)
.bind(&req.profile.username)
.bind(&req.profile.game_id)
.bind(&now)
.bind(&now)
.bind(&req.source_fingerprint)
.execute(&mut *tx)
.await
.context("insert profile")?;
sqlx::query(
"INSERT INTO clubs (id, profile_id, name, coins, level, created_at, updated_at) \
VALUES (?, ?, ?, ?, 1, ?, ?)",
)
.bind(&club_id)
.bind(&profile_id)
.bind(&req.club.name)
.bind(req.club.coins)
.bind(&now)
.bind(&now)
.execute(&mut *tx)
.await
.context("insert club")?;
for o in &req.owned {
sqlx::query(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \
VALUES (?, ?, ?, 0, NULL, ?)",
)
.bind(&o.owned_item_id)
.bind(&club_id)
.bind(&o.card_id)
.bind(&now)
.execute(&mut *tx)
.await
.with_context(|| format!("insert owned_card {}", o.owned_item_id))?;
}
for e in &req.entitlements {
sqlx::query(
"INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, 0, ?)",
)
.bind(Uuid::new_v4().to_string())
.bind(&club_id)
.bind(&e.definition_id)
.bind(&now)
.execute(&mut *tx)
.await
.with_context(|| format!("insert entitlement {}", e.definition_id))?;
}
let mut squad_slots = 0usize;
if let Some(sq) = &req.squad {
let squad_id = Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&squad_id)
.bind(&club_id)
.bind(&sq.name)
.bind(&sq.formation)
.bind(&now)
.bind(&now)
.execute(&mut *tx)
.await
.context("insert squad")?;
for slot in &sq.slots {
sqlx::query(
"INSERT INTO squad_players (id, squad_id, owned_card_id, position_index, is_captain, is_on_bench) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(Uuid::new_v4().to_string())
.bind(&squad_id)
.bind(&slot.owned_item_id)
.bind(slot.position_index)
.bind(slot.is_captain)
.bind(slot.is_on_bench)
.execute(&mut *tx)
.await
.context("insert squad_player")?;
}
squad_slots = sq.slots.len();
// Core computes the canonical fingerprint over the COMMITTED squad — never
// an adapter-supplied value — and persists the opaque extension atomically
// in the same tx, exactly as the live squad-write path does.
let canonical_fingerprint = squad_fingerprint(
&squad_id,
&sq.formation,
sq.slots.iter().map(|s| {
(
s.position_index,
s.owned_item_id.as_str(),
s.is_captain,
s.is_on_bench,
)
}),
);
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(&req.profile.game_id)
.bind(&squad_id)
.bind(&sq.extension.namespace)
.bind(sq.extension.schema_version)
.bind(&canonical_fingerprint)
.bind(&sq.extension.payload)
.bind(&now)
.execute(&mut *tx)
.await
.context("insert game_entity_ext")?;
}
tx.commit().await?;
Ok(ImportOutcome::Imported {
owned: req.owned.len(),
squad_slots,
})
}
+4 -9
View File
@@ -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 \
+3 -13
View File
@@ -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.
+2 -5
View File
@@ -2,21 +2,18 @@ pub mod achievement;
pub mod card_db;
pub mod checkin;
pub mod club;
pub mod notification;
pub mod draft;
pub mod economy;
pub mod event;
pub mod fut_champs;
pub mod game_ext;
pub mod import;
pub mod inventory;
pub mod season;
pub mod market;
pub mod match_service;
pub mod notification;
pub mod objective;
pub mod pack;
pub mod profile;
pub mod sbc;
pub mod season;
pub mod settings;
pub mod squad;
pub mod squad_rules;
+1 -1
View File
@@ -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 = ?"
+2 -2
View File
@@ -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
View File
@@ -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);
+3
View File
@@ -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
View File
@@ -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.
pub(crate) 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"
);
}
}
-65
View File
@@ -1,65 +0,0 @@
//! Reproduction for the fresh-DB multi-connection warm-up write failure.
//! Forces several pooled connections to open concurrently on a brand-new DB and
//! captures the ACTUAL sqlx/SQLite error (not the service's generic string).
use openfut_core::db::{init_pool, run_migrations};
use openfut_core::services::economy;
async fn seed_club(pool: &sqlx::SqlitePool) {
sqlx::query(
"INSERT INTO profiles (id, username, created_at, updated_at) VALUES ('p','p','t','t')",
)
.execute(pool)
.await
.unwrap();
sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES ('c','p','c',100000,'t','t')")
.execute(pool)
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn fresh_db_multiconn_concurrent_writes() {
let base = std::env::temp_dir().join(format!("ofut-cc-{}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
let iters = 100usize;
let mut failures = 0usize;
let mut first_err = String::new();
for i in 0..iters {
let url = format!("sqlite://{}/db{i}.db", base.display());
let pool = init_pool(&url, 5).await.expect("init_pool");
run_migrations(&pool).await.expect("migrations");
seed_club(&pool).await;
// Fire concurrent credits to force several connections to warm up at once
// on the brand-new DB, then a write — the harness's failing shape.
let mut handles = Vec::new();
for _ in 0..8 {
let p = pool.clone();
handles.push(tokio::spawn(async move {
economy::grant_reward(&p, "c", 1).await
}));
}
for h in handles {
match h.await.unwrap() {
Ok(_) => {}
Err(e) => {
failures += 1;
if first_err.is_empty() {
first_err = format!("{e:?}");
}
}
}
}
// Serialization correctness: 8 concurrent +1 credits, no lost update.
let bal = economy::balance(&pool, "c").await.unwrap();
assert_eq!(bal, 100_008, "iter {i}: lost update under concurrency");
pool.close().await;
}
std::fs::remove_dir_all(&base).ok();
assert_eq!(
failures,
0,
"{failures}/{} iterations had a write failure; first error: {first_err}",
iters * 8
);
}
-104
View File
@@ -1,104 +0,0 @@
//! Content preflight: a real profile with owned players but a missing
//! CardDefinition must fail startup LOUDLY, never serve a silent empty club.
use axum::{
body::Body,
http::{Request, StatusCode},
};
use openfut_core::services::card_db::CardDb;
use tower::ServiceExt;
async fn fresh_pool() -> sqlx::SqlitePool {
let p = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("in-memory sqlite");
sqlx::migrate!("./migrations")
.run(&p)
.await
.expect("migrations");
p
}
async fn create_profile(app: &axum::Router) {
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/auth/local")
.header("content-type", "application/json")
.body(Body::from(r#"{"username":"CAGE"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"auth/local should create a profile+club"
);
}
async fn insert_owned(pool: &sqlx::SqlitePool, id: &str, club_id: &str, card_id: &str) {
sqlx::query(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \
VALUES (?, ?, ?, 0, NULL, ?)",
)
.bind(id)
.bind(club_id)
.bind(card_id)
.bind("2026-01-01T00:00:00Z")
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn preflight_fails_on_owned_card_missing_definition() {
let pool = fresh_pool().await;
// first build is fine: no owned cards yet.
let app = openfut_core::build_app(pool.clone(), "data").await.unwrap();
create_profile(&app).await;
let club: String = sqlx::query_scalar("SELECT id FROM clubs LIMIT 1")
.fetch_one(&pool)
.await
.unwrap();
insert_owned(&pool, "oc-bogus", &club, "fifa17_definitely_missing_999999").await;
// second build must now fail preflight: one owned card references a def that
// is not loaded — must not silently serve an empty collection.
let err = openfut_core::build_app(pool.clone(), "data")
.await
.expect_err("preflight must fail on a missing definition");
let msg = format!("{err:#}");
assert!(
msg.contains("content preflight failed"),
"unexpected error: {msg}"
);
}
#[tokio::test]
async fn preflight_passes_when_owned_card_definition_is_loaded() {
let pool = fresh_pool().await;
// pick a definition that IS in the default data/cards catalog.
let valid_id = CardDb::load("data")
.unwrap()
.all()
.first()
.map(|c| c.id.clone())
.expect("data/cards must be non-empty");
let app = openfut_core::build_app(pool.clone(), "data").await.unwrap();
create_profile(&app).await;
let club: String = sqlx::query_scalar("SELECT id FROM clubs LIMIT 1")
.fetch_one(&pool)
.await
.unwrap();
insert_owned(&pool, "oc-valid", &club, &valid_id).await;
let _app = openfut_core::build_app(pool.clone(), "data")
.await
.expect("preflight passes when the owned card's definition is loaded");
}
-181
View File
@@ -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");
}
-284
View File
@@ -1,284 +0,0 @@
//! Generic transactional profile import (services::import). These also serve as
//! the Core-level half of the migration mutation battery: each hostile input is
//! rejected BEFORE any partial write, and re-runs converge instead of duplicating.
use openfut_core::services::card_db::CardDb;
use openfut_core::services::import::{
apply_profile_import, ImportClub, ImportEntitlement, ImportExtension, ImportOwnedCard,
ImportProfile, ImportSlot, ImportSquad, ProfileImportRequest,
};
async fn fresh_pool() -> sqlx::SqlitePool {
let p = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("in-memory sqlite");
sqlx::migrate!("./migrations")
.run(&p)
.await
.expect("migrations");
p
}
fn valid_ids(n: usize) -> Vec<String> {
let db = CardDb::load("data").expect("load data catalog");
let ids: Vec<String> = db.all().iter().take(n).map(|c| c.id.clone()).collect();
assert!(ids.len() >= n, "data catalog too small for test");
ids
}
fn owned(ids: &[String]) -> Vec<ImportOwnedCard> {
ids.iter()
.enumerate()
.map(|(i, id)| ImportOwnedCard {
owned_item_id: format!("oc-{i}"),
card_id: id.clone(),
})
.collect()
}
fn squad_over(owned: &[ImportOwnedCard]) -> ImportSquad {
ImportSquad {
formation: "f433".into(),
name: "OpenFUT".into(),
slots: owned
.iter()
.take(3)
.enumerate()
.map(|(i, o)| ImportSlot {
owned_item_id: o.owned_item_id.clone(),
position_index: i as i64,
is_captain: i == 0,
is_on_bench: false,
})
.collect(),
extension: ImportExtension {
namespace: "fifa17.squad.v1".into(),
schema_version: 1,
payload: r#"{"custom":[]}"#.into(),
},
}
}
fn request(
game: &str,
fp: &str,
owned: Vec<ImportOwnedCard>,
squad: Option<ImportSquad>,
) -> ProfileImportRequest {
ProfileImportRequest {
source_fingerprint: fp.into(),
profile: ImportProfile {
username: format!("CAGE-{game}"),
game_id: game.into(),
},
club: ImportClub {
name: "OpenFUT".into(),
coins: 28_112_944,
},
owned,
squad,
entitlements: Vec::new(),
}
}
async fn count(pool: &sqlx::SqlitePool, table: &str) -> i64 {
sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}"))
.fetch_one(pool)
.await
.unwrap()
}
#[tokio::test]
async fn imports_profile_club_owned_and_squad_in_one_shot() {
let pool = fresh_pool().await;
let db = CardDb::load("data").unwrap();
let ids = valid_ids(5);
let ow = owned(&ids);
let sq = squad_over(&ow);
let req = request("g_happy", "fp-happy", ow, Some(sq));
let out = apply_profile_import(&pool, &db, &req)
.await
.expect("import");
assert!(matches!(
out,
openfut_core::services::import::ImportOutcome::Imported {
owned: 5,
squad_slots: 3
}
));
assert_eq!(count(&pool, "profiles").await, 1);
assert_eq!(count(&pool, "clubs").await, 1);
assert_eq!(count(&pool, "owned_cards").await, 5);
assert_eq!(count(&pool, "squad_players").await, 3);
// opaque extension persisted with a Core-computed fingerprint.
let fp: String =
sqlx::query_scalar("SELECT canonical_fingerprint FROM game_entity_ext LIMIT 1")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(fp.len(), 16, "16-hex FNV fingerprint");
let stored_import_fp: String =
sqlx::query_scalar("SELECT import_fingerprint FROM profiles LIMIT 1")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(stored_import_fp, "fp-happy");
}
#[tokio::test]
async fn imports_entitlements_seeds_unopened_packs() {
let pool = fresh_pool().await;
let db = CardDb::load("data").unwrap();
let ids = valid_ids(2);
let ow = owned(&ids);
let mut req = request("g_ent", "fp-ent", ow, None);
req.entitlements = vec![
ImportEntitlement {
definition_id: "70".into(),
},
ImportEntitlement {
definition_id: "70".into(),
},
];
apply_profile_import(&pool, &db, &req)
.await
.expect("import");
// Two unconsumed entitlements seeded into packs (opened = 0).
assert_eq!(count(&pool, "packs").await, 2);
let unopened: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM packs WHERE opened = 0")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(unopened, 2);
}
#[tokio::test]
async fn rerun_same_fingerprint_is_idempotent_noop() {
let pool = fresh_pool().await;
let db = CardDb::load("data").unwrap();
let ids = valid_ids(4);
let mk = || {
request(
"g_rerun",
"fp-x",
owned(&ids),
Some(squad_over(&owned(&ids))),
)
};
apply_profile_import(&pool, &db, &mk())
.await
.expect("first");
let out = apply_profile_import(&pool, &db, &mk())
.await
.expect("second");
assert_eq!(
out,
openfut_core::services::import::ImportOutcome::AlreadyImported
);
// no duplication.
assert_eq!(count(&pool, "profiles").await, 1);
assert_eq!(count(&pool, "owned_cards").await, 4);
}
#[tokio::test]
async fn different_fingerprint_on_imported_game_fails() {
let pool = fresh_pool().await;
let db = CardDb::load("data").unwrap();
let ids = valid_ids(3);
apply_profile_import(&pool, &db, &request("g_diff", "fp-a", owned(&ids), None))
.await
.expect("first");
let err = apply_profile_import(&pool, &db, &request("g_diff", "fp-b", owned(&ids), None))
.await
.expect_err("second, different fingerprint");
assert!(format!("{err:#}").contains("different source"), "{err:#}");
assert_eq!(count(&pool, "profiles").await, 1);
}
#[tokio::test]
async fn missing_definition_fails_preflight_with_no_writes() {
let pool = fresh_pool().await;
let db = CardDb::load("data").unwrap();
let mut ow = owned(&valid_ids(2));
ow.push(ImportOwnedCard {
owned_item_id: "oc-bad".into(),
card_id: "fifa17_definitely_absent_999999".into(),
});
let err = apply_profile_import(&pool, &db, &request("g_miss", "fp", ow, None))
.await
.expect_err("missing definition must fail");
assert!(
format!("{err:#}").contains("definition preflight failed"),
"{err:#}"
);
// preflight is before the tx: nothing was written.
assert_eq!(count(&pool, "profiles").await, 0);
assert_eq!(count(&pool, "owned_cards").await, 0);
}
#[tokio::test]
async fn squad_slot_not_in_ownership_fails() {
let pool = fresh_pool().await;
let db = CardDb::load("data").unwrap();
let ids = valid_ids(3);
let ow = owned(&ids);
let mut sq = squad_over(&ow);
sq.slots[1].owned_item_id = "oc-not-owned".into();
let err = apply_profile_import(&pool, &db, &request("g_sq", "fp", ow, Some(sq)))
.await
.expect_err("squad slot not owned must fail");
assert!(format!("{err:#}").contains("all-or-nothing"), "{err:#}");
assert_eq!(count(&pool, "profiles").await, 0);
}
#[tokio::test]
async fn duplicate_owned_item_id_fails() {
let pool = fresh_pool().await;
let db = CardDb::load("data").unwrap();
let ids = valid_ids(2);
let mut ow = owned(&ids);
ow[1].owned_item_id = ow[0].owned_item_id.clone();
let err = apply_profile_import(&pool, &db, &request("g_dup", "fp", ow, None))
.await
.expect_err("duplicate OwnedItemId must fail");
assert!(
format!("{err:#}").contains("duplicate OwnedItemId"),
"{err:#}"
);
assert_eq!(count(&pool, "profiles").await, 0);
}
#[tokio::test]
async fn non_imported_profile_is_not_clobbered() {
let pool = fresh_pool().await;
let db = CardDb::load("data").unwrap();
// simulate a gameplay/dev profile with NO import_fingerprint for this game.
sqlx::query(
"INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at) \
VALUES ('p0','someone',1,0,'g_clobber','2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')",
)
.execute(&pool)
.await
.unwrap();
let ids = valid_ids(2);
let err = apply_profile_import(&pool, &db, &request("g_clobber", "fp", owned(&ids), None))
.await
.expect_err("must refuse to clobber a non-imported profile");
assert!(format!("{err:#}").contains("non-imported"), "{err:#}");
assert_eq!(count(&pool, "owned_cards").await, 0);
}
#[tokio::test]
async fn empty_owned_fails() {
let pool = fresh_pool().await;
let db = CardDb::load("data").unwrap();
let err = apply_profile_import(&pool, &db, &request("g_empty", "fp", vec![], None))
.await
.expect_err("empty owned must fail");
assert!(format!("{err:#}").contains("zero owned cards"), "{err:#}");
}
+175 -698
View File
File diff suppressed because it is too large Load Diff