34 Commits

Author SHA1 Message Date
funman300 e8be289660 feat(consume): durable per-instance contract state + HTTP apply route
CI / Build, lint & test (push) Successful in 3m16s
`consume_item` was a complete, tested, atomic apply transaction with zero
production callers and no route -- it could not be reached over HTTP because
its effect is an in-process `ItemMutation` trait object and the host is a
separate process on a synchronous JSON boundary.

Closes that gap with a CLOSED, Core-validated effect vocabulary rather than a
pass-through: `InstanceEffect::AddContractMatches { amount, cap,
default_when_unset }`. A generic "apply this field/value" escape hatch would
hand economic authority back to the caller and break the architecture.

The read-modify-write runs INSIDE the caller's transaction
(`min(cap, COALESCE(contract_matches, default) + amount)`) so two concurrent
applies cannot lose an update, and the reported `granted` stays the requested
amount even when the cap clamps the total.

Migration 0028 adds `owned_cards.contract_matches` NULLABLE: NULL means "Core
tracks no contract here", which keeps the pack-fresh default (a FIFA-specific
7) out of Core and leaves every existing row unchanged in meaning. ADD COLUMN,
not a rebuild -- a rebuild would drop 0026's transfer trigger.

Two ordering fixes forced by putting this on the live path:
* consume_item moves from DEFERRED `pool.begin()` to `BEGIN IMMEDIATE`, the
  discipline economy.rs documents: three reads precede the first write, which
  is exactly the shape that returns SQLITE_BUSY past the busy handler.
* the replay answer now precedes source validation. With DestroyInstance the
  first apply deletes the source, so the old order answered a retry with 404
  instead of the recorded outcome -- replay semantics were unreachable.
2026-08-22 18:23:05 +00:00
funman300 233df1d99d feat(core): dry-run mode for reclassify, and a per-kind tally
CI / Build, lint & test (push) Successful in 3m7s
Production is frozen and sits on schema 19, so the content_kind correction it
needs cannot be applied yet. `--dry-run` runs the real UPDATEs and rolls the
transaction back, so an operator can see exactly what a run would touch before
touching a database they cannot easily restore — the counts are measured, not
predicted.

`updated_by_kind` reports the SHAPE of the change ("consumable 17, staff 3"),
which is the thing worth sanity-checking: a different shape means the source
profile moved and the mapping needs regenerating.

Measured against a copy of prod-core.db: 20 rows would change (17 consumable,
3 staff), 1966 already correct, 0 unmatched, and the copy was verified unchanged
afterwards.
2026-08-21 21:23:43 +00:00
funman300 30fae1a2f9 style(core): rustfmt the reclassify tests
CI / Build, lint & test (push) Successful in 3m14s
2026-08-21 20:45:29 +00:00
funman300 c896545cf0 feat(core): reclassify the content_kind of already-imported owned rows
CI / Build, lint & test (push) Failing after 1m57s
A profile import is once-only — the same fingerprint no-ops and a different one
is refused — so a taxonomy correction cannot arrive by re-importing. Every club
imported before content_kind existed still records its coaches, kits and
consumables as players, because Core defaults an unstated row to `player`.

Core stays generic: the caller supplies card_id -> kind, since only the game
adapter can map its own taxonomy. One transaction, idempotent, scoped to a
single game so a shared database cannot be reclassified across games, and an
assignment nobody owns is reported rather than invented.
2026-08-21 19:55:39 +00:00
funman300 36bc594924 docs(core): quantity is not a consumable's wire amount
CI / Build, lint & test (push) Successful in 4m32s
FIFA 17's consumable `amount` is the definition's effect magnitude (a "+15
training" card), measured: across every owned consumable in the real profile
the observed `amount` equals its `fcc_*` table row, and each copy is its own
instance carrying the same value. Recording it as `quantity` would claim the
club owns fifteen of them.
2026-08-21 19:49:40 +00:00
funman300 8b1081019f feat(core): one instance-based ownership model for every kind of owned content
CI / Build, lint & test (push) Successful in 3m21s
Core could only own players. Everything else a FUT club holds — managers, staff,
consumables, kits, badges, balls, stadiums — had no representation, so the only
way to show one to a client was to synthesise it on read. That is the failure
mode this commit exists to make impossible: read authority, write authority and
persistent ownership authority are now the same rows.

MODEL. There is deliberately NO parallel items table. A manager, a consumable, a
kit and a player are all rows in `owned_cards`, differing only by a new
game-INDEPENDENT `content_kind` (player|manager|staff|consumable|kit|badge|ball|
stadium|misc). A game adapter translates its own taxonomy — FIFA 17's
`cardsubtypeid` and resource ranges — into one of those tokens before ownership
reaches Core; no game's numerics land here. Ownership stays INSTANCE-based:
`card_id` is the definition, `id` is the instance, and two copies of one
definition remain two rows.

`quantity` is a nullable per-instance attribute, not a replacement for the
instance. The real profile settles this: its 17 consumables are instance-based
and only SOME carry a wire `amount` (observed 1,2,4,5,10,15), while two copies of
definition 5003068 exist as two distinct instances. So NULL means "not a stack"
and a positive integer is the stack size; collapsing instances into counts is
forbidden by the model.

ACTIVE DESIGNATIONS. Migration 0024's two-slot kit table becomes
`club_active_items` over the five slots that correspond exactly to the client's
recovered equipped-state vocabulary (activeBadge 100, activeHomeKit 101,
activeAwayKit 102, activeBall 103, activeStadium 104). There is no
activeLeagueLogo or activeMisc token, so those kinds correctly get no slot. The
invariants are schema-enforced rather than conventional: PK(club_id, slot) allows
at most one item per role, `owned_card_id UNIQUE` makes "the same card is both
home and away kit" unstorable, and ON DELETE CASCADE means a quick-sold or
consumed item cannot be projected back as active. 0024's trigger is preserved in
semantics — and dropped EXPLICITLY before its table, because it lives ON
`owned_cards`, so DROP TABLE would have orphaned it and broken every later
ownership transfer. It still exists because the market moves ownership by UPDATE,
which no foreign key can observe.

CONSUMABLE ACTIONS. `services/consume.rs` is one transaction primitive —
validate source ownership and kind, validate target, mutate, consume the source
exactly once, commit — guarded by `UNIQUE(profile_id, action_identity)` in
migration 0027, the same discipline as `match_completions`. It supports both
deleting the row and decrementing a stack, chosen by the caller, inside the one
transaction and the one replay guard. It deliberately contains NO category
formulas: an unreversed effect must not be invented, so callers supply the
mutation and category validation stays explicit.

`/club/kits` is replaced by slot-generic `/club/active-items`. `get_collection`
now carries `content_kind` and `quantity`, accepts a `content_kind` filter, and
— importantly — stops dropping an owned card with a missing definition silently:
the envelope reports `owned_rows`, `unresolved_items` and the offending
definition ids. That silent `filter_map` is the documented cause of a club that
looks empty while the rows are all present.

Verified against a REAL populated club, not a fixture: the production snapshot
(migration 19) is copied to a tempdir, migrated to 0024, given two kit
designations on real owned instances, then migrated to head. 1986 owned rows
survive as content_kind='player', both designations land in `club_active_items`,
no row gains a quantity, and the old table is gone. 258 tests pass, clippy clean.
2026-08-21 19:10:54 +00:00
funman300 bae0a2bdaa fix(matches): close the second, unguarded match-economy authority
CI / Build, lint & test (push) Successful in 3m15s
`POST /matches/result` granted coins, XP, level-ups, statistics, four objective
metrics, loan expiry, season progression and achievements across a dozen
SEPARATE writes with no transaction and no idempotency key. Every call
re-credited the same match, and any mid-way failure half-applied it. It sat
beside `/matches/complete`, so nothing stopped one match being paid twice
through two different doors.

It cannot be made exactly-once in place: that needs a caller-supplied match
identity, and this request shape has none. Deriving one from the body would
collapse two legitimate matches with the same scoreline into one — the
under-credit trap already documented for the `fp:` fallback. So the route fails
closed: it rejects with a message naming `/matches/complete`, rather than 404,
so a caller learns why.

The behaviour it uniquely drove is kept, not deleted. `process_match` was the
ONLY caller of loan expiry and Core's season model, so both move into
`complete_match`'s transaction behind opt-in `expire_loans` / `advance_season`
flags. Both default OFF, which keeps the FIFA 17 retail path byte-identical:
FIFA 17 has its own loan and Seasons models, and Core's season END GRANTS coins
and a pack — invisible economy on a path that never asked for it. Their pooled
implementations are replaced by `expire_loans_tx` and
`season::record_match_tx`, so a loan that expires or a season that ends commits
with the match that caused it.

Notifications (level-up / objective / loan / season) were pooled side effects of
the removed path. They now emit from the route AFTER the commit — never inside
the transaction, since a failed notification must not roll back a completed
match — and only when `applied`, so a replay no longer re-notifies. The pooled
path had no replay concept and notified every time.

Also fixes a real bug this surfaced: `/auth/reset` never deleted
`match_completions`, which carries un-cascaded foreign keys to BOTH `matches`
and `profiles`. Any profile that completed a match through the authoritative
route — i.e. every FIFA 17 profile after a retail match — failed to reset with a
database error. It is now deleted first, and ordering is documented.

Tests: the 20 integration call sites move to the authoritative route through one
helper that mints a per-call identity (each call IS a distinct match). New
coverage for the closed path: it rejects without moving the balance or writing
history; Core progression stays off unless opted into; a replay does not
duplicate notifications; and a profile that completed matches can still be
reset.
2026-08-21 04:47:57 +00:00
funman300 f0550e2ae1 feat(club): persist active home/away kit assignments
CI / Build, lint & test (push) Successful in 2m50s
Kits are ownership-backed club items: the owned instance stays in the
generic owned_cards inventory and only the two active roles get their own
table. This mirrors the squad_managers precedent and keeps every
FIFA-specific resourceId/wire concern in the game adapter.

* migration 0024: club_kit_assignments(club_id, slot, owned_card_id) with a
  UNIQUE owned_card_id (one instance cannot hold both roles) and
  ON DELETE CASCADE from owned_cards so a quick-sell clears the role.
* a BEFORE UPDATE OF club_id trigger clears the designation on a market
  transfer, which moves ownership by UPDATE and so is not covered by the
  cascade.
* set_active_club_kits replaces BOTH slots in one transaction, rejects
  home == away, and validates each instance against current club ownership,
  so a half-applied or dangling designation is not representable.
* get_active_club_kits revalidates ownership on read, so a stale row can
  never surface another club's item.
* GET/PUT /club/kits expose the pair.

Tests cover restart persistence, replace/clear without duplicates, atomic
rejection of invalid references, and clearing via delete and transfer.
2026-08-21 03:17:40 +00:00
funman300 2fb835200f style(core): cargo fmt match/club agent additions
CI / Build, lint & test (push) Successful in 4m1s
2026-08-20 17:59:02 +00:00
funman300 5f9f556af8 feat(app): register GET/PUT /club/manager routes 2026-08-20 16:43:51 +00:00
funman300 9036f5f411 feat(club): ownership-backed squad manager assignment
Add a generic, durable squad->manager assignment (migration 0023
squad_managers) so a manager persists across squad save, reload, and
server restart, backed by authoritative Core owned_cards.

- squad_managers(squad_id PK, owned_card_id, updated_at) with ON DELETE
  CASCADE on both FKs: quick-selling the manager auto-clears the
  assignment (no resurrection); one manager per squad (no duplicates).
- club::{get,set,clear}_squad_manager validate club ownership of both the
  squad and the card, and re-check ownership on read (defends against a
  stale row left by a market transfer).
- GET/PUT /club/manager routes expose the assignment; FIFA wire meaning
  stays in the adapter.

Tests: persistence across reload+restart (headline), reassignment
replace/no-duplicate, clear/no-resurrection, cascade on quick-sell,
foreign-card rejection.
2026-08-20 16:43:28 +00:00
funman300 b0306a9b1d feat(match): atomic exactly-once match-completion transaction
Add complete_match: one BEGIN/COMMIT that validates identity + result,
enforces a durable (profile_id, match_identity) uniqueness guard
(migration 0022 match_completions), persists match history, and grants
coins + XP/level-ups + W/D/L/DNF statistics + objectives + achievements
exactly once. Any failure rolls the whole match back (no compensating
cleanup). Handles sequential/restart/concurrent replay, conflicting
re-report (first result canonical), DNF (loss economics, own stat
bucket) and no-contest (zero economic effect).

Adds tx-scoped variants: statistics::record_match_tx/
record_position_goals_tx, objective::increment_metric_tx,
achievement::check_and_unlock_tx. New MatchResultKind/CompleteMatchRequest/
MatchCompletionResult models + POST /matches/complete route.
2026-08-20 16:38:16 +00:00
funman300 a034e74c16 style(core): apply cargo fmt across routes, services, models, tests
CI / Build, lint & test (push) Successful in 2m19s
Pure rustfmt reflow (import grouping, array/match-arm/call-arg wrapping,
alphabetized module decls, comment realignment). No semantic change:
full-diff and `git diff -w` both confirm logic byte-identical to 271c363;
workspace builds and all 74 core tests pass. Retained pre-existing WIP
brought forward after verification.
2026-08-20 16:05:41 +00:00
funman300 271c3639ed feat(sbc): make submissions atomic and durable
CI / Build, lint & test (push) Failing after 52s
2026-08-18 18:26:23 +00:00
funman300 637a21eac1 fix(economy): quick-selling a squadded card failed with a FOREIGN KEY error
Found while implementing sale settlement, and reproduced before fixing:

  sell_item(pool, "club", "item-x", 250)
    -> Database(SqliteError { code: 787, message: "FOREIGN KEY constraint failed" })

squad_players.owned_card_id is a FK onto owned_cards(id) and db.rs enables
foreign_keys, so DELETEing an item that sits in any lineup is refused outright.
services::economy::sell_item never cleared the lineup, and this is the LIVE FIFA 17
quick-sell path (host economy_store -> econ.sell_item -> POST /economy/sell-item).
Selling a card that happens to be in your squad is ordinary, not an edge case.

sell_item now evicts the item from every lineup first, inside its existing
transaction, so a wrong-owner sale rolls the eviction back with everything else
(asserted, not assumed).

Also folds routes/cards.rs::delete_owned_card into the same authority. It
hand-rolled DELETE + club::add_coins directly on the pool, which had BOTH defects:
no transaction, so a failed credit destroyed the card for nothing, and the same
missing squad eviction. Its response shape is unchanged and the pre-existing route
test still passes.

Tests: selling_a_squadded_item_succeeds_and_frees_the_slot (the repro) and
a_rejected_quick_sell_leaves_the_lineup_intact. Core 55 lib + 123 integration pass,
clippy clean.

Not deployed — production is mid live-test.
2026-08-18 01:01:13 +00:00
funman300 31ab4a683e feat(economy): atomic market sale settlement — transfer ownership, credit seller, destroy fee
Core had no way to settle a real sale. Every "buy" MINTED a new owned_cards row
(services/economy.rs::purchase_item), so a sold card existed twice; the seller was
never credited; no fee arithmetic existed anywhere in the project; and no
/economy/* route could even name a counterparty, since all eight resolve one club
from the X-OpenFUT-Game active profile.

Adds settle_sale() beside the existing tx-scoped primitives, so it inherits the
module's proven atomicity (pool.acquire + BEGIN IMMEDIATE + finish) rather than
re-deriving it: debit buyer gross -> evict from squads -> transfer the EXISTING row
-> credit seller gross-fee. The fee is simply never credited anywhere, which is
what destroys it. Exposed as POST /economy/settle-sale, the one economy route that
names clubs explicitly because a sale has two sides; an omitted buyer means a
counterparty OUTSIDE the modelled economy, never a silent fallback to the active
club (which would settle a club against itself).

Ownership moves by UPDATE ... WHERE id = ? AND club_id = ?, an ownership CAS. No
INSERT and no DELETE on the two-party path, so duplication is ruled out
structurally, not by an assertion.

Two bugs found by writing the tests rather than by reading the code:

1. Deriving the seller from CURRENT ownership let two racing buyers BOTH succeed —
   after the first sale the item belonged to B, so the second call read B as the
   seller and chain-sold it B -> C. Core has no listing concept and could not
   notice the replay. The seller is now the caller's EXPECTED owner and every
   ownership statement is predicated on it, which makes the CAS authoritative about
   "already sold" independently of caller-side listing state.

2. Debiting before transferring made a replay fail as "insufficient balance" (the
   buyer had spent the coins on the sale that succeeded), so the ownership guard was
   shadowed and settling_the_same_sale_twice_pays_once passed WITHOUT exercising
   the guard it named. Ownership is now judged first; the test asserts NotFound
   specifically and adds a cheap affordable replay that only ownership can refuse.

Squad eviction is mandatory, not cosmetic: squad_players.owned_card_id is a FK and
the pool enables foreign_keys, so an Outside sale of a squadded card would fail
outright, and a transfer preserves the row id so a stale lineup row would leave the
PREVIOUS owner fielding a card they no longer own.

Tests: 21 service (canonical 15,000/750/14,250 fixture, conservation, squad
eviction, Outside retirement, 8 invalid paths, zero-price, replay, two-buyer race)
+ 6 route-level over HTTP with two parties. Core 194 pass.

Deliberately NOT done: no wire/route output change, no deployment, and nothing yet
decides that a player's listing has sold — the seller-facing sold wire state needs
live client evidence and must not be guessed.
2026-08-18 00:51:20 +00:00
funman300 68d10658c7 fix(economy): close SBC dup-card exploit + non-atomic economy races
Correctness fixes from docs/CORE_CORRECTNESS_ISSUES.md:

- Issue 3 (HIGH, exploit): submit_sbc dedups owned_card_ids (HashSet) and
  bounds the list (MAX_SBC_CARDS=30) before resolution. A repeated id resolved
  the same card N times, passed validation, and granted the reward while only
  one card was consumed -> any SBC satisfiable with one duplicated card = free
  reward. Now rejected with BadRequest. Regression test added.
- Issue 2 (HIGH): TOCTOU economy mutations closed with single-statement
  compare-and-swap (no transaction plumbing): club::spend_coins conditional
  debit (WHERE coins >= ?) + rows_affected, also rejects negative amounts;
  pack::open_pack claims the pack before minting; market::buy_listing claims
  the listing before charging and releases on debit failure; market::sell_card
  guards the DELETE with owner + rows_affected; checkin::claim uses a
  conditional INSERT ... WHERE NOT EXISTS (today) before paying out.
- Issue 4 (LOW): season.rs .expect() on missing rows -> graceful AppError;
  checkin index (streak-1) % 7 -> .rem_euclid(7) (guards negative index panic).
- Issue 1 (LOW): migration 0019 adds sbc_submissions.club_id + backfill;
  submit_sbc binds it so the MY CLUB milestone query stops silently reading 0.

Core suite 179 green + clippy clean.
2026-08-17 16:00:48 +00:00
OpenFUT Agent fbb54eac95 fix(economy): BEGIN IMMEDIATE for write transactions (concurrency-safe)
Root cause of the fresh-DB multi-connection write failure: economy writes ran in
a DEFERRED transaction (pool.begin() = BEGIN) that read then wrote; SQLite returns
SQLITE_BUSY (code 5, 'database is locked') *immediately* when a deferred tx
upgrades to a write while another holds the write lock, bypassing busy_timeout to
avoid deadlock. Fix: open each write op with BEGIN IMMEDIATE on a dedicated pooled
connection (finish() commits/rolls back), taking the write lock up front so
busy_timeout serializes writers. Reproduction test: 100 fresh DBs x 8 concurrent
grant_reward — was 644/800 failures, now 800/800 succeed with correct final
balance (no lost update). Reads unchanged.
2026-08-13 20:20:14 +00:00
OpenFUT Agent 75b183077f fix(db): serialize SQLite WAL establishment before pooling
Switching a brand-new DB file to WAL is a one-time file-level change; letting
multiple pooled connections perform it concurrently during warm-up races the
switch and can surface a spurious lock (observed as intermittent 500s under the
integration harness). Open ONE connection to establish WAL before the pool
opens, so every pooled connection thereafter only re-asserts an already-WAL
file. Keeps per-connection foreign_keys + busy_timeout.
2026-08-13 20:03:40 +00:00
OpenFUT Agent 0360135322 fix(db): per-connection sqlite pragmas + busy_timeout
Set journal_mode=WAL, foreign_keys, and a 5s busy_timeout on the connection
options so EVERY pooled connection gets them. Previously WAL/foreign_keys were
set by a one-off PRAGMA on the pool (configuring only whichever connection
served that query), and no busy_timeout was set — so under concurrent access a
transient SQLITE_BUSY failed the transaction (surfaced as a 500 database error)
instead of waiting. This makes economy transactions robust under concurrency.
2026-08-13 19:55:13 +00:00
OpenFUT Agent bcc4f5104a feat(economy): purchase_items primitive + entitlement import seeding
purchase_items: generic atomic debit + mint of several items (fail-closed) for
open-on-buy Store packs (Store BUY returns items immediately) + POST
/economy/purchase-items route. Import: add optional entitlements[] to
ProfileImportRequest, seeding unconsumed packs rows in the same transaction (so a
source's unopened packs become Core entitlements); idempotency via the existing
import fingerprint. Tests: 2 purchase_items unit + endpoint + entitlement-seed
import. Core matrix 45 lib + 116 integration + 9 import green; clippy clean.
2026-08-13 19:27:06 +00:00
OpenFUT Agent d32dc6e3ae feat(economy): expose transactional economy service over HTTP
Add generic /economy/* routes (balance, entitlements, purchase-entitlement,
redeem-entitlement, sell-item, grant-reward, purchase-item) resolving the club
server-side via the same game-scoped active-profile mechanism as /collection —
callers never supply a club id, so there is no cross-club access. Add
list_unopened_entitlements + Entitlement for the reader side. Game-neutral: no
currency names or wire semantics. 4 endpoint integration tests (balance+reward,
purchase+redeem, purchase-item+sell fail-closed, insufficient-funds fail-closed);
full matrix 43 lib + 115 integration green.
2026-08-13 19:09:46 +00:00
OpenFUT Agent c8269d0df7 feat(economy): add generic purchase_item (atomic debit + mint)
The FIFA17 economy audit proved the transfer market is synthetic-seller:
buy-now mints a new owned item and debits the buyer; no real counterparty,
no sale-credit/expiry/fee. The generic primitive that models this is an
atomic debit + inventory add (purchase_item), NOT a two-party
transfer_item_with_payment (which would be unused). Fail-closed: an
unaffordable purchase debits nothing and mints nothing. 2 unit tests.
2026-08-13 18:58:36 +00:00
OpenFUT Agent ee2caa0bb0 feat(economy): generic atomic profile-economy authority
Add a game-agnostic economy service that exposes atomic, fail-closed
operations over Core's existing durable tables rather than forking a
parallel persistence stack:

  * currency ledger -> clubs.coins
  * owned inventory -> owned_cards
  * entitlements    -> packs (opaque definition_id, consume-once `opened`)

Compound operations run inside a single SQLite transaction, closing the
atomicity gap in the pool-scoped club::{spend,add}_coins helpers whose
read/modify/write spans multiple round-trips. Public ops:

  balance, purchase_entitlement (debit+grant), redeem_entitlement
  (consume-once + add items, all-or-nothing), sell_item (remove+credit),
  grant_reward (credit).

Deliberately game-neutral: currency names, entitlement/pack ids, and
per-save item-id sequences stay in the per-game adapter that drives these
primitives. 9 unit tests cover debit/credit fail-closed rollback,
consume-once, partial-redeem rollback, and non-negative guards.
2026-08-13 18:44:51 +00:00
funman300 66c88fb48e fix(cli): route tracing diagnostics to stderr
Machine output (the import/seed-dev subcommands' JSON result) now owns stdout;
tracing logs go to stderr. Lets a caller parse the import outcome without
log-line contamination on stdout. No behavioral change to the server beyond
where its logs are written.
2026-08-12 20:36:12 +00:00
funman300 9f3c545c46 feat(import): generic transactional profile-import service + CLI
Core performs a GAME-AGNOSTIC transactional profile import; all FIFA17 semantics
(manifest parse, wire ids, resourceId/nextItemId, squad extension v1,
CardDefinitionId/OwnedItemId choice) stay in openfut-import-fifa17. Core sees
only opaque ids and opaque extension bytes.

services::import::apply_profile_import(pool, card_db, ProfileImportRequest):
- ONE SQLite transaction installs profile + club + all owned cards + canonical
  squad + one opaque game extension; commits together or not at all.
- Definition preflight (pre-tx): every owned card_id MUST resolve in loaded
  production content, so ownership never points at absent content.
- Squad all-or-nothing (pre-tx): every active-squad OwnedItemId MUST be in the
  imported ownership set.
- OwnedItemId uniqueness + generic extension bounds enforced pre-tx.
- Core computes the canonical squad fingerprint itself (never adapter-supplied)
  and persists the extension atomically, exactly as the live squad-write path.

Rerun identity via profiles.import_fingerprint (migration 0018, nullable):
- identical source_fingerprint on an already-imported game -> idempotent no-op;
- differing token -> fail (needs explicit update mode);
- pre-existing non-imported profile -> never clobbered.
So a crash after identity-seeding re-runs cleanly with no cleanup/reminting.

CLI: 'openfut-core import <request.json>' loads production content packs, parses
a generic request, applies. squad_fingerprint made pub(crate) for reuse.

8 import-service tests (happy path, idempotent rerun, fingerprint mismatch,
missing-definition no-write, squad-not-owned, dup OwnedItemId, non-imported
clobber guard, empty-owned). clippy -D warnings clean; full suite 159 green.
2026-08-12 20:01:27 +00:00
funman300 352ad11bc4 feat(content): production content-pack loader + referenced-definition preflight
Production real-profile content is loaded via an explicit path, NOT the dev-only
OPENFUT_DEV_CONTENT_GAMES gate:
- Config.content_packs from env OPENFUT_CONTENT_PACKS (comma-sep file paths).
- CardDb::load_pack(path): merge an explicit CardDefinition[] production pack.
- app::build loads dev packs then production packs.

Preflight (app::build, always on): every owned_cards.card_id MUST resolve to a
loaded CardDefinition. A real profile with owned players but even ONE missing
definition fails LOUDLY instead of silently serving an empty /collection; an
empty owned_cards table (fresh DB / tests) passes.

2 preflight integration tests (missing def fails, loaded def passes). clippy
-D warnings clean; full suite 151 tests green.
2026-08-12 19:49:14 +00:00
funman300 3084a46dcc style(squad): rustfmt the squad-ext transport routes
Formatting-only follow-up to the squad-ext routes; no behavior change.
Pre-existing Core fmt drift elsewhere (e.g. app.rs route chain) predates
this branch (615c5fd is already not fmt-clean) and is intentionally left
untouched — not reformatting frozen Core beyond the squad-ext change.
2026-08-12 03:58:34 +00:00
funman300 9b2c6b82f2 feat(squad): expose extension-aware squad read/write over HTTP
Add two thin transport routes wrapping the existing extension services
(no new domain logic; Core still owns validation, ownership, the atomic
canonical+extension transaction, the server fingerprint, and staleness):

  GET /squad/ext?namespace=<ns>  -> read_squad_with_ext
      returns {squad, players, extension:{state: fresh|stale|missing,
      schema_version, payload, stored_fingerprint, current_fingerprint}}
  PUT /squad/replace             -> replace_squad_with_extension
      body {name, formation, slots[], client_reported, extension};
      resolves the active squad in place (creates if none); returns
      {squad_id, canonical_fingerprint, slots_written}

A game host needs these to read/persist the FIFA squad extension atomically
over HTTP; the service functions existed but were unreachable. Adds an
integration test (replace -> read Fresh, verbatim payload, idempotent PUT
converges to the same fingerprint, missing-namespace -> Missing) and clears
a pre-existing len_zero lint so the crate is clippy-clean.
2026-08-12 02:47:15 +00:00
funman300 615c5fd7a5 feat(squad): generic game-scoped opaque extension + server fingerprint, atomic in replace_squad tx 2026-08-12 01:39:04 +00:00
funman300 36abd4b6fb feat(seed): curated FIFA17 dev content pack + opt-in game-scoped ownership seed 2026-08-11 22:55:02 +00:00
funman300 6acae54f80 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:38:16 +00:00
funman300 aecbff0de8 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 21:33:05 +00:00
funman300 8c8a4116bf wip: checkpoint multi-game core work 2026-08-07 12:03:21 -07:00
87 changed files with 14806 additions and 1037 deletions
+1
View File
@@ -37,3 +37,4 @@ axum-macros = "0.4"
axum-test = "14" axum-test = "14"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tower = { version = "0.5", features = ["util"] } tower = { version = "0.5", features = ["util"] }
tempfile = "3"
+5 -4
View File
@@ -70,7 +70,7 @@ DATABASE_URL=sqlite://./myclub.db LISTEN_ADDR=127.0.0.1:8080 ./target/release/op
| `GET` | `/squad` | Get active squad | | `GET` | `/squad` | Get active squad |
| `POST` | `/squad` | Save squad | | `POST` | `/squad` | Save squad |
| `GET` | `/objectives` | List objectives with progress | | `GET` | `/objectives` | List objectives with progress |
| `POST` | `/matches/result` | Submit match result + receive rewards | | `POST` | `/matches/complete` | Complete a match exactly once + receive rewards |
| `GET` | `/sbc` | List SBC definitions | | `GET` | `/sbc` | List SBC definitions |
| `POST` | `/sbc/submit` | Submit SBC solution | | `POST` | `/sbc/submit` | Submit SBC solution |
| `GET` | `/market` | Browse NPC transfer market | | `GET` | `/market` | Browse NPC transfer market |
@@ -95,10 +95,11 @@ curl http://localhost:8080/club
# Open your starter pack # Open your starter pack
curl -X POST http://localhost:8080/packs/open/<pack_id> curl -X POST http://localhost:8080/packs/open/<pack_id>
# Submit a match win # Submit a match win. `match_identity` keys exactly-once economy: resubmitting the
curl -X POST http://localhost:8080/matches/result \ # same identity echoes the first result and grants nothing twice.
curl -X POST http://localhost:8080/matches/complete \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"squad_id":"any","opponent_name":"Beginner AI","goals_for":3,"goals_against":0,"mode":"squad_battles"}' -d '{"match_identity":"match-1","result":"win","squad_id":"any","opponent_name":"Beginner AI","goals_for":3,"goals_against":0,"mode":"squad_battles"}'
``` ```
--- ---
+546
View File
@@ -0,0 +1,546 @@
[
{
"id": "fifa17_101490",
"name": "Conor Casey",
"overall": 64,
"position": "ST",
"nation": "United States",
"league": "MLS",
"club": "Columbus Crew SC",
"pace": 43,
"shooting": 65,
"passing": 52,
"dribbling": 60,
"defending": 33,
"physical": 72,
"rarity": "bronze",
"image_path": null
},
{
"id": "fifa17_101880",
"name": "Rob Green",
"overall": 74,
"position": "GK",
"nation": "England",
"league": "EFL Championship",
"club": "Leeds United",
"pace": 78,
"shooting": 70,
"passing": 62,
"dribbling": 77,
"defending": 47,
"physical": 71,
"rarity": "silver",
"image_path": null
},
{
"id": "fifa17_102356",
"name": "Markus Feulner",
"overall": 74,
"position": "CM",
"nation": "Germany",
"league": "Bundesliga",
"club": "Augsburg",
"pace": 58,
"shooting": 70,
"passing": 75,
"dribbling": 71,
"defending": 66,
"physical": 71,
"rarity": "silver",
"image_path": null
},
{
"id": "fifa17_102593",
"name": "Craig Woodman",
"overall": 64,
"position": "LB",
"nation": "England",
"league": "EFL League Two",
"club": "Exeter City",
"pace": 66,
"shooting": 45,
"passing": 58,
"dribbling": 60,
"defending": 62,
"physical": 65,
"rarity": "bronze",
"image_path": null
},
{
"id": "fifa17_105046",
"name": "Anders Østli",
"overall": 64,
"position": "CB",
"nation": "Norway",
"league": "Tippeligaen",
"club": "Sarpsborg 08 FF",
"pace": 54,
"shooting": 46,
"passing": 54,
"dribbling": 52,
"defending": 62,
"physical": 75,
"rarity": "bronze",
"image_path": null
},
{
"id": "fifa17_107298",
"name": "Yohann Pelé",
"overall": 74,
"position": "GK",
"nation": "France",
"league": "Ligue 1",
"club": "O. de Marseille",
"pace": 75,
"shooting": 74,
"passing": 72,
"dribbling": 70,
"defending": 49,
"physical": 76,
"rarity": "silver",
"image_path": null
},
{
"id": "fifa17_107713",
"name": "Tom Starke",
"overall": 74,
"position": "GK",
"nation": "Germany",
"league": "Bundesliga",
"club": "Bayern",
"pace": 76,
"shooting": 73,
"passing": 59,
"dribbling": 72,
"defending": 39,
"physical": 76,
"rarity": "silver",
"image_path": null
},
{
"id": "fifa17_110020",
"name": "Sergio Pelegrín",
"overall": 74,
"position": "CB",
"nation": "Spain",
"league": "LaLiga 1 I 2 I 3",
"club": "Elche CF",
"pace": 45,
"shooting": 32,
"passing": 52,
"dribbling": 49,
"defending": 75,
"physical": 76,
"rarity": "silver",
"image_path": null
},
{
"id": "fifa17_110026",
"name": "Cani",
"overall": 74,
"position": "LM",
"nation": "Spain",
"league": "LaLiga 1 I 2 I 3",
"club": "Real Zaragoza",
"pace": 67,
"shooting": 72,
"passing": 73,
"dribbling": 78,
"defending": 45,
"physical": 61,
"rarity": "silver",
"image_path": null
},
{
"id": "fifa17_11811",
"name": "Paul Green",
"overall": 64,
"position": "CM",
"nation": "Republic of Ireland",
"league": "EFL League One",
"club": "Oldham Athletic",
"pace": 65,
"shooting": 58,
"passing": 62,
"dribbling": 63,
"defending": 62,
"physical": 68,
"rarity": "bronze",
"image_path": null
},
{
"id": "fifa17_139720",
"name": "Vincent Kompany",
"overall": 86,
"position": "CB",
"nation": "Belgium",
"league": "Premier League",
"club": "Manchester City",
"pace": 69,
"shooting": 54,
"passing": 62,
"dribbling": 65,
"defending": 86,
"physical": 81,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_146562",
"name": "Santi Cazorla",
"overall": 86,
"position": "CAM",
"nation": "Spain",
"league": "Premier League",
"club": "Arsenal",
"pace": 71,
"shooting": 78,
"passing": 85,
"dribbling": 86,
"defending": 57,
"physical": 64,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_153079",
"name": "Sergio Agüero",
"overall": 89,
"position": "ST",
"nation": "Argentina",
"league": "Premier League",
"club": "Manchester City",
"pace": 89,
"shooting": 88,
"passing": 75,
"dribbling": 89,
"defending": 23,
"physical": 70,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_158023",
"name": "Lionel Messi",
"overall": 93,
"position": "RW",
"nation": "Argentina",
"league": "LaLiga Santander",
"club": "FC Barcelona",
"pace": 89,
"shooting": 90,
"passing": 86,
"dribbling": 96,
"defending": 26,
"physical": 61,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_162895",
"name": "Cesc Fàbregas",
"overall": 86,
"position": "CM",
"nation": "Spain",
"league": "Premier League",
"club": "Chelsea",
"pace": 63,
"shooting": 77,
"passing": 89,
"dribbling": 81,
"defending": 61,
"physical": 64,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_163705",
"name": "Steve Mandanda",
"overall": 85,
"position": "GK",
"nation": "France",
"league": "Premier League",
"club": "Crystal Palace",
"pace": 86,
"shooting": 80,
"passing": 79,
"dribbling": 85,
"defending": 49,
"physical": 81,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_165229",
"name": "Laurent Koscielny",
"overall": 85,
"position": "CB",
"nation": "France",
"league": "Premier League",
"club": "Arsenal",
"pace": 78,
"shooting": 40,
"passing": 62,
"dribbling": 65,
"defending": 85,
"physical": 78,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_167948",
"name": "Hugo Lloris",
"overall": 88,
"position": "GK",
"nation": "France",
"league": "Premier League",
"club": "Spurs",
"pace": 87,
"shooting": 87,
"passing": 68,
"dribbling": 90,
"defending": 64,
"physical": 82,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_168542",
"name": "David Silva",
"overall": 87,
"position": "CAM",
"nation": "Spain",
"league": "Premier League",
"club": "Manchester City",
"pace": 68,
"shooting": 72,
"passing": 87,
"dribbling": 87,
"defending": 32,
"physical": 58,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_176580",
"name": "Luis Suárez",
"overall": 92,
"position": "ST",
"nation": "Uruguay",
"league": "LaLiga Santander",
"club": "FC Barcelona",
"pace": 82,
"shooting": 90,
"passing": 79,
"dribbling": 87,
"defending": 42,
"physical": 79,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_176635",
"name": "Mesut Özil",
"overall": 89,
"position": "CAM",
"nation": "Germany",
"league": "Premier League",
"club": "Arsenal",
"pace": 72,
"shooting": 74,
"passing": 86,
"dribbling": 86,
"defending": 24,
"physical": 58,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_177388",
"name": "Dimitri Payet",
"overall": 86,
"position": "LM",
"nation": "France",
"league": "Premier League",
"club": "West Ham",
"pace": 77,
"shooting": 78,
"passing": 87,
"dribbling": 87,
"defending": 42,
"physical": 70,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_183277",
"name": "Eden Hazard",
"overall": 88,
"position": "LM",
"nation": "Belgium",
"league": "Premier League",
"club": "Chelsea",
"pace": 90,
"shooting": 81,
"passing": 82,
"dribbling": 91,
"defending": 32,
"physical": 64,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_184941",
"name": "Alexis Sánchez",
"overall": 87,
"position": "LW",
"nation": "Chile",
"league": "Premier League",
"club": "Arsenal",
"pace": 86,
"shooting": 82,
"passing": 79,
"dribbling": 88,
"defending": 39,
"physical": 74,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_190871",
"name": "Neymar",
"overall": 92,
"position": "LW",
"nation": "Brazil",
"league": "LaLiga Santander",
"club": "FC Barcelona",
"pace": 91,
"shooting": 84,
"passing": 78,
"dribbling": 95,
"defending": 30,
"physical": 56,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_192119",
"name": "Thibaut Courtois",
"overall": 89,
"position": "GK",
"nation": "Belgium",
"league": "Premier League",
"club": "Chelsea",
"pace": 84,
"shooting": 91,
"passing": 69,
"dribbling": 89,
"defending": 48,
"physical": 86,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_192985",
"name": "Kevin De Bruyne",
"overall": 88,
"position": "CAM",
"nation": "Belgium",
"league": "Premier League",
"club": "Manchester City",
"pace": 77,
"shooting": 83,
"passing": 86,
"dribbling": 84,
"defending": 40,
"physical": 75,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_193080",
"name": "David De Gea",
"overall": 90,
"position": "GK",
"nation": "Spain",
"league": "Premier League",
"club": "Manchester Utd",
"pace": 88,
"shooting": 85,
"passing": 87,
"dribbling": 90,
"defending": 56,
"physical": 85,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_195864",
"name": "Paul Pogba",
"overall": 88,
"position": "CM",
"nation": "France",
"league": "Premier League",
"club": "Manchester Utd",
"pace": 77,
"shooting": 80,
"passing": 83,
"dribbling": 87,
"defending": 72,
"physical": 87,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_20801",
"name": "Cristiano Ronaldo",
"overall": 94,
"position": "LW",
"nation": "Portugal",
"league": "LaLiga Santander",
"club": "Real Madrid",
"pace": 92,
"shooting": 92,
"passing": 81,
"dribbling": 91,
"defending": 33,
"physical": 80,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_41236",
"name": "Zlatan Ibrahimović",
"overall": 90,
"position": "ST",
"nation": "Sweden",
"league": "Premier League",
"club": "Manchester Utd",
"pace": 72,
"shooting": 90,
"passing": 81,
"dribbling": 85,
"defending": 31,
"physical": 86,
"rarity": "gold",
"image_path": null
},
{
"id": "fifa17_48940",
"name": "Petr Čech",
"overall": 88,
"position": "GK",
"nation": "Czech Republic",
"league": "Premier League",
"club": "Arsenal",
"pace": 83,
"shooting": 90,
"passing": 77,
"dribbling": 85,
"defending": 48,
"physical": 85,
"rarity": "gold",
"image_path": null
}
]
+25 -9
View File
@@ -71,17 +71,33 @@ All game-content data is loaded at startup from `data/` into `Arc`-wrapped colle
8. Increment pack stats + objective progress 8. Increment pack stats + objective progress
9. Return `PackOpenResult { pack_id, cards }` 9. Return `PackOpenResult { pack_id, cards }`
## Data Flow: Match Result ## Data Flow: Match Completion
1. `POST /matches/result``routes::matches::post_match_result` 1. `POST /matches/complete``routes::matches::post_match_complete`
2. Fetch profile + club 2. Fetch profile + club
3. `services::match_service::process_match(...)` 3. `services::match_service::complete_match(...)` — everything below runs in ONE
4. Determine outcome (win/draw/loss), compute coins + XP transaction and either commits together or rolls back whole
5. Insert match record 4. Insert the match-history row (also takes SQLite's writer lock, serializing
6. `club::add_coins`, `profile::add_xp` overlapping completions)
7. `statistics::record_match` 5. Insert the `match_completions` guard row. `UNIQUE(profile_id, match_identity)`
8. `objective::increment_metric` for matches_played, matches_won, goals_scored, coins_earned makes the economy exactly-once: a duplicate — sequential, concurrent, after a
9. Return `MatchRewardResult` restart, or a conflicting re-report — collides here and the whole attempt
rolls back, then echoes the persisted result with `applied = false`
6. Coins, XP + level-ups, W/D/L/DNF statistics, objective metrics, achievements
7. Opt-in only: `expire_loans` (loan tick-down/removal) and `advance_season`
(Core's own division model, which grants coins and a pack at season end).
Both default OFF so a game with its own loan/season model — FIFA 17 — is
unaffected
8. Commit, then the route emits player notifications for what landed (never
inside the transaction, and only when `applied`)
9. Return `MatchCompletionResult`
`POST /matches/result` was REMOVED as an economy path. It performed the same
grants across a dozen separate writes with no transaction and no idempotency
key, which made it a second economy authority that re-credited on every call and
could half-apply on any mid-way failure. It now rejects and names
`/matches/complete`. Exactly-once requires a caller-supplied match identity,
which its request shape did not carry and could not derive.
## Single-Profile Design ## Single-Profile Design
+5
View File
@@ -0,0 +1,5 @@
-- 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
@@ -0,0 +1,3 @@
-- 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
@@ -0,0 +1,14 @@
-- 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
@@ -0,0 +1,23 @@
-- 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)
);
@@ -0,0 +1,10 @@
-- 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;
@@ -0,0 +1,11 @@
-- Issue 1: sbc_submissions was created (0001_initial.sql) without a club_id column,
-- but the MY CLUB milestone query (routes/club.rs get_milestones) counts
-- SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1
-- so SQLite errored on the unknown column and the error was swallowed by
-- `.unwrap_or(0)` -> the `sbcs_completed` milestone always read 0. Add the column
-- and backfill it from the profile's club so historical submissions count.
ALTER TABLE sbc_submissions ADD COLUMN club_id TEXT;
UPDATE sbc_submissions
SET club_id = (SELECT c.id FROM clubs c WHERE c.profile_id = sbc_submissions.profile_id)
WHERE club_id IS NULL;
@@ -0,0 +1,14 @@
-- Durable replay and non-repeatable-completion guards for atomic SBC submissions.
-- Existing successful rows are treated as non-repeatable; if historical data already
-- violates that invariant the migration fails rather than silently discarding history.
ALTER TABLE sbc_submissions ADD COLUMN repeatable INTEGER NOT NULL DEFAULT 0;
CREATE UNIQUE INDEX idx_sbc_nonrepeatable_completion
ON sbc_submissions(profile_id, sbc_id)
WHERE passed = 1 AND repeatable = 0;
-- submitted_card_ids is stored in canonical sorted order by the writer. This rejects
-- stale retries of the same card set even for explicitly repeatable challenges.
CREATE UNIQUE INDEX idx_sbc_submission_replay
ON sbc_submissions(profile_id, sbc_id, submitted_card_ids)
WHERE passed = 1;
+9
View File
@@ -0,0 +1,9 @@
-- Core owns durable per-profile working squads for SBC challenges. The FIFA17
-- adapter maps its numeric challenge id to the opaque generic sbc_id.
CREATE TABLE sbc_challenge_squads (
profile_id TEXT NOT NULL REFERENCES profiles(id),
sbc_id TEXT NOT NULL,
owned_card_ids TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (profile_id, sbc_id)
);
+29
View File
@@ -0,0 +1,29 @@
-- Durable economic idempotency for match completion.
--
-- One economic effect per (profile_id, match_identity), independent of any HTTP
-- receipt idempotency the game host/adapter layers on top. A sequential replay,
-- a restart replay, a concurrent duplicate, or a conflicting re-report of the
-- same match all collide on this UNIQUE and are refused BEFORE any coins, XP,
-- statistics, objectives, or achievements are applied — the first completion is
-- the one canonical result, the rest are idempotent no-ops.
--
-- `match_identity` is opaque to Core: the game adapter/host derives a stable
-- per-match token (e.g. the FIFA17 match-create id). Core never parses it.
CREATE TABLE match_completions (
id TEXT PRIMARY KEY NOT NULL,
profile_id TEXT NOT NULL REFERENCES profiles(id),
match_identity TEXT NOT NULL,
result TEXT NOT NULL, -- canonical: win | draw | loss | dnf | no_contest
coins_awarded INTEGER NOT NULL DEFAULT 0,
xp_awarded INTEGER NOT NULL DEFAULT 0,
match_id TEXT NOT NULL REFERENCES matches(id),
completed_at TEXT NOT NULL,
UNIQUE(profile_id, match_identity)
);
CREATE INDEX idx_match_completions_profile ON match_completions(profile_id);
-- W/D/L already live on `statistics`; add the DNF (abandon/quit) bucket so the
-- four match outcomes are mutually-exclusive counters. A did-not-finish is
-- economically a loss but is tallied here, not in `matches_lost`.
ALTER TABLE statistics ADD COLUMN matches_dnf INTEGER NOT NULL DEFAULT 0;
+25
View File
@@ -0,0 +1,25 @@
-- Squad manager assignment: an owned item assigned as a squad's manager.
--
-- Generic, game-neutral canonical state. Core does not know what a "manager"
-- means to any game; it only records that one owned item (`owned_card_id`) is
-- assigned to a squad in the manager role. The FIFA 17 adapter owns the wire
-- meaning (itemType "manager", contract, chemistry) exactly as it owns player
-- item shaping — Core just persists the ownership-backed assignment durably and
-- atomically, so a manager survives squad save / reload / server restart.
--
-- One manager per squad: `squad_id` is the primary key, so a re-assignment
-- REPLACEs rather than accumulating (no duplicate-manager rows).
--
-- `owned_card_id` references `owned_cards(id)` with ON DELETE CASCADE: quick
-- selling / discarding the manager card (a DELETE on owned_cards) removes the
-- assignment automatically, so a sold manager is never resurrected on the next
-- squad read. Reads additionally re-check the manager still belongs to the club
-- (see `club::get_squad_manager`), defending against a stale row left by a
-- market transfer (which UPDATEs owner rather than deleting).
CREATE TABLE IF NOT EXISTS squad_managers (
squad_id TEXT PRIMARY KEY NOT NULL REFERENCES squads(id) ON DELETE CASCADE,
owned_card_id TEXT NOT NULL REFERENCES owned_cards(id) ON DELETE CASCADE,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_squad_managers_owned ON squad_managers(owned_card_id);
+23
View File
@@ -0,0 +1,23 @@
-- Active home/away kits for a club. Ownership remains the generic owned_cards
-- inventory; this table only records which owned instances occupy the two kit
-- roles. FIFA-specific resource ids and wire shapes stay in the FIFA17 adapter.
CREATE TABLE IF NOT EXISTS club_kit_assignments (
club_id TEXT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
slot TEXT NOT NULL CHECK (slot IN ('home', 'away')),
owned_card_id TEXT NOT NULL UNIQUE REFERENCES owned_cards(id) ON DELETE CASCADE,
updated_at TEXT NOT NULL,
PRIMARY KEY (club_id, slot)
);
CREATE INDEX IF NOT EXISTS idx_club_kit_assignments_owned
ON club_kit_assignments(owned_card_id);
-- Market transfers change owned_cards.club_id by UPDATE rather than DELETE.
-- Remove any old-club active designation before ownership moves so a stale row
-- cannot hide or block the item for its new owner.
CREATE TRIGGER IF NOT EXISTS clear_club_kit_assignment_before_transfer
BEFORE UPDATE OF club_id ON owned_cards
WHEN OLD.club_id <> NEW.club_id
BEGIN
DELETE FROM club_kit_assignments WHERE owned_card_id = OLD.id;
END;
+39
View File
@@ -0,0 +1,39 @@
-- Generic owned-content classification on the EXISTING ownership table.
--
-- Core owns ONE instance-based ownership model for every kind of owned content.
-- There is deliberately no parallel "items" table: a manager, a consumable, a
-- kit and a player are all rows in `owned_cards`, differing only by
-- `content_kind`. Two copies of one definition remain TWO rows (instance-based
-- ownership: `card_id` is the definition, `id` is the instance).
--
-- `content_kind` is a game-INDEPENDENT vocabulary. Game adapters translate their
-- own taxonomy (e.g. FIFA 17 `cardsubtypeid` / resource ranges) into one of these
-- tokens before ownership reaches Core; a game's numeric ids NEVER land here.
--
-- BACKFILL: none needed — every pre-existing row is a player card, which is
-- exactly the column DEFAULT, so the ALTER backfills all existing ownership as
-- 'player' in place. (Verified against a real populated club snapshot: 1986
-- owned rows, all players.)
ALTER TABLE owned_cards ADD COLUMN content_kind TEXT NOT NULL DEFAULT 'player'
CHECK (content_kind IN (
'player', 'manager', 'staff', 'consumable',
'kit', 'badge', 'ball', 'stadium', 'misc'
));
-- Optional stack count for content that is owned as an instance CARRYING a
-- count rather than as a bare instance.
--
-- Evidence (real profile, 1995 owned items): consumables are instance-based with
-- an OPTIONAL count — some carry a wire `amount` (observed 1,2,4,5,10,15), some
-- omit the key entirely, and two copies of one definition exist as two distinct
-- instances. So a count is a per-instance ATTRIBUTE, never a replacement for the
-- instance: NULL means "not a stack", a positive integer is the stack size.
-- Collapsing instances into counts is forbidden by the ownership model above.
ALTER TABLE owned_cards ADD COLUMN quantity INTEGER
CHECK (quantity IS NULL OR quantity >= 1);
-- Every club projection reads one kind at a time (players for the squad, kits
-- for the club room, consumables for the item list), so the club+kind pair is
-- the hot access path.
CREATE INDEX IF NOT EXISTS idx_owned_cards_club_kind
ON owned_cards(club_id, content_kind);
+55
View File
@@ -0,0 +1,55 @@
-- Generalise the two-slot kit designation (migration 0024) into the full set of
-- active club designations.
--
-- Ownership still lives ONLY in `owned_cards`; this table records which owned
-- INSTANCE currently occupies each club-scoped role. A row here is a pointer,
-- never a second ownership authority.
--
-- Lifecycle invariants enforced by the schema, not by convention:
-- * `PRIMARY KEY (club_id, slot)` — at most one active item per role.
-- * `owned_card_id ... UNIQUE` — one owned instance can occupy at most ONE
-- slot, so "the same card is both the home and the away kit" is unstorable.
-- * `REFERENCES owned_cards(id) ON DELETE CASCADE` — quick-selling/consuming
-- the item removes the designation, so a sold item can never be projected
-- back to the client as active.
-- * the BEFORE UPDATE trigger below — a market transfer moves ownership by
-- UPDATE (the row id survives), which no FK action can see, so the
-- designation is dropped explicitly before the owner changes.
--
-- `squad_managers` (migration 0023) is squad-scoped, not club-scoped, and is
-- deliberately NOT folded in here.
CREATE TABLE IF NOT EXISTS club_active_items (
club_id TEXT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
slot TEXT NOT NULL CHECK (slot IN (
'home_kit', 'away_kit', 'badge', 'ball', 'stadium'
)),
owned_card_id TEXT NOT NULL UNIQUE REFERENCES owned_cards(id) ON DELETE CASCADE,
updated_at TEXT NOT NULL,
PRIMARY KEY (club_id, slot)
);
CREATE INDEX IF NOT EXISTS idx_club_active_items_owned
ON club_active_items(owned_card_id);
-- Carry every existing kit designation over: 'home' -> 'home_kit',
-- 'away' -> 'away_kit'. No designation is lost and none is invented.
INSERT INTO club_active_items (club_id, slot, owned_card_id, updated_at)
SELECT club_id,
CASE slot WHEN 'home' THEN 'home_kit' ELSE 'away_kit' END,
owned_card_id,
updated_at
FROM club_kit_assignments
WHERE slot IN ('home', 'away');
-- 0024's trigger lives ON owned_cards, so DROP TABLE would NOT remove it and
-- every subsequent ownership transfer would fail on a missing table. Drop it
-- explicitly first, then replace it with the generalised one.
DROP TRIGGER IF EXISTS clear_club_kit_assignment_before_transfer;
DROP TABLE club_kit_assignments;
CREATE TRIGGER IF NOT EXISTS clear_club_active_item_before_transfer
BEFORE UPDATE OF club_id ON owned_cards
WHEN OLD.club_id <> NEW.club_id
BEGIN
DELETE FROM club_active_items WHERE owned_card_id = OLD.id;
END;
@@ -0,0 +1,40 @@
-- Durable idempotency for applying a consumable to a target.
--
-- Same discipline as `match_completions` (migration 0022): ONE effect per
-- (profile_id, action_identity). A sequential replay, a restart replay, a
-- concurrent duplicate, or a retried HTTP request all collide on this UNIQUE and
-- are refused BEFORE the target is mutated and BEFORE the source is consumed —
-- so a consumable can never be spent twice, and its effect can never be applied
-- twice from one spend.
--
-- `action_identity` is opaque to Core: the game adapter/host derives a stable
-- per-application token from its own wire request. Core never parses it.
--
-- `source_owned_card_id` / `target_owned_card_id` are deliberately NOT foreign
-- keys: the source row is DELETEd (or decremented to zero and deleted) by the
-- very transaction that writes this record, and the target may later be sold.
-- This table is an audit + replay record, not an ownership reference.
--
-- `effect` is the caller-supplied outcome summary stored verbatim as JSON text.
-- Core defines NO per-category formula: what a given consumable does to its
-- target is the calling game adapter's reversed behaviour, and an unreversed
-- behaviour must not be invented here.
CREATE TABLE consumable_applications (
id TEXT PRIMARY KEY NOT NULL,
profile_id TEXT NOT NULL REFERENCES profiles(id),
action_identity TEXT NOT NULL,
source_owned_card_id TEXT NOT NULL,
source_card_id TEXT NOT NULL,
source_content_kind TEXT NOT NULL,
-- 1 = the source instance was destroyed; 0 = a stack was decremented.
source_consumed INTEGER NOT NULL,
-- Remaining stack size after a decrement, NULL when the instance was destroyed.
source_quantity_after INTEGER,
target_owned_card_id TEXT,
effect TEXT NOT NULL,
applied_at TEXT NOT NULL,
UNIQUE(profile_id, action_identity)
);
CREATE INDEX idx_consumable_applications_profile
ON consumable_applications(profile_id);
+19
View File
@@ -0,0 +1,19 @@
-- Per-instance match-contract counter on an owned instance.
--
-- NULLABLE ON PURPOSE. NULL means "Core tracks no contract for this instance",
-- which is NOT the same as zero: a game whose contracts start at a pack-fresh
-- default (FIFA 17 hands out 7) must supply that default itself, so the number
-- stays in the game adapter and never becomes a Core constant. Every row that
-- pre-dates this migration therefore reads back NULL and keeps its exact prior
-- meaning — the migration is a pure widening, not a backfill.
--
-- `>= 0` only: the cap is a per-application input (the caller's game rule), not
-- a schema invariant, so the CHECK refuses the one value that is nonsense in
-- every game rather than pinning someone else's ceiling.
--
-- ALTER TABLE ADD COLUMN, NEVER a table rebuild: `owned_cards` carries the
-- `clear_club_active_item_before_transfer` trigger installed by 0026, and a
-- DROP/recreate would silently take it with it — exactly the failure 0026:44-46
-- documents for 0024's trigger.
ALTER TABLE owned_cards ADD COLUMN contract_matches INTEGER
CHECK (contract_matches IS NULL OR contract_matches >= 0);
+119 -10
View File
@@ -42,7 +42,38 @@ pub struct AppState {
} }
pub async fn build(pool: Pool, cfg: Config) -> Result<Router> { pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
let card_db = Arc::new(CardDb::load(&cfg.data_dir)?); let mut card_db = CardDb::load(&cfg.data_dir)?;
for game in &cfg.dev_content_games {
card_db.load_game_dev(&cfg.data_dir, game)?;
}
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 pack_defs = Arc::new(load_pack_definitions(&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 obj_defs = Arc::new(load_objective_definitions(&cfg.data_dir)?);
let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?); let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?);
@@ -138,9 +169,49 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/club/checkin", get(routes::club::get_checkin_status)) .route("/club/checkin", get(routes::club::get_checkin_status))
.route("/club/checkin", post(routes::club::post_checkin)) .route("/club/checkin", post(routes::club::post_checkin))
.route("/club/milestones", get(routes::club::get_milestones)) .route("/club/milestones", get(routes::club::get_milestones))
// ClubB: squad manager assignment (append-only; own lines).
.route("/club/manager", get(routes::club::get_squad_manager))
.route("/club/manager", put(routes::club::put_squad_manager))
// Active club-item designations (home/away kit, badge, ball, stadium).
.route("/club/active-items", get(routes::club::get_active_items))
.route("/club/active-items", put(routes::club::put_active_item))
.route("/cards", get(routes::cards::get_cards)) .route("/cards", get(routes::cards::get_cards))
.route("/cards/:card_id", get(routes::cards::get_card)) .route("/cards/:card_id", get(routes::cards::get_card))
.route("/collection", get(routes::cards::get_collection)) .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(
"/consumables/apply",
post(routes::consumables::post_apply_consumable),
)
.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(
"/economy/settle-sale",
post(routes::economy::post_settle_sale),
)
.route( .route(
"/collection/:owned_card_id", "/collection/:owned_card_id",
delete(routes::cards::delete_owned_card), delete(routes::cards::delete_owned_card),
@@ -168,11 +239,16 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/packs/open/:pack_id", post(routes::packs::post_open_pack)) .route("/packs/open/:pack_id", post(routes::packs::post_open_pack))
.route("/squad", get(routes::squad::get_squad)) .route("/squad", get(routes::squad::get_squad))
.route("/squad", post(routes::squad::post_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", get(routes::squad::get_squads))
.route("/squads/:squad_id", get(routes::squad::get_squad_by_id)) .route("/squads/:squad_id", get(routes::squad::get_squad_by_id))
.route("/squads/:squad_id", delete(routes::squad::delete_squad)) .route("/squads/:squad_id", delete(routes::squad::delete_squad))
.route("/objectives", get(routes::objectives::get_objectives)) .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( .route(
"/objectives/claim", "/objectives/claim",
post(routes::objectives::post_claim_objective), post(routes::objectives::post_claim_objective),
@@ -184,13 +260,25 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/matches", get(routes::matches::get_matches)) .route("/matches", get(routes::matches::get_matches))
.route("/matches/opponent", get(routes::matches::get_opponent)) .route("/matches/opponent", get(routes::matches::get_opponent))
.route("/matches/result", post(routes::matches::post_match_result)) .route("/matches/result", post(routes::matches::post_match_result))
.route(
"/matches/complete",
post(routes::matches::post_match_complete),
)
.route("/sbc", get(routes::sbc::get_sbcs)) .route("/sbc", get(routes::sbc::get_sbcs))
.route("/sbc/status", get(routes::sbc::get_sbc_status))
.route("/sbc/submit", post(routes::sbc::post_sbc_submit)) .route("/sbc/submit", post(routes::sbc::post_sbc_submit))
.route("/sbc/:sbc_id", get(routes::sbc::get_sbc)) .route("/sbc/:sbc_id", get(routes::sbc::get_sbc))
.route(
"/sbc/:sbc_id/squad",
get(routes::sbc::get_sbc_squad).put(routes::sbc::put_sbc_squad),
)
.route("/market", get(routes::market::get_market)) .route("/market", get(routes::market::get_market))
.route("/market/buy", post(routes::market::post_market_buy)) .route("/market/buy", post(routes::market::post_market_buy))
.route("/market/sell", post(routes::market::post_market_sell)) .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/refresh", post(routes::market::post_market_refresh))
.route("/market/my-listings", get(routes::market::get_my_listings)) .route("/market/my-listings", get(routes::market::get_my_listings))
.route( .route(
@@ -205,15 +293,36 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/settings", get(routes::settings::get_settings)) .route("/settings", get(routes::settings::get_settings))
.route("/settings", put(routes::settings::put_settings)) .route("/settings", put(routes::settings::put_settings))
.route("/division", get(routes::division::get_division)) .route("/division", get(routes::division::get_division))
.route("/division/history", get(routes::division::get_division_history)) .route(
.route("/division/leaderboard", get(routes::division::get_division_leaderboard)) "/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("/achievements", get(routes::achievements::get_achievements))
.route("/notifications", get(routes::notifications::get_notifications)) .route(
.route("/notifications/read-all", post(routes::notifications::mark_all_notifications_read)) "/notifications",
.route("/notifications/:id/read", patch(routes::notifications::mark_notification_read)) 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", get(routes::fut_champs::get_fut_champs))
.route("/fut-champs/start", post(routes::fut_champs::post_start_fut_champs)) .route(
.route("/fut-champs/history", get(routes::fut_champs::get_champs_history)) "/fut-champs/start",
post(routes::fut_champs::post_start_fut_champs),
)
.route(
"/fut-champs/history",
get(routes::fut_champs::get_champs_history),
)
.route( .route(
"/fut-champs/:session_id/result", "/fut-champs/:session_id/result",
post(routes::fut_champs::post_champs_result), post(routes::fut_champs::post_champs_result),
+30 -1
View File
@@ -1,12 +1,21 @@
use anyhow::Result; use anyhow::Result;
use std::path::PathBuf;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Config { pub struct Config {
pub listen_addr: String, pub listen_addr: String,
pub database_url: String, pub database_url: String,
pub data_dir: String, pub data_dir: String,
#[allow(dead_code)]
pub max_connections: u32, 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 { impl Config {
@@ -20,6 +29,26 @@ impl Config {
.ok() .ok()
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(5), .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(),
}) })
} }
} }
+25 -9
View File
@@ -1,24 +1,40 @@
use anyhow::Result; use anyhow::Result;
use sqlx::{ use sqlx::{
sqlite::{SqliteConnectOptions, SqlitePoolOptions}, sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
SqlitePool, ConnectOptions, Connection, SqlitePool,
}; };
use std::str::FromStr; use std::str::FromStr;
use std::time::Duration;
use tracing::info; use tracing::info;
pub type Pool = SqlitePool; pub type Pool = SqlitePool;
pub async fn init_pool(database_url: &str) -> Result<Pool> { pub async fn init_pool(database_url: &str, max_connections: u32) -> Result<Pool> {
info!("Connecting to database: {}", database_url); info!("Connecting to database: {}", database_url);
let opts = SqliteConnectOptions::from_str(database_url)?.create_if_missing(true); // 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 pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
.max_connections(5) .max_connections(max_connections)
.connect_with(opts) .connect_with(opts)
.await?; .await?;
sqlx::query("PRAGMA journal_mode=WAL")
.execute(&pool)
.await?;
sqlx::query("PRAGMA foreign_keys=ON").execute(&pool).await?;
Ok(pool) Ok(pool)
} }
+51
View File
@@ -0,0 +1,51 @@
//! 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,6 +2,7 @@ pub mod app;
pub mod config; pub mod config;
pub mod db; pub mod db;
pub mod error; pub mod error;
pub mod extractors;
pub mod middleware; pub mod middleware;
pub mod modding; pub mod modding;
pub mod models; pub mod models;
@@ -20,6 +21,8 @@ pub async fn build_app(pool: db::Pool, data_dir: &str) -> Result<Router> {
database_url: "sqlite::memory:".into(), database_url: "sqlite::memory:".into(),
data_dir: data_dir.to_string(), data_dir: data_dir.to_string(),
max_connections: 1, max_connections: 1,
dev_content_games: Vec::new(),
content_packs: Vec::new(),
}; };
app::build(pool, cfg).await app::build(pool, cfg).await
} }
+67 -3
View File
@@ -1,4 +1,4 @@
use anyhow::Result; use anyhow::{Context, Result};
use openfut_core::{config, db, seed}; use openfut_core::{config, db, seed};
use tracing::info; use tracing::info;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
@@ -12,13 +12,77 @@ async fn main() -> Result<()> {
EnvFilter::try_from_default_env() EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "openfut_core=debug,tower_http=debug".into()), .unwrap_or_else(|_| "openfut_core=debug,tower_http=debug".into()),
) )
.with(tracing_subscriber::fmt::layer()) // 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))
.init(); .init();
let cfg = config::Config::from_env()?; 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(());
}
// `openfut-core reclassify <request.json> [--dry-run]` — correct the
// content_kind of already-imported owned rows. A profile import is
// once-only, so a taxonomy fix cannot arrive by re-importing; the adapter
// supplies card_id -> kind because only it can map its own taxonomy.
// Idempotent. `--dry-run` runs the same statements and rolls back, so an
// operator can see what a production run would touch before it touches it.
if std::env::args().nth(1).as_deref() == Some("reclassify") {
let path = std::env::args()
.nth(2)
.context("usage: openfut-core reclassify <request.json> [--dry-run]")?;
let dry_run = std::env::args().any(|a| a == "--dry-run");
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
db::run_migrations(&pool).await?;
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("read reclassify request {path}"))?;
let mut req: openfut_core::services::import::ReclassifyRequest =
serde_json::from_str(&raw).context("parse reclassify request JSON")?;
req.dry_run |= dry_run;
let outcome = openfut_core::services::import::reclassify_owned_content(&pool, &req).await?;
println!("{}", serde_json::to_string_pretty(&outcome)?);
return Ok(());
}
info!("OpenFUT Core starting on {}", cfg.listen_addr); info!("OpenFUT Core starting on {}", cfg.listen_addr);
let pool = db::init_pool(&cfg.database_url).await?; let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
db::run_migrations(&pool).await?; db::run_migrations(&pool).await?;
seed::maybe_seed(&pool).await?; seed::maybe_seed(&pool).await?;
+1 -24
View File
@@ -1,24 +1 @@
use anyhow::{Context, Result}; // Reserved for future modding loader utilities.
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,4 +1,2 @@
//! Modding support: load JSON data files from the data/ directory. //! Modding support: load JSON data files from the data/ directory.
//! All game content (cards, packs, objectives, SBCs) is data-driven. //! All game content (cards, packs, objectives, SBCs) is data-driven.
pub mod loader;
+290 -1
View File
@@ -13,6 +13,206 @@ pub enum Rarity {
Icon, 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
/// carries special-card programs like TOTW/Hero/Icon). Derived from a card's base
/// overall using FIFA 17's proven tier convention: gold >= 75, silver >= 65,
/// otherwise bronze (evidence: `fifa17-recon/tools/fut_cards.py` `tier()`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Quality {
Bronze,
Silver,
Gold,
}
impl Quality {
/// Classify a base overall rating into its quality tier.
pub fn from_overall(overall: u8) -> Self {
if overall >= 75 {
Quality::Gold
} else if overall >= 65 {
Quality::Silver
} else {
Quality::Bronze
}
}
}
/// What KIND of content one owned instance is.
///
/// The game-independent ownership vocabulary: Core has exactly one instance-based
/// ownership model (`owned_cards`) and this enum is the only thing that
/// distinguishes a manager from a player from a chemistry style. It carries NO
/// game numerics — a game adapter translates its own taxonomy (FIFA 17
/// `cardsubtypeid`, resource ranges, …) into these tokens before ownership
/// reaches Core, and translates them back on the way out.
///
/// The tokens are the persisted values of `owned_cards.content_kind` and are
/// pinned by that column's CHECK constraint (migration 0025).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[serde(rename_all = "lowercase")]
#[sqlx(rename_all = "lowercase")]
pub enum ContentKind {
/// A playable footballer card.
#[default]
Player,
/// A squad manager.
Manager,
/// Non-manager club staff (fitness/goalkeeping/… coaches, physios, scouts).
Staff,
/// A single-use item applied to a target (contract, fitness, healing,
/// chemistry style, position modifier, training).
Consumable,
/// A club kit (occupies the home or away designation).
Kit,
/// A club badge/crest.
Badge,
/// A match ball.
Ball,
/// A club stadium.
Stadium,
/// Owned content that is legitimately none of the above.
Misc,
}
impl ContentKind {
/// The canonical persisted token.
pub fn as_str(self) -> &'static str {
match self {
ContentKind::Player => "player",
ContentKind::Manager => "manager",
ContentKind::Staff => "staff",
ContentKind::Consumable => "consumable",
ContentKind::Kit => "kit",
ContentKind::Badge => "badge",
ContentKind::Ball => "ball",
ContentKind::Stadium => "stadium",
ContentKind::Misc => "misc",
}
}
/// Every kind, in declaration order (for exhaustive round-trip checks).
pub const ALL: [ContentKind; 9] = [
ContentKind::Player,
ContentKind::Manager,
ContentKind::Staff,
ContentKind::Consumable,
ContentKind::Kit,
ContentKind::Badge,
ContentKind::Ball,
ContentKind::Stadium,
ContentKind::Misc,
];
}
impl std::fmt::Display for ContentKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for ContentKind {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"player" => Ok(ContentKind::Player),
"manager" => Ok(ContentKind::Manager),
"staff" => Ok(ContentKind::Staff),
"consumable" => Ok(ContentKind::Consumable),
"kit" => Ok(ContentKind::Kit),
"badge" => Ok(ContentKind::Badge),
"ball" => Ok(ContentKind::Ball),
"stadium" => Ok(ContentKind::Stadium),
"misc" => Ok(ContentKind::Misc),
other => Err(format!("unknown content kind '{other}'")),
}
}
}
/// A club-scoped "active item" designation slot.
///
/// One owned instance may occupy at most one slot and each slot holds at most one
/// instance (migration 0026 `club_active_items`). Each slot admits exactly one
/// [`ContentKind`], so a badge can never be installed as a kit.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ActiveSlot {
HomeKit,
AwayKit,
Badge,
Ball,
Stadium,
}
impl ActiveSlot {
/// The canonical persisted token (`club_active_items.slot`).
pub fn as_str(self) -> &'static str {
match self {
ActiveSlot::HomeKit => "home_kit",
ActiveSlot::AwayKit => "away_kit",
ActiveSlot::Badge => "badge",
ActiveSlot::Ball => "ball",
ActiveSlot::Stadium => "stadium",
}
}
/// The one content kind this slot accepts.
pub fn required_kind(self) -> ContentKind {
match self {
ActiveSlot::HomeKit | ActiveSlot::AwayKit => ContentKind::Kit,
ActiveSlot::Badge => ContentKind::Badge,
ActiveSlot::Ball => ContentKind::Ball,
ActiveSlot::Stadium => ContentKind::Stadium,
}
}
pub const ALL: [ActiveSlot; 5] = [
ActiveSlot::HomeKit,
ActiveSlot::AwayKit,
ActiveSlot::Badge,
ActiveSlot::Ball,
ActiveSlot::Stadium,
];
}
impl std::fmt::Display for ActiveSlot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for ActiveSlot {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"home_kit" => Ok(ActiveSlot::HomeKit),
"away_kit" => Ok(ActiveSlot::AwayKit),
"badge" => Ok(ActiveSlot::Badge),
"ball" => Ok(ActiveSlot::Ball),
"stadium" => Ok(ActiveSlot::Stadium),
other => Err(format!("unknown active-item slot '{other}'")),
}
}
}
/// A card definition loaded from JSON data files. /// A card definition loaded from JSON data files.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardDefinition { pub struct CardDefinition {
@@ -33,7 +233,12 @@ pub struct CardDefinition {
pub image_path: Option<String>, pub image_path: Option<String>,
} }
/// A card instance owned by a club (stored in DB). /// One owned content INSTANCE (stored in DB).
///
/// Instance-based: `id` is the instance, `card_id` the definition, so two copies
/// of one definition are two rows. `content_kind` says what the instance IS;
/// `quantity` is an optional per-instance stack size (`None` = not a stack) and
/// never a substitute for an instance.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct OwnedCard { pub struct OwnedCard {
pub id: String, pub id: String,
@@ -45,4 +250,88 @@ pub struct OwnedCard {
pub chemistry_style: String, pub chemistry_style: String,
pub position_override: Option<String>, pub position_override: Option<String>,
pub training_bonus: i64, pub training_bonus: i64,
pub content_kind: ContentKind,
pub quantity: Option<i64>,
/// Match-contracts remaining on this instance, or `None` when Core tracks
/// no contract for it. `None` is not zero: the pack-fresh starting value is
/// a per-game rule the caller supplies, never a Core default.
pub contract_matches: Option<i64>,
}
/// The ONE canonical column list for reading an [`OwnedCard`].
///
/// `sqlx::FromRow` needs every field present in the row, so a hand-written
/// partial column list decodes into a runtime `ColumnNotFound` rather than a
/// compile error. Every read goes through this const so adding a column can
/// never leave a stale SELECT behind; append `WHERE …` to it.
pub const OWNED_CARD_SELECT: &str = "SELECT id, club_id, card_id, is_loan, \
loan_matches_remaining, acquired_at, chemistry_style, position_override, \
training_bonus, content_kind, quantity, contract_matches FROM owned_cards";
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
/// The persisted token, the serde token and the parser MUST agree for every
/// kind: the DB CHECK, the HTTP body and the adapter all read the same
/// vocabulary, so a divergence would silently mis-classify ownership.
#[test]
fn content_kind_round_trips_token_serde_and_parse() {
for kind in ContentKind::ALL {
let token = kind.as_str();
assert_eq!(
serde_json::to_string(&kind).unwrap(),
format!("\"{token}\""),
"serde token must equal the persisted token"
);
assert_eq!(
serde_json::from_str::<ContentKind>(&format!("\"{token}\"")).unwrap(),
kind
);
assert_eq!(ContentKind::from_str(token).unwrap(), kind);
assert_eq!(kind.to_string(), token);
}
}
/// The vocabulary is closed and pinned to migration 0025's CHECK list.
#[test]
fn content_kind_vocabulary_is_exactly_the_contract() {
let tokens: Vec<&str> = ContentKind::ALL.iter().map(|k| k.as_str()).collect();
assert_eq!(
tokens,
vec![
"player",
"manager",
"staff",
"consumable",
"kit",
"badge",
"ball",
"stadium",
"misc"
]
);
assert!(ContentKind::from_str("Player").is_err(), "case-sensitive");
assert!(ContentKind::from_str("coach").is_err());
assert_eq!(ContentKind::default(), ContentKind::Player);
}
#[test]
fn active_slot_round_trips_and_pins_its_required_kind() {
for slot in ActiveSlot::ALL {
let token = slot.as_str();
assert_eq!(ActiveSlot::from_str(token).unwrap(), slot);
assert_eq!(
serde_json::to_string(&slot).unwrap(),
format!("\"{token}\"")
);
}
assert_eq!(ActiveSlot::HomeKit.required_kind(), ContentKind::Kit);
assert_eq!(ActiveSlot::AwayKit.required_kind(), ContentKind::Kit);
assert_eq!(ActiveSlot::Badge.required_kind(), ContentKind::Badge);
assert_eq!(ActiveSlot::Ball.required_kind(), ContentKind::Ball);
assert_eq!(ActiveSlot::Stadium.required_kind(), ContentKind::Stadium);
assert!(ActiveSlot::from_str("home").is_err(), "0024's old token");
}
} }
+60
View File
@@ -0,0 +1,60 @@
//! 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,
}
+92 -16
View File
@@ -11,14 +11,43 @@ pub enum MatchOutcome {
Loss, Loss,
} }
#[derive(Debug, Deserialize)] /// Canonical, game-independent economic result of a completed match. The game
pub struct SubmitMatchRequest { /// adapter maps its own wire (FIFA17 `endReason`, score, …) onto this — Core
pub squad_id: String, /// never sees a game-specific reason string.
pub opponent_name: String, ///
pub goals_for: i64, /// * `Win` / `Draw` / `Loss` — a finished match; standard reward tiers.
pub goals_against: i64, /// * `Dnf` — did-not-finish (abandon/quit). Economically a loss, but tallied in
pub mode: String, /// its own statistics bucket and never in `matches_lost`.
pub goal_positions: Option<Vec<String>>, /// * `NoContest` — a voided match. Zero economic effect: no coins, XP, or
/// W/D/L/DNF change; recorded only for history + idempotency.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MatchResultKind {
Win,
Draw,
Loss,
Dnf,
NoContest,
}
impl MatchResultKind {
/// The canonical lowercase token persisted in `matches.outcome` and
/// `match_completions.result`.
pub fn as_str(self) -> &'static str {
match self {
MatchResultKind::Win => "win",
MatchResultKind::Draw => "draw",
MatchResultKind::Loss => "loss",
MatchResultKind::Dnf => "dnf",
MatchResultKind::NoContest => "no_contest",
}
}
/// Whether this result applies any economic effect (coins / XP / statistics /
/// objectives / achievements). `NoContest` is the only non-economic result.
pub fn is_economic(self) -> bool {
!matches!(self, MatchResultKind::NoContest)
}
} }
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
@@ -72,18 +101,65 @@ impl Match {
} }
} }
/// Request to atomically complete a match exactly once. `match_identity` is the
/// opaque, host-supplied per-match token that keys durable economic idempotency
/// (persona/profile + match_identity). `result` is the canonical outcome the
/// game adapter derived from its wire; `goals_for`/`goals_against` are recorded
/// for history and statistics (0-0 is normal for a DNF/no-contest).
#[derive(Debug, Clone, Deserialize)]
pub struct CompleteMatchRequest {
pub match_identity: String,
pub result: MatchResultKind,
pub squad_id: String,
pub opponent_name: String,
pub goals_for: i64,
pub goals_against: i64,
pub mode: String,
#[serde(default)]
pub goal_positions: Option<Vec<String>>,
/// Tick down `loan_matches_remaining` for this squad's starters and remove
/// the cards whose loan ran out.
///
/// OFF by default so a game whose loan model is its own (FIFA 17 does not
/// route loans through Core) is unaffected. Callers of Core's own match
/// modes opt in.
#[serde(default)]
pub expire_loans: bool,
/// Advance Core's OWN season model (division progress, and its end-of-season
/// coin/pack award).
///
/// OFF by default: this grants economy, and it is NOT the same thing as a
/// game's native seasons (FIFA 17 offline Seasons are the adapter's, keyed by
/// its own wire). Only a caller using Core's season model opts in.
#[serde(default)]
pub advance_season: bool,
}
/// Outcome of [`crate::services::match_service::complete_match`].
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct MatchRewardResult { pub struct MatchCompletionResult {
pub match_record: Match, /// `true` when THIS call applied the economic effect; `false` on an
/// idempotent replay of an already-completed match (the persisted canonical
/// result is echoed unchanged).
pub applied: bool,
pub match_identity: String,
pub result: MatchResultKind,
pub coins_awarded: i64, pub coins_awarded: i64,
pub xp_awarded: i64, pub xp_awarded: i64,
/// Club balance after completion — echoed so the host can render the wire
/// reward body without a second round-trip.
pub coins_balance: i64,
/// Objectives completed by this match (empty on a replay).
pub objectives_updated: Vec<String>, pub objectives_updated: Vec<String>,
/// Owned card IDs removed because the loan expired this match. /// Level-ups gained from this match's XP (empty on a replay).
pub expired_loans: Vec<String>,
/// Present when this match completed the current season.
pub season_end: Option<crate::models::season::SeasonEndSummary>,
/// Non-empty when the player levelled up one or more times from this match's XP.
pub level_ups: Vec<LevelUpEvent>, pub level_ups: Vec<LevelUpEvent>,
/// Achievements unlocked as a result of this match. /// Achievements unlocked by this match (empty on a replay).
pub achievements_unlocked: Vec<AchievementDefinition>, pub achievements_unlocked: Vec<AchievementDefinition>,
/// Owned card ids removed because their loan expired on this match. Empty
/// unless the caller set `expire_loans`, and empty on a replay.
pub expired_loans: Vec<String>,
/// Present when this match ended a Core season. `None` unless the caller set
/// `advance_season`, and `None` on a replay.
pub season_end: Option<crate::models::season::SeasonEndSummary>,
pub match_record: Match,
} }
+4 -3
View File
@@ -1,18 +1,19 @@
pub mod achievement; pub mod achievement;
pub mod card; pub mod card;
pub mod chemistry_style; pub mod chemistry_style;
pub mod notification;
pub mod club; pub mod club;
pub mod draft; pub mod draft;
pub mod fut_champs;
pub mod event; pub mod event;
pub mod season; pub mod fut_champs;
pub mod game_ext;
pub mod market; pub mod market;
pub mod match_result; pub mod match_result;
pub mod notification;
pub mod objective; pub mod objective;
pub mod pack; pub mod pack;
pub mod profile; pub mod profile;
pub mod reward; pub mod reward;
pub mod sbc; pub mod sbc;
pub mod season;
pub mod squad; pub mod squad;
pub mod statistics; pub mod statistics;
+13
View File
@@ -21,6 +21,19 @@ pub enum ObjectiveMetric {
CoinsEarned, 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 /// Objective definition from data/objectives/*.json
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObjectiveDefinition { pub struct ObjectiveDefinition {
+5 -1
View File
@@ -8,18 +8,22 @@ pub struct Profile {
pub username: String, pub username: String,
pub level: i64, pub level: i64,
pub xp: 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 created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
impl Profile { impl Profile {
pub fn new(username: impl Into<String>) -> Self { pub fn new(username: impl Into<String>, game_id: impl Into<String>) -> Self {
let now = Utc::now(); let now = Utc::now();
Self { Self {
id: Uuid::new_v4().to_string(), id: Uuid::new_v4().to_string(),
username: username.into(), username: username.into(),
level: 1, level: 1,
xp: 0, xp: 0,
game_id: game_id.into(),
created_at: now, created_at: now,
updated_at: now, updated_at: now,
} }
+14 -2
View File
@@ -33,16 +33,17 @@ pub struct SbcReward {
pub pack_id: Option<String>, pub pack_id: Option<String>,
} }
/// DB record of a completed submission /// DB record of a completed submission.
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SbcSubmission { pub struct SbcSubmission {
pub id: String, pub id: String,
pub profile_id: String, pub profile_id: String,
pub club_id: Option<String>,
pub sbc_id: String, pub sbc_id: String,
pub submitted_card_ids: String, pub submitted_card_ids: String,
pub passed: bool, pub passed: bool,
pub submitted_at: String, pub submitted_at: String,
pub repeatable: bool,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -51,6 +52,17 @@ pub struct SubmitSbcRequest {
pub owned_card_ids: Vec<String>, pub owned_card_ids: Vec<String>,
} }
#[derive(Debug, Deserialize)]
pub struct SaveSbcSquadRequest {
pub owned_card_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SbcSquadState {
pub sbc_id: String,
pub owned_card_ids: Vec<String>,
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct SbcResult { pub struct SbcResult {
pub passed: bool, pub passed: bool,
+46
View File
@@ -54,3 +54,49 @@ pub struct SquadPlayerInput {
pub is_captain: bool, pub is_captain: bool,
pub is_on_bench: bool, pub is_on_bench: bool,
} }
/// A complete squad, as a game client sends it.
///
/// # Why replacement rather than edits
///
/// Retail FIFA 17 sends the WHOLE squad on every save — roughly 2 KB carrying
/// every slot, item id and kit number — and a user swapping two players
/// produced nine changed slots across two saves. Slot deltas therefore do not
/// describe what the user did, and any attempt to derive `swap_players` or
/// `move_player` from them would be inventing intent the wire never carried.
///
/// So the only honest semantic operation is: *this is the squad now*.
///
/// Empty slots are simply absent from `slots`; a client that models an empty
/// slot as a zero item id must drop it at the adapter boundary rather than
/// sending a player Core would have to special-case.
#[derive(Debug, Clone, Default)]
pub struct SquadReplacement {
pub name: Option<String>,
pub formation: Option<String>,
pub slots: Vec<SlotAssignment>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SlotAssignment {
pub owned_card_id: String,
/// Core's slot numbering. The adapter maps the game's numbering onto it.
pub slot: i64,
pub is_captain: bool,
pub is_on_bench: bool,
}
/// Outcome of a replacement.
///
/// Carries the server's own evaluation and, separately, any disagreement with
/// what the client claimed — never a merged value.
#[derive(Debug, Clone)]
pub struct SquadReplaced {
pub squad: Squad,
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
View File
@@ -7,6 +7,7 @@ pub struct Statistics {
pub matches_won: i64, pub matches_won: i64,
pub matches_drawn: i64, pub matches_drawn: i64,
pub matches_lost: i64, pub matches_lost: i64,
pub matches_dnf: i64,
pub goals_scored: i64, pub goals_scored: i64,
pub goals_conceded: i64, pub goals_conceded: i64,
pub packs_opened: i64, pub packs_opened: i64,
@@ -25,6 +26,7 @@ impl Statistics {
matches_won: 0, matches_won: 0,
matches_drawn: 0, matches_drawn: 0,
matches_lost: 0, matches_lost: 0,
matches_dnf: 0,
goals_scored: 0, goals_scored: 0,
goals_conceded: 0, goals_conceded: 0,
packs_opened: 0, packs_opened: 0,
+12 -4
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{extract::State, Json}; use axum::{extract::State, Json};
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -7,12 +8,19 @@ use crate::{
services::{achievement as ach_svc, club as club_svc, profile as profile_svc}, services::{achievement as ach_svc, club as club_svc, profile as profile_svc},
}; };
pub async fn get_achievements(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_achievements(
let profile = profile_svc::get_active_profile(&state.pool).await?; State(state): State<AppState>,
game: GameId,
) -> 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 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 _ = 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?; let achievements = ach_svc::list_with_status(&state.pool, &state.achievement_defs).await?;
let earned = achievements.iter().filter(|a| a["unlocked"].as_bool().unwrap_or(false)).count(); let earned = achievements
.iter()
.filter(|a| a["unlocked"].as_bool().unwrap_or(false))
.count();
Ok(Json(json!({ Ok(Json(json!({
"achievements": achievements, "achievements": achievements,
"earned": earned, "earned": earned,
+93 -28
View File
@@ -1,9 +1,11 @@
use axum::{extract::State, Json}; use axum::{extract::State, Json};
use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::{ use crate::{
app::AppState, app::AppState,
error::AppResult, error::{AppError, AppResult},
extractors::GameId,
models::{club::Club, profile::CreateProfileRequest}, models::{club::Club, profile::CreateProfileRequest},
seed, seed,
services::{club as club_svc, profile as profile_svc}, services::{club as club_svc, profile as profile_svc},
@@ -11,11 +13,12 @@ use crate::{
pub async fn post_auth_local( pub async fn post_auth_local(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Json(req): Json<CreateProfileRequest>, Json(req): Json<CreateProfileRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let username = req.username.unwrap_or_else(|| "Player 1".into()); let username = req.username.unwrap_or_else(|| "Player 1".into());
let profile = profile_svc::create_profile(&state.pool, &username).await?; let profile = profile_svc::create_profile(&state.pool, &username, game.as_str()).await?;
let club = Club::new(&profile.id, "OpenFUT FC", 5000); let club = Club::new(&profile.id, "OpenFUT FC", 5000);
club_svc::create_club(&state.pool, &club).await?; club_svc::create_club(&state.pool, &club).await?;
@@ -31,51 +34,113 @@ pub async fn post_auth_local(
/// GET /auth/status — lightweight check: does a profile exist? /// GET /auth/status — lightweight check: does a profile exist?
/// Returns 200 `{ "has_profile": true/false }` without erroring. /// Returns 200 `{ "has_profile": true/false }` without erroring.
pub async fn get_auth_status(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_auth_status(
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles") 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())
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await?; .await?;
Ok(Json(json!({ "has_profile": count > 0 }))) 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. /// POST /auth/reset — wipe all game data and start fresh.
/// Deletes every user-data table in dependency order. The schema tables /// Requires `{"confirm":"reset"}` in the request body as a safeguard against
/// (migrations) are left intact; calling POST /auth/local afterwards /// accidental or unauthenticated calls. Deletes every user-data table in
/// creates a new profile. /// dependency order; schema (migrations) is preserved.
pub async fn post_auth_reset(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn post_auth_reset(
// Delete in reverse-dependency order to satisfy FK constraints State(state): State<AppState>,
// (SQLite FK enforcement is opt-in, but we follow the order anyway) game: GameId,
let tables = [ Json(req): Json<ResetRequest>,
"player_achievements", ) -> AppResult<Json<Value>> {
"notifications", 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?;
}
}
// Order matters: `match_completions` carries un-cascaded foreign keys to BOTH
// `matches` and `profiles`, so it has to go before either of them or the
// reset fails with a constraint error. Any profile that completed a match
// through /matches/complete has rows here.
for table in [
"match_completions",
"fut_champs_sessions", "fut_champs_sessions",
"sbc_submissions", "sbc_submissions",
"objective_progress", "objective_progress",
"position_goals", "position_goals",
"statistics", "statistics",
"seasons", "seasons",
"season_history",
"matches", "matches",
"market_listings",
"squad_players",
"squads",
"owned_cards",
"packs",
"events",
"draft_sessions", "draft_sessions",
"settings", "daily_checkins",
"clubs", ] {
"profiles", sqlx::query(&format!("DELETE FROM {table} WHERE profile_id = ?"))
]; .bind(&profile_id)
for table in &tables {
sqlx::query(&format!("DELETE FROM {table}"))
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;
} }
tracing::info!("Full game reset performed"); 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");
Ok(Json(json!({ Ok(Json(json!({
"reset": true, "reset": true,
"message": "All progress has been wiped. Call POST /auth/local to start a new club." "message": "All progress for this game has been wiped. Call POST /auth/local to start a new club."
}))) })))
} }
+111 -44
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
Json, Json,
@@ -8,17 +9,27 @@ use serde_json::{json, Value};
use crate::{ use crate::{
app::AppState, app::AppState,
error::{AppError, AppResult}, error::{AppError, AppResult},
models::card::OwnedCard, models::card::{OwnedCard, OWNED_CARD_SELECT},
services::{club as club_svc, profile as profile_svc}, services::{
club as club_svc, economy as economy_svc,
inventory::{self, OwnedItemQuery, OwnedItemView},
profile as profile_svc,
},
}; };
/// Quick-sell value for a card based on overall rating. /// Quick-sell value for a card based on overall rating.
fn quick_sell_coins(overall: u8) -> i64 { fn quick_sell_coins(overall: u8) -> i64 {
if overall >= 85 { 1500 } if overall >= 85 {
else if overall >= 80 { 900 } 1500
else if overall >= 75 { 600 } } else if overall >= 80 {
else if overall >= 65 { 300 } 900
else { 150 } } else if overall >= 75 {
600
} else if overall >= 65 {
300
} else {
150
}
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -56,7 +67,7 @@ pub async fn get_cards(
let rarity_ok = query let rarity_ok = query
.rarity .rarity
.as_ref() .as_ref()
.map(|r| format!("{:?}", c.rarity).to_lowercase() == r.to_lowercase()) .map(|r| c.rarity.as_str().eq_ignore_ascii_case(r))
.unwrap_or(true); .unwrap_or(true);
let pos_ok = query let pos_ok = query
.position .position
@@ -90,80 +101,136 @@ pub async fn get_cards(
cards.truncate(limit); cards.truncate(limit);
} }
Ok(Json(json!({ "cards": cards, "total": total, "returned": cards.len() }))) Ok(Json(
json!({ "cards": cards, "total": total, "returned": cards.len() }),
))
} }
pub async fn get_collection(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_collection(
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let owned = sqlx::query_as::<_, OwnedCard>( let owned = sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} WHERE 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 club_id = ?"
)
.bind(&club.id) .bind(&club.id)
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await?; .await?;
let with_defs: Vec<Value> = owned // An owned row whose definition is absent from the loaded content CANNOT be
.iter() // projected (there is nothing to project), but it must never vanish in
.filter_map(|o| { // silence: that silent `filter_map` drop is how a real club once served
state.card_db.get(&o.card_id).map(|def| { // `total: 0` while 1986 owned rows sat in the DB. So: keep the drop (a
// missing definition is not a 500), but LOG each one and report the count in
// the envelope so a caller and an operator both see it.
let mut unresolved: Vec<&str> = Vec::new();
let mut views: Vec<OwnedItemView> = Vec::with_capacity(owned.len());
for o in &owned {
let Some(def) = state.card_db.get(&o.card_id) else {
tracing::warn!(
owned_card_id = %o.id,
card_id = %o.card_id,
content_kind = %o.content_kind,
club_id = %club.id,
"owned item dropped from /collection: no card definition loaded"
);
unresolved.push(o.card_id.as_str());
continue;
};
let effective_overall = def.overall as i64 + o.training_bonus; let effective_overall = def.overall as i64 + o.training_bonus;
let effective_position = let effective_position = o.position_override.as_deref().unwrap_or(&def.position);
o.position_override.as_deref().unwrap_or(&def.position); let body = json!({
json!({
"owned_card_id": o.id, "owned_card_id": o.id,
"content_kind": o.content_kind,
"quantity": o.quantity,
"is_loan": o.is_loan, "is_loan": o.is_loan,
"loan_matches_remaining": o.loan_matches_remaining, "loan_matches_remaining": o.loan_matches_remaining,
"acquired_at": o.acquired_at, "acquired_at": o.acquired_at,
"chemistry_style": o.chemistry_style, "chemistry_style": o.chemistry_style,
"position_override": o.position_override, "position_override": o.position_override,
"training_bonus": o.training_bonus, "training_bonus": o.training_bonus,
// Core's stored value verbatim: `null` means Core tracks no contract
// for this instance, which is NOT zero. Substituting a default here
// would bake one game's pack-fresh number into every game's envelope.
"contract_matches": o.contract_matches,
"effective_overall": effective_overall, "effective_overall": effective_overall,
"effective_position": effective_position, "effective_position": effective_position,
"card": def, "card": def,
}) });
}) views.push(OwnedItemView {
}) owned_card_id: o.id.clone(),
.collect(); content_kind: o.content_kind,
base_overall: def.overall,
effective_overall,
position: effective_position.to_string(),
nation: def.nation.clone(),
league: def.league.clone(),
club: def.club.clone(),
body,
});
}
if !unresolved.is_empty() {
unresolved.sort_unstable();
unresolved.dedup();
tracing::warn!(
club_id = %club.id,
owned_rows = owned.len(),
dropped = owned.len() - views.len(),
definitions = ?unresolved,
"/collection dropped owned items with missing definitions"
);
}
Ok(Json( let owned_rows = owned.len();
json!({ "collection": with_defs, "total": with_defs.len() }), let unresolved_items = owned_rows - views.len();
)) let page = inventory::apply_query(views, &query);
let returned = page.items.len();
Ok(Json(json!({
"collection": page.items,
"total": page.total,
"returned": returned,
"offset": page.offset,
"limit": page.limit,
// Ownership truth vs. what could be projected. `owned_rows` counts every
// row Core actually owns for this club; `unresolved_items` counts those
// dropped for want of a definition. Both zero-cost when nothing is wrong.
"owned_rows": owned_rows,
"unresolved_items": unresolved_items,
"unresolved_definitions": unresolved,
})))
} }
/// Quick-sell an owned card for instant coins. The card is removed from the collection. /// Quick-sell an owned card for instant coins. The card is removed from the collection.
pub async fn delete_owned_card( pub async fn delete_owned_card(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(owned_card_id): Path<String>, Path(owned_card_id): Path<String>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let owned = sqlx::query_as::<_, OwnedCard>( let owned = sqlx::query_as::<_, OwnedCard>(&format!(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \ "{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"
chemistry_style, position_override, training_bonus \ ))
FROM owned_cards WHERE id = ? AND club_id = ?",
)
.bind(&owned_card_id) .bind(&owned_card_id)
.bind(&club.id) .bind(&club.id)
.fetch_optional(&state.pool) .fetch_optional(&state.pool)
.await? .await?
.ok_or_else(|| AppError::NotFound(format!("owned card '{owned_card_id}' not found")))?; .ok_or_else(|| AppError::NotFound(format!("owned card '{owned_card_id}' not found")))?;
let card = state let card = state.card_db.get(&owned.card_id).ok_or_else(|| {
.card_db AppError::NotFound(format!("card definition '{}' missing", owned.card_id))
.get(&owned.card_id) })?;
.ok_or_else(|| AppError::NotFound(format!("card definition '{}' missing", owned.card_id)))?;
let coins = quick_sell_coins(card.overall); let coins = quick_sell_coins(card.overall);
sqlx::query("DELETE FROM owned_cards WHERE id = ?") // Delegate to the economy authority rather than hand-rolling DELETE + add_coins:
.bind(&owned_card_id) // that pair ran on the pool with NO transaction (a failed credit left the card
.execute(&state.pool) // destroyed for nothing) and it skipped `squad_players`, whose FK onto
.await?; // `owned_cards(id)` made quick-selling a squadded card fail with SQLite 787.
economy_svc::sell_item(&state.pool, &club.id, &owned_card_id, coins).await?;
club_svc::add_coins(&state.pool, &club.id, coins).await?;
Ok(Json(json!({ Ok(Json(json!({
"quick_sold": owned_card_id, "quick_sold": owned_card_id,
+125 -24
View File
@@ -1,15 +1,19 @@
use crate::extractors::GameId;
use crate::{ use crate::{
app::AppState, app::AppState,
error::AppResult, error::{AppError, AppResult},
models::club::Club, models::{card::ActiveSlot, club::Club},
services::{checkin as checkin_svc, club as club_svc, profile as profile_svc, statistics as stats_svc}, services::{
checkin as checkin_svc, club as club_svc, profile as profile_svc, statistics as stats_svc,
},
}; };
use axum::{extract::State, Json}; use axum::{extract::State, Json};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::str::FromStr;
pub async fn get_club(State(state): State<AppState>) -> AppResult<Json<Club>> { pub async fn get_club(State(state): State<AppState>, game: GameId) -> AppResult<Json<Club>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
Ok(Json(club)) Ok(Json(club))
} }
@@ -22,9 +26,10 @@ pub struct UpdateClubRequest {
pub async fn put_club( pub async fn put_club(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Json(req): Json<UpdateClubRequest>, Json(req): Json<UpdateClubRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let updated = club_svc::update_club( let updated = club_svc::update_club(
&state.pool, &state.pool,
@@ -36,8 +41,11 @@ pub async fn put_club(
Ok(Json(json!({ "club": updated }))) Ok(Json(json!({ "club": updated })))
} }
pub async fn get_checkin_status(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_checkin_status(
let profile = profile_svc::get_active_profile(&state.pool).await?; State(state): State<AppState>,
game: GameId,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let status = checkin_svc::get_status(&state.pool, &profile.id).await?; let status = checkin_svc::get_status(&state.pool, &profile.id).await?;
Ok(Json(json!({ Ok(Json(json!({
"available": status.available, "available": status.available,
@@ -48,8 +56,8 @@ pub async fn get_checkin_status(State(state): State<AppState>) -> AppResult<Json
}))) })))
} }
pub async fn post_checkin(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn post_checkin(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let r = checkin_svc::claim(&state.pool, &profile.id, &club.id).await?; let r = checkin_svc::claim(&state.pool, &profile.id, &club.id).await?;
Ok(Json(json!({ Ok(Json(json!({
@@ -60,14 +68,13 @@ pub async fn post_checkin(State(state): State<AppState>) -> AppResult<Json<Value
}))) })))
} }
pub async fn get_milestones(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_milestones(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?; let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
let seasons_completed: i64 = sqlx::query_scalar( let seasons_completed: i64 =
"SELECT COUNT(*) FROM season_history WHERE profile_id = ?", sqlx::query_scalar("SELECT COUNT(*) FROM season_history WHERE profile_id = ?")
)
.bind(&profile.id) .bind(&profile.id)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await .await
@@ -81,25 +88,21 @@ pub async fn get_milestones(State(state): State<AppState>) -> AppResult<Json<Val
.await .await
.unwrap_or(10); .unwrap_or(10);
let cards_owned: i64 = sqlx::query_scalar( let cards_owned: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE club_id = ?")
"SELECT COUNT(*) FROM owned_cards WHERE club_id = ?",
)
.bind(&club.id) .bind(&club.id)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await .await
.unwrap_or(0); .unwrap_or(0);
let sbcs_completed: i64 = sqlx::query_scalar( let sbcs_completed: i64 =
"SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1", sqlx::query_scalar("SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1")
)
.bind(&club.id) .bind(&club.id)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await .await
.unwrap_or(0); .unwrap_or(0);
let total_checkins: i64 = sqlx::query_scalar( let total_checkins: i64 =
"SELECT COUNT(*) FROM daily_checkins WHERE profile_id = ?", sqlx::query_scalar("SELECT COUNT(*) FROM daily_checkins WHERE profile_id = ?")
)
.bind(&profile.id) .bind(&profile.id)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await .await
@@ -122,3 +125,101 @@ pub async fn get_milestones(State(state): State<AppState>) -> AppResult<Json<Val
"club_level": club.level, "club_level": club.level,
}))) })))
} }
/// The owned card assigned as the active squad's manager, or `null`. Generic:
/// Core returns the ownership-backed assignment; the FIFA 17 adapter shapes the
/// manager wire item from it (itemType/contract/chemistry are adapter concerns).
pub async fn get_squad_manager(
State(state): State<AppState>,
game: GameId,
) -> 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 manager = club_svc::get_squad_manager(&state.pool, &club.id).await?;
Ok(Json(json!({ "manager": manager })))
}
#[derive(Deserialize)]
pub struct SetManagerRequest {
/// The owned card to assign as manager, or `null`/absent to clear it.
pub owned_card_id: Option<String>,
}
/// Assign (or, with a null/absent `owned_card_id`, clear) the active squad's
/// manager. Fail-closed: the card must be owned by this club and the club must
/// have a squad. Returns the resulting assignment.
pub async fn put_squad_manager(
State(state): State<AppState>,
game: GameId,
Json(req): Json<SetManagerRequest>,
) -> 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?;
match req.owned_card_id {
Some(owned_card_id) => {
club_svc::set_squad_manager(&state.pool, &club.id, &owned_card_id).await?
}
None => club_svc::clear_squad_manager(&state.pool, &club.id).await?,
}
let manager = club_svc::get_squad_manager(&state.pool, &club.id).await?;
Ok(Json(json!({ "manager": manager })))
}
/// Every active club-item designation, slot-keyed and EXPLICIT: all five slots
/// are always present, an empty slot being `null`. A caller therefore never has
/// to guess whether a missing key means "no item" or "unsupported slot".
fn active_items_body(items: &club_svc::ActiveClubItems) -> AppResult<Value> {
let mut body = serde_json::Map::new();
for slot in ActiveSlot::ALL {
body.insert(
slot.as_str().to_string(),
serde_json::to_value(items.get(slot))?,
);
}
Ok(Value::Object(body))
}
/// Return the club's ownership-backed active item designations.
pub async fn get_active_items(
State(state): State<AppState>,
game: GameId,
) -> 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 items = club_svc::get_active_club_items(&state.pool, &club.id).await?;
Ok(Json(json!({ "active_items": active_items_body(&items)? })))
}
#[derive(Deserialize)]
pub struct SetActiveItemRequest {
/// Which club role to write: home_kit | away_kit | badge | ball | stadium.
///
/// Taken as a string and parsed here so an unknown slot comes back as this
/// crate's `400 {"error": …}` envelope, like every other bad request, rather
/// than axum's plain-text deserialization rejection.
pub slot: String,
/// The owned instance to designate, or `null`/absent to clear the slot.
#[serde(default)]
pub owned_card_id: Option<String>,
}
/// Write ONE active club-item designation. Core enforces ownership and that the
/// slot admits the item's `content_kind`; game adapters own their own mapping
/// from a wire item onto that generic kind.
pub async fn put_active_item(
State(state): State<AppState>,
game: GameId,
Json(req): Json<SetActiveItemRequest>,
) -> AppResult<Json<Value>> {
let slot = ActiveSlot::from_str(&req.slot).map_err(AppError::BadRequest)?;
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?;
match req.owned_card_id {
Some(owned_card_id) => {
club_svc::set_active_club_item(&state.pool, &club.id, slot, &owned_card_id).await?
}
None => club_svc::clear_active_club_item(&state.pool, &club.id, slot).await?,
}
let items = club_svc::get_active_club_items(&state.pool, &club.id).await?;
Ok(Json(json!({ "active_items": active_items_body(&items)? })))
}
+82
View File
@@ -0,0 +1,82 @@
//! `POST /consumables/apply` — the HTTP boundary for Core's atomic
//! apply-one-consumable transaction.
//!
//! Game-neutral like the rest of Core's surface: the caller names an owned source
//! instance, an owned target and a described [`InstanceEffect`]; Core resolves the
//! game-scoped active profile and its club from the `X-OpenFUT-Game` header, so no
//! caller can reach across clubs. Everything after that is one durable SQLite
//! transaction in [`consume::consume_item`], guarded by
//! `UNIQUE(profile_id, action_identity)`.
//!
//! The effect vocabulary is closed and validated by Core — see
//! [`crate::services::instance_effect`] for why the host describes an effect
//! instead of supplying one.
use axum::{extract::State, Json};
use serde::Deserialize;
use crate::{
app::AppState,
error::AppResult,
extractors::GameId,
models::card::ContentKind,
services::{
club as club_svc,
consume::{self, ConsumeOutcome, ConsumeRequest, ConsumeTarget, SourceConsumption},
instance_effect::InstanceEffect,
profile as profile_svc,
},
};
#[derive(Deserialize)]
pub struct ApplyConsumableRequest {
/// Opaque, stable per-application token. Core never parses it; it only
/// enforces uniqueness, so a retried HTTP request replays instead of
/// applying twice.
pub action_identity: String,
pub source_owned_card_id: String,
pub target_owned_card_id: String,
/// The kind the target MUST be. The caller states it because only the caller
/// knows which family its consumable belongs to; a mismatch is refused rather
/// than applied to whatever happens to be there.
pub target_kind: ContentKind,
pub effect: InstanceEffect,
}
/// `POST /consumables/apply` — atomic validate + apply + consume-once.
///
/// `applied: false` in the response means the `action_identity` was already
/// recorded: nothing was mutated and the recorded outcome is echoed.
pub async fn post_apply_consumable(
State(state): State<AppState>,
game: GameId,
Json(req): Json<ApplyConsumableRequest>,
) -> AppResult<Json<ConsumeOutcome>> {
// Both ids are needed: the profile scopes the replay guard, the club scopes
// ownership. Same resolution pair as `cards::get_collection`.
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 outcome = consume::consume_item(
&state.pool,
&profile.id,
&club.id,
&ConsumeRequest {
action_identity: &req.action_identity,
source_owned_card_id: &req.source_owned_card_id,
// A consumable is the only thing that can be applied, and it is spent
// whole: FIFA-style stacking is the adapter's projection, not an
// ownership model Core has for these instances.
expected_source_kind: ContentKind::Consumable,
consumption: SourceConsumption::DestroyInstance,
target: ConsumeTarget::OwnedCard {
owned_card_id: &req.target_owned_card_id,
expected_kind: req.target_kind,
},
},
&req.effect,
)
.await?;
Ok(Json(outcome))
}
+38 -13
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{extract::State, Json}; use axum::{extract::State, Json};
use rand::{Rng, SeedableRng}; use rand::{Rng, SeedableRng};
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -9,8 +10,8 @@ use crate::{
services::{club as club_svc, profile as profile_svc, season as season_svc}, services::{club as club_svc, profile as profile_svc, season as season_svc},
}; };
pub async fn get_division(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_division(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 _club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let season = season_svc::get_or_create(&state.pool, &profile.id).await?; let season = season_svc::get_or_create(&state.pool, &profile.id).await?;
@@ -36,14 +37,20 @@ pub async fn get_division(State(state): State<AppState>) -> AppResult<Json<Value
}))) })))
} }
pub async fn get_division_history(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_division_history(
let profile = profile_svc::get_active_profile(&state.pool).await?; State(state): State<AppState>,
game: GameId,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let history = season_svc::get_history(&state.pool, &profile.id).await?; let history = season_svc::get_history(&state.pool, &profile.id).await?;
Ok(Json(json!({ "history": history, "total": history.len() }))) Ok(Json(json!({ "history": history, "total": history.len() })))
} }
pub async fn get_division_leaderboard(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_division_leaderboard(
let profile = profile_svc::get_active_profile(&state.pool).await?; State(state): State<AppState>,
game: GameId,
) -> 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let season = season_svc::get_or_create(&state.pool, &profile.id).await?; let season = season_svc::get_or_create(&state.pool, &profile.id).await?;
@@ -52,11 +59,26 @@ pub async fn get_division_leaderboard(State(state): State<AppState>) -> AppResul
let mut rng = rand::rngs::SmallRng::seed_from_u64(seed); let mut rng = rand::rngs::SmallRng::seed_from_u64(seed);
const NPC_NAMES: &[&str] = &[ const NPC_NAMES: &[&str] = &[
"Riverside FC", "City Athletic", "County United", "Valley Rangers", "Riverside FC",
"Harbor Town FC", "Mountside City", "Lakewood Athletic", "Eastbrook United", "City Athletic",
"Westfield Rovers", "Northgate FC", "Southport Athletic", "Ironbridge City", "County United",
"Milldale United", "Hillcrest Rangers", "Bayside FC", "Thornfield Athletic", "Valley Rangers",
"Greenhill United", "Coldwater City", "Redbury Rangers", "Ashdown FC", "Harbor Town FC",
"Mountside City",
"Lakewood Athletic",
"Eastbrook United",
"Westfield Rovers",
"Northgate FC",
"Southport Athletic",
"Ironbridge City",
"Milldale United",
"Hillcrest Rangers",
"Bayside FC",
"Thornfield Athletic",
"Greenhill United",
"Coldwater City",
"Redbury Rangers",
"Ashdown FC",
]; ];
// Pick 9 NPC names without repetition using the seeded RNG // Pick 9 NPC names without repetition using the seeded RNG
@@ -74,8 +96,11 @@ pub async fn get_division_leaderboard(State(state): State<AppState>) -> AppResul
// Quality bias: index 0-2 = stronger, 6-8 = weaker // Quality bias: index 0-2 = stronger, 6-8 = weaker
let quality: f64 = 1.0 - (idx as f64 / 8.0); // 1.0 → 0.0 let quality: f64 = 1.0 - (idx as f64 / 8.0); // 1.0 → 0.0
let expected_win_rate = 0.2 + quality * 0.6; // 0.20.8 let expected_win_rate = 0.2 + quality * 0.6; // 0.20.8
let wins = (npc_matches as f64 * expected_win_rate * (0.8 + rng.gen::<f64>() * 0.4)) as i64; let wins =
let losses = (npc_matches as f64 * (1.0 - expected_win_rate) * (0.8 + rng.gen::<f64>() * 0.4)) as i64; (npc_matches as f64 * expected_win_rate * (0.8 + rng.gen::<f64>() * 0.4)) as i64;
let losses = (npc_matches as f64
* (1.0 - expected_win_rate)
* (0.8 + rng.gen::<f64>() * 0.4)) as i64;
let draws = (npc_matches - wins - losses).max(0); let draws = (npc_matches - wins - losses).max(0);
let pts = wins * 3 + draws; let pts = wins * 3 + draws;
json!({ json!({
+13 -6
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
Json, Json,
@@ -39,21 +40,25 @@ pub async fn get_draft_squad(
/// until all 11 slots are filled. /// until all 11 slots are filled.
pub async fn post_draft_start( pub async fn post_draft_start(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Query(query): Query<DraftQuery>, Query(query): Query<DraftQuery>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let difficulty = query.difficulty.as_deref().unwrap_or("professional"); let difficulty = query.difficulty.as_deref().unwrap_or("professional");
let session = draft_svc::start_draft(&state.pool, &state.card_db, &profile.id, difficulty).await?; let session =
draft_svc::start_draft(&state.pool, &state.card_db, &profile.id, difficulty).await?;
Ok(Json(session)) Ok(Json(session))
} }
/// Get the current state of a draft session. /// Get the current state of a draft session.
pub async fn get_draft_session( pub async fn get_draft_session(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(session_id): Path<String>, Path(session_id): Path<String>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let session = draft_svc::get_draft(&state.pool, &state.card_db, &profile.id, &session_id).await?; let session =
draft_svc::get_draft(&state.pool, &state.card_db, &profile.id, &session_id).await?;
Ok(Json(session)) Ok(Json(session))
} }
@@ -69,10 +74,11 @@ pub struct PickRequest {
/// and rewards (coins + optional pack) are granted automatically. /// and rewards (coins + optional pack) are granted automatically.
pub async fn post_draft_pick( pub async fn post_draft_pick(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(session_id): Path<String>, Path(session_id): Path<String>,
Json(req): Json<PickRequest>, Json(req): Json<PickRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let session = draft_svc::pick_card( let session = draft_svc::pick_card(
&state.pool, &state.pool,
@@ -89,9 +95,10 @@ pub async fn post_draft_pick(
/// Abandon an active draft session. No rewards are granted. /// Abandon an active draft session. No rewards are granted.
pub async fn post_draft_abandon( pub async fn post_draft_abandon(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(session_id): Path<String>, Path(session_id): Path<String>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let result = draft_svc::abandon_draft(&state.pool, &profile.id, &session_id).await?; let result = draft_svc::abandon_draft(&state.pool, &profile.id, &session_id).await?;
Ok(Json(result)) Ok(Json(result))
} }
+220
View File
@@ -0,0 +1,220 @@
//! 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 }))
}
/// `POST /economy/settle-sale` request.
///
/// This is the ONE economy route that names clubs explicitly, and it has to: a
/// market sale has two sides, and the module's active-profile resolution can only
/// ever describe one. Both are optional and default to the game-scoped active
/// club, so the single-player case stays as terse as every other route:
///
/// * `seller_club_id` omitted -> the active club is the seller (it listed the
/// item), which is the production shape.
/// * `buyer_club_id` omitted -> the counterparty is OUTSIDE the modelled
/// economy: no balance is debited and the item leaves the inventory. It does
/// NOT silently fall back to the active club, because that would settle a sale
/// between a club and itself.
#[derive(Deserialize)]
pub struct SettleSaleRequest {
/// The authoritative owned-item instance changing hands.
pub item_id: String,
/// What the buyer pays. The fee is withheld from this, never added to it.
pub gross: i64,
/// Withheld from the seller and destroyed. The RATE is a per-game policy the
/// caller owns; Core only checks `0 <= fee <= gross`.
pub fee: i64,
#[serde(default)]
pub seller_club_id: Option<String>,
#[serde(default)]
pub buyer_club_id: Option<String>,
}
/// `POST /economy/settle-sale` — atomically debit the buyer, transfer the existing
/// item, credit the seller net of the fee, and destroy the fee.
pub async fn post_settle_sale(
State(state): State<AppState>,
game: GameId,
Json(req): Json<SettleSaleRequest>,
) -> AppResult<Json<economy::SaleReceipt>> {
let seller = match req.seller_club_id {
Some(id) => id,
None => resolve_club(&state, &game).await?,
};
let buyer = match req.buyer_club_id.as_deref() {
Some(id) => economy::SaleBuyer::Club(id),
None => economy::SaleBuyer::Outside,
};
let receipt = economy::settle_sale(
&state.pool,
&req.item_id,
&seller,
buyer,
economy::SaleTerms {
gross: req.gross,
fee: req.fee,
},
)
.await?;
Ok(Json(receipt))
}
+27 -17
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
Json, Json,
@@ -8,12 +9,14 @@ use serde_json::{json, Value};
use crate::{ use crate::{
app::AppState, app::AppState,
error::AppResult, error::AppResult,
services::{club as club_svc, fut_champs as champs_svc, profile as profile_svc, season as season_svc}, services::{
club as club_svc, fut_champs as champs_svc, profile as profile_svc, season as season_svc,
},
}; };
/// GET /fut-champs — current active session, or null if none. /// GET /fut-champs — current active session, or null if none.
pub async fn get_fut_champs(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_fut_champs(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let session = champs_svc::get_active_session(&state.pool, &profile.id).await?; let session = champs_svc::get_active_session(&state.pool, &profile.id).await?;
Ok(Json(json!({ Ok(Json(json!({
@@ -23,8 +26,11 @@ pub async fn get_fut_champs(State(state): State<AppState>) -> AppResult<Json<Val
} }
/// POST /fut-champs/start — open a new FUT Champions week. /// POST /fut-champs/start — open a new FUT Champions week.
pub async fn post_start_fut_champs(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn post_start_fut_champs(
let profile = profile_svc::get_active_profile(&state.pool).await?; State(state): State<AppState>,
game: GameId,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let session = champs_svc::start_session(&state.pool, &profile.id).await?; let session = champs_svc::start_session(&state.pool, &profile.id).await?;
Ok(Json(json!({ Ok(Json(json!({
@@ -42,10 +48,11 @@ pub struct ChampsMatchRequest {
/// POST /fut-champs/:session_id/result — record a match in this session. /// POST /fut-champs/:session_id/result — record a match in this session.
pub async fn post_champs_result( pub async fn post_champs_result(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(session_id): Path<String>, Path(session_id): Path<String>,
Json(req): Json<ChampsMatchRequest>, Json(req): Json<ChampsMatchRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let session = champs_svc::record_match( let session = champs_svc::record_match(
&state.pool, &state.pool,
@@ -75,9 +82,10 @@ pub async fn post_champs_result(
/// POST /fut-champs/:session_id/claim — claim end-of-week rewards. /// POST /fut-champs/:session_id/claim — claim end-of-week rewards.
pub async fn post_claim_champs_rewards( pub async fn post_claim_champs_rewards(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(session_id): Path<String>, Path(session_id): Path<String>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let result = champs_svc::claim_rewards( let result = champs_svc::claim_rewards(
@@ -93,8 +101,11 @@ pub async fn post_claim_champs_rewards(
} }
/// GET /fut-champs/history — past sessions, newest first. /// GET /fut-champs/history — past sessions, newest first.
pub async fn get_champs_history(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_champs_history(
let profile = profile_svc::get_active_profile(&state.pool).await?; State(state): State<AppState>,
game: GameId,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let history = champs_svc::get_history(&state.pool, &profile.id).await?; let history = champs_svc::get_history(&state.pool, &profile.id).await?;
Ok(Json(json!({ Ok(Json(json!({
@@ -104,19 +115,18 @@ pub async fn get_champs_history(State(state): State<AppState>) -> AppResult<Json
} }
/// POST /rivals/claim-weekly — claim weekly Division Rivals reward. /// POST /rivals/claim-weekly — claim weekly Division Rivals reward.
pub async fn post_claim_rivals_reward(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn post_claim_rivals_reward(
let profile = profile_svc::get_active_profile(&state.pool).await?; State(state): State<AppState>,
game: GameId,
) -> 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
// Ensure a season row exists // Ensure a season row exists
season_svc::get_or_create(&state.pool, &profile.id).await?; season_svc::get_or_create(&state.pool, &profile.id).await?;
let result = champs_svc::claim_rivals_reward( let result =
&state.pool, champs_svc::claim_rivals_reward(&state.pool, &profile.id, &club.id, &state.pack_defs)
&profile.id,
&club.id,
&state.pack_defs,
)
.await?; .await?;
Ok(Json(result)) Ok(Json(result))
+22 -10
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
Json, Json,
@@ -12,14 +13,16 @@ use crate::{
services::{club as club_svc, market as market_svc, profile as profile_svc}, services::{club as club_svc, market as market_svc, profile as profile_svc},
}; };
pub async fn get_trade_history(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_trade_history(
let profile = profile_svc::get_active_profile(&state.pool).await?; State(state): State<AppState>,
game: GameId,
) -> 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let trades = market_svc::get_trade_history(&state.pool, &club.id).await?; let trades = market_svc::get_trade_history(&state.pool, &club.id).await?;
Ok(Json(json!({ "trades": trades, "total": trades.len() }))) Ok(Json(json!({ "trades": trades, "total": trades.len() })))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct MarketQuery { pub struct MarketQuery {
pub min_overall: Option<u8>, pub min_overall: Option<u8>,
@@ -52,9 +55,10 @@ pub async fn get_market(
pub async fn post_market_buy( pub async fn post_market_buy(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Json(req): Json<BuyListingRequest>, Json(req): Json<BuyListingRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 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?; let card = market_svc::buy_listing(&state.pool, &state.card_db, &club.id, &req).await?;
@@ -65,9 +69,10 @@ pub async fn post_market_buy(
pub async fn post_market_sell( pub async fn post_market_sell(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Json(req): Json<SellCardRequest>, Json(req): Json<SellCardRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 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?; let new_balance = market_svc::sell_card(&state.pool, &state.card_db, &club.id, &req).await?;
@@ -83,19 +88,26 @@ pub async fn post_market_refresh(State(state): State<AppState>) -> AppResult<Jso
} }
/// Return all active market listings posted by the current player's club. /// Return all active market listings posted by the current player's club.
pub async fn get_my_listings(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_my_listings(
let profile = profile_svc::get_active_profile(&state.pool).await?; State(state): State<AppState>,
game: GameId,
) -> 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 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?; let listings =
Ok(Json(json!({ "listings": listings, "total": listings.len() }))) market_svc::get_listings_by_seller(&state.pool, &state.card_db, &club.id).await?;
Ok(Json(
json!({ "listings": listings, "total": listings.len() }),
))
} }
/// Cancel a player-posted listing and return the card to the collection. /// Cancel a player-posted listing and return the card to the collection.
pub async fn delete_market_listing( pub async fn delete_market_listing(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(listing_id): Path<String>, Path(listing_id): Path<String>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
market_svc::cancel_listing(&state.pool, &club.id, &listing_id).await?; market_svc::cancel_listing(&state.pool, &club.id, &listing_id).await?;
Ok(Json(json!({ "cancelled": listing_id }))) Ok(Json(json!({ "cancelled": listing_id })))
+121 -10
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Query, State}, extract::{Query, State},
Json, Json,
@@ -7,9 +8,9 @@ use serde_json::{json, Value};
use crate::{ use crate::{
app::AppState, app::AppState,
error::AppResult, error::{AppError, AppResult},
models::match_result::{Match, MatchRewardResult, SubmitMatchRequest}, models::match_result::{CompleteMatchRequest, Match, MatchCompletionResult},
services::{club as club_svc, match_service, profile as profile_svc}, services::{club as club_svc, match_service, notification, profile as profile_svc},
}; };
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -20,9 +21,10 @@ pub struct MatchHistoryQuery {
pub async fn get_matches( pub async fn get_matches(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Query(query): Query<MatchHistoryQuery>, Query(query): Query<MatchHistoryQuery>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let limit = query.limit.unwrap_or(20).clamp(1, 100); let limit = query.limit.unwrap_or(20).clamp(1, 100);
let matches = if let Some(mode) = &query.mode { let matches = if let Some(mode) = &query.mode {
@@ -61,16 +63,125 @@ pub async fn get_opponent(
Ok(Json(opponent)) Ok(Json(opponent))
} }
pub async fn post_match_result( /// `POST /matches/result` — REMOVED as an economy path, and deliberately kept as
/// an explicit rejection rather than a 404.
///
/// It used to grant coins, XP, level-ups, statistics, objectives, loan expiry,
/// season progression and achievements across a dozen separate writes with NO
/// transaction and NO idempotency key, which made it a second economy authority
/// that could re-credit the same match on every call and could half-apply on any
/// mid-way failure. Exactly-once needs a caller-supplied match identity, which
/// this request shape does not carry and cannot derive (a body fingerprint would
/// collapse two legitimate matches with the same scoreline into one).
///
/// Callers submit to `/matches/complete` with a `match_identity`; the loan and
/// season behaviour this route used to trigger is available there via
/// `expire_loans` / `advance_season`.
pub async fn post_match_result() -> AppResult<Json<Value>> {
Err(AppError::BadRequest(
"POST /matches/result is no longer an economy path: it had no transaction \
and no idempotency key. Submit to POST /matches/complete with a \
match_identity (and expire_loans / advance_season if you need Core's loan \
and season progression)."
.into(),
))
}
/// `POST /matches/complete` — the authoritative, atomic, exactly-once match
/// economy entry point (the game host routes a finished match here). Idempotent
/// on `(profile, match_identity)`: a replay returns the persisted canonical
/// result with `applied = false` and grants nothing twice.
pub async fn post_match_complete(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<SubmitMatchRequest>, game: GameId,
) -> AppResult<Json<MatchRewardResult>> { Json(req): Json<CompleteMatchRequest>,
let profile = profile_svc::get_active_profile(&state.pool).await?; ) -> AppResult<Json<MatchCompletionResult>> {
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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let result = let result = match_service::complete_match(
match_service::process_match(&state.pool, &profile.id, &club.id, &req, &state.obj_defs, &state.achievement_defs) &state.pool,
&profile.id,
&club.id,
&req,
&state.obj_defs,
&state.achievement_defs,
)
.await?; .await?;
// Player-visible notifications are non-durable side effects, so they are
// emitted AFTER the economy transaction commits, never inside it — a failed
// notification must not roll back a completed match. Gated on `applied`, so
// an idempotent replay does not re-notify (the pooled path this replaced had
// no such guard). Achievement notifications are written in-transaction by
// `check_and_unlock_tx` and are deliberately not repeated here.
if result.applied {
emit_match_notifications(&state, &result).await;
}
Ok(Json(result)) Ok(Json(result))
} }
async fn emit_match_notifications(state: &AppState, result: &MatchCompletionResult) {
for ev in &result.level_ups {
let body = match &ev.pack_granted {
Some(pack) => format!(
"You reached level {}! Reward: {} coins + {pack}.",
ev.new_level, ev.coins_granted
),
None => format!(
"You reached level {}! Reward: {} coins.",
ev.new_level, ev.coins_granted
),
};
let _ = notification::create(
&state.pool,
"level_up",
&format!("Level {}!", ev.new_level),
&body,
)
.await;
}
for obj_id in &result.objectives_updated {
let display_name = state
.obj_defs
.iter()
.find(|d| &d.id == obj_id)
.map(|d| d.title.as_str())
.unwrap_or(obj_id.as_str());
let body = format!("\"{display_name}\" is now complete. Claim your reward in Objectives.");
let _ = notification::create(
&state.pool,
"objective_complete",
"Objective complete!",
&body,
)
.await;
}
for owned_id in &result.expired_loans {
let body =
format!("Loan card (id: {owned_id}) has expired and been removed from your club.");
let _ = notification::create(&state.pool, "loan_expired", "Loan card expired", &body).await;
}
if let Some(se) = &result.season_end {
use crate::models::season::SeasonResult;
let direction = match se.result {
SeasonResult::Promoted => "Promoted",
SeasonResult::Relegated => "Relegated",
SeasonResult::Maintained => "Maintained",
};
let body = format!(
"{direction} — now in Division {}. Rewards: {} coins{}.",
se.new_division,
se.coins_awarded,
se.pack_awarded
.as_deref()
.map(|p| format!(" + {p}"))
.unwrap_or_default()
);
let _ = notification::create(&state.pool, "season_end", "Season complete!", &body).await;
}
}
+3 -1
View File
@@ -2,10 +2,12 @@ pub mod achievements;
pub mod auth; pub mod auth;
pub mod cards; pub mod cards;
pub mod club; pub mod club;
pub mod consumables;
pub mod division; pub mod division;
pub mod draft; pub mod draft;
pub mod fut_champs; pub mod economy;
pub mod events; pub mod events;
pub mod fut_champs;
pub mod health; pub mod health;
pub mod market; pub mod market;
pub mod matches; pub mod matches;
+14 -8
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
Json, Json,
@@ -7,7 +8,9 @@ use serde_json::{json, Value};
use crate::{ use crate::{
app::AppState, app::AppState,
error::{AppError, AppResult}, error::{AppError, AppResult},
services::{club as club_svc, notification as notif_svc, objective as obj_svc, profile as profile_svc}, services::{
club as club_svc, notification as notif_svc, objective as obj_svc, profile as profile_svc,
},
}; };
/// GET /notifications /// GET /notifications
@@ -16,8 +19,11 @@ use crate::{
/// notifications (unclaimed objectives, expiring loans, season ending soon). /// notifications (unclaimed objectives, expiring loans, season ending soon).
/// Persistent notifications carry an `id` and `is_read` flag; dynamic ones /// Persistent notifications carry an `id` and `is_read` flag; dynamic ones
/// have `id: null` and are always considered unread. /// have `id: null` and are always considered unread.
pub async fn get_notifications(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_notifications(
let profile = profile_svc::get_active_profile(&state.pool).await?; State(state): State<AppState>,
game: GameId,
) -> 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
// ── Persistent notifications ───────────────────────────────────────────── // ── Persistent notifications ─────────────────────────────────────────────
@@ -92,14 +98,16 @@ pub async fn get_notifications(State(state): State<AppState>) -> AppResult<Json<
// Use "type" key for compatibility with dashboard and existing tests. // Use "type" key for compatibility with dashboard and existing tests.
let all: Vec<Value> = persistent let all: Vec<Value> = persistent
.iter() .iter()
.map(|n| json!({ .map(|n| {
json!({
"id": n.id, "id": n.id,
"type": n.kind, "type": n.kind,
"title": n.title, "title": n.title,
"body": n.body, "body": n.body,
"is_read": n.is_read, "is_read": n.is_read,
"created_at": n.created_at, "created_at": n.created_at,
})) })
})
.chain(dynamic.iter().cloned()) .chain(dynamic.iter().cloned())
.collect(); .collect();
@@ -124,9 +132,7 @@ pub async fn mark_notification_read(
} }
/// POST /notifications/read-all /// POST /notifications/read-all
pub async fn mark_all_notifications_read( pub async fn mark_all_notifications_read(State(state): State<AppState>) -> AppResult<Json<Value>> {
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
let count = notif_svc::mark_all_read(&state.pool).await?; let count = notif_svc::mark_all_read(&state.pool).await?;
Ok(Json(json!({ "marked_read": count }))) Ok(Json(json!({ "marked_read": count })))
} }
+9 -5
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
Json, Json,
@@ -11,8 +12,8 @@ use crate::{
services::{club as club_svc, objective as obj_svc, profile as profile_svc}, services::{club as club_svc, objective as obj_svc, profile as profile_svc},
}; };
pub async fn get_objectives(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_objectives(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let objectives = let objectives =
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?; obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
Ok(Json(json!({ "objectives": objectives }))) Ok(Json(json!({ "objectives": objectives })))
@@ -20,9 +21,10 @@ pub async fn get_objectives(State(state): State<AppState>) -> AppResult<Json<Val
pub async fn get_objective( pub async fn get_objective(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(objective_id): Path<String>, Path(objective_id): Path<String>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let all = let all =
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?; obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
let obj = all let obj = all
@@ -34,9 +36,10 @@ pub async fn get_objective(
pub async fn post_claim_objective_by_id( pub async fn post_claim_objective_by_id(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(objective_id): Path<String>, Path(objective_id): Path<String>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let reward = obj_svc::claim_objective( let reward = obj_svc::claim_objective(
&state.pool, &state.pool,
@@ -56,9 +59,10 @@ pub struct ClaimRequest {
pub async fn post_claim_objective( pub async fn post_claim_objective(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Json(req): Json<ClaimRequest>, Json(req): Json<ClaimRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let reward = obj_svc::claim_objective( let reward = obj_svc::claim_objective(
&state.pool, &state.pool,
+18 -8
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
Json, Json,
@@ -19,9 +20,10 @@ pub struct BuyPackRequest {
pub async fn post_buy_pack( pub async fn post_buy_pack(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Json(req): Json<BuyPackRequest>, Json(req): Json<BuyPackRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let pack = pack_svc::buy_pack( let pack = pack_svc::buy_pack(
&state.pool, &state.pool,
@@ -54,8 +56,8 @@ pub async fn get_pack_store(State(state): State<AppState>) -> AppResult<Json<Val
Ok(Json(json!({ "packs": store }))) Ok(Json(json!({ "packs": store })))
} }
pub async fn get_packs(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_packs(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let packs = pack_svc::get_unopened_packs(&state.pool, &club.id).await?; let packs = pack_svc::get_unopened_packs(&state.pool, &club.id).await?;
@@ -77,8 +79,11 @@ pub async fn get_packs(State(state): State<AppState>) -> AppResult<Json<Value>>
} }
/// Return recently opened packs with the card IDs they contained. /// Return recently opened packs with the card IDs they contained.
pub async fn get_pack_history(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_pack_history(
let profile = profile_svc::get_active_profile(&state.pool).await?; State(state): State<AppState>,
game: GameId,
) -> 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let opened = sqlx::query_as::<_, crate::models::pack::Pack>( let opened = sqlx::query_as::<_, crate::models::pack::Pack>(
@@ -122,9 +127,10 @@ pub async fn get_pack_history(State(state): State<AppState>) -> AppResult<Json<V
pub async fn post_open_pack( pub async fn post_open_pack(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(pack_id): Path<String>, Path(pack_id): Path<String>,
) -> AppResult<Json<PackOpenResult>> { ) -> AppResult<Json<PackOpenResult>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let result = pack_svc::open_pack( let result = pack_svc::open_pack(
@@ -140,8 +146,12 @@ pub async fn post_open_pack(
objective::increment_metric(&state.pool, &profile.id, &state.obj_defs, "packsopened", 1) objective::increment_metric(&state.pool, &profile.id, &state.obj_defs, "packsopened", 1)
.await?; .await?;
let _ = crate::services::achievement::check_and_unlock( let _ = crate::services::achievement::check_and_unlock(
&state.pool, &state.achievement_defs, &profile.id, &club.id, &state.pool,
).await; &state.achievement_defs,
&profile.id,
&club.id,
)
.await;
Ok(Json(result)) Ok(Json(result))
} }
+3 -2
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use crate::{ use crate::{
app::AppState, app::AppState,
error::AppResult, error::AppResult,
@@ -7,8 +8,8 @@ use crate::{
use axum::{extract::State, Json}; use axum::{extract::State, Json};
use serde_json::{json, Value}; use serde_json::{json, Value};
pub async fn get_profile(State(state): State<AppState>) -> AppResult<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).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let computed_level = level_for_xp(profile.xp); let computed_level = level_for_xp(profile.xp);
let next_level = computed_level + 1; let next_level = computed_level + 1;
+55 -13
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
Json, Json,
@@ -7,7 +8,7 @@ use serde_json::{json, Value};
use crate::{ use crate::{
app::AppState, app::AppState,
error::{AppError, AppResult}, error::{AppError, AppResult},
models::sbc::{SbcResult, SubmitSbcRequest}, models::sbc::{SaveSbcSquadRequest, SbcResult, SubmitSbcRequest},
services::{club as club_svc, profile as profile_svc, sbc as sbc_svc}, services::{club as club_svc, profile as profile_svc, sbc as sbc_svc},
}; };
@@ -27,29 +28,70 @@ pub async fn get_sbc(
Ok(Json(json!({ "sbc": sbc }))) Ok(Json(json!({ "sbc": sbc })))
} }
pub async fn get_sbc_status(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let completions = sbc_svc::completion_counts(&state.pool, &profile.id).await?;
Ok(Json(json!({ "completions": completions })))
}
pub async fn get_sbc_squad(
State(state): State<AppState>,
game: GameId,
Path(sbc_id): Path<String>,
) -> AppResult<Json<Value>> {
if !state
.sbc_defs
.iter()
.any(|definition| definition.id == sbc_id)
{
return Err(AppError::NotFound(format!("SBC '{sbc_id}' not found")));
}
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let squad = sbc_svc::load_sbc_squad(&state.pool, &profile.id, &sbc_id).await?;
Ok(Json(json!({ "squad": squad })))
}
pub async fn put_sbc_squad(
State(state): State<AppState>,
game: GameId,
Path(sbc_id): Path<String>,
Json(req): Json<SaveSbcSquadRequest>,
) -> 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 = sbc_svc::save_sbc_squad(
&state.pool,
&state.sbc_defs,
&profile.id,
&club.id,
&sbc_id,
&req.owned_card_ids,
)
.await?;
Ok(Json(json!({ "squad": squad })))
}
pub async fn post_sbc_submit( pub async fn post_sbc_submit(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Json(req): Json<SubmitSbcRequest>, Json(req): Json<SubmitSbcRequest>,
) -> AppResult<Json<SbcResult>> { ) -> AppResult<Json<SbcResult>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let result = sbc_svc::submit_sbc( let result = sbc_svc::submit_sbc(
&state.pool, &state.pool,
&state.card_db, sbc_svc::SbcSubmissionContext {
&state.sbc_defs, card_db: &state.card_db,
&state.obj_defs, sbc_defs: &state.sbc_defs,
&profile.id, objective_defs: &state.obj_defs,
&club.id, achievement_defs: &state.achievement_defs,
profile_id: &profile.id,
club_id: &club.id,
},
&req, &req,
) )
.await?; .await?;
if result.passed {
let _ = crate::services::achievement::check_and_unlock(
&state.pool, &state.achievement_defs, &profile.id, &club.id,
).await;
}
Ok(Json(result)) Ok(Json(result))
} }
+154 -13
View File
@@ -1,18 +1,25 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, Query, State},
Json, Json,
}; };
use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::{ use crate::{
app::AppState, app::AppState,
error::AppResult, error::{AppError, AppResult},
models::squad::SaveSquadRequest, models::game_ext::OpaqueExtensionWrite,
services::{club as club_svc, profile as profile_svc, squad as squad_svc}, models::squad::{SaveSquadRequest, SlotAssignment, SquadReplacement},
services::{
club as club_svc, profile as profile_svc, squad as squad_svc,
squad::SquadExtState,
squad_rules::{ClientReportedEvaluation, DefaultSquadRules},
},
}; };
pub async fn get_squad(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_squad(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let (squad, players) = squad_svc::get_squad(&state.pool, &club.id).await?; let (squad, players) = squad_svc::get_squad(&state.pool, &club.id).await?;
@@ -21,8 +28,8 @@ pub async fn get_squad(State(state): State<AppState>) -> AppResult<Json<Value>>
Ok(Json(squad_response(&squad, &players, chemistry))) Ok(Json(squad_response(&squad, &players, chemistry)))
} }
pub async fn get_squads(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_squads(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let squads = squad_svc::list_squads(&state.pool, &club.id).await?; let squads = squad_svc::list_squads(&state.pool, &club.id).await?;
Ok(Json(json!({ "squads": squads }))) Ok(Json(json!({ "squads": squads })))
@@ -30,9 +37,10 @@ pub async fn get_squads(State(state): State<AppState>) -> AppResult<Json<Value>>
pub async fn get_squad_by_id( pub async fn get_squad_by_id(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(squad_id): Path<String>, Path(squad_id): Path<String>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 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?; let (squad, players) = squad_svc::get_squad_by_id(&state.pool, &club.id, &squad_id).await?;
@@ -43,24 +51,26 @@ pub async fn get_squad_by_id(
pub async fn post_squad( pub async fn post_squad(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Json(req): Json<SaveSquadRequest>, Json(req): Json<SaveSquadRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
if !req.players.is_empty() { if !req.players.is_empty() {
squad_svc::validate_formation(&state.pool, &state.card_db, &req.players).await?; squad_svc::validate_formation(&state.pool, &state.card_db, &club.id, &req.players).await?;
} }
let squad = squad_svc::save_squad(&state.pool, &club.id, &req).await?; let squad = squad_svc::save_squad(&state.pool, &state.card_db, &club.id, &req).await?;
Ok(Json(json!({ "squad": squad }))) Ok(Json(json!({ "squad": squad })))
} }
pub async fn delete_squad( pub async fn delete_squad(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(squad_id): Path<String>, Path(squad_id): Path<String>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
squad_svc::delete_squad(&state.pool, &club.id, &squad_id).await?; squad_svc::delete_squad(&state.pool, &club.id, &squad_id).await?;
Ok(Json(json!({ "deleted": squad_id }))) Ok(Json(json!({ "deleted": squad_id })))
@@ -94,3 +104,134 @@ fn squad_response(
"chemistry": chemistry, "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,
})))
}
+5 -3
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Query, State}, extract::{Query, State},
Json, Json,
@@ -12,8 +13,8 @@ use crate::{
services::{profile as profile_svc, statistics as stats_svc}, services::{profile as profile_svc, statistics as stats_svc},
}; };
pub async fn get_statistics(State(state): State<AppState>) -> AppResult<Json<Value>> { pub async fn get_statistics(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let stats = stats_svc::get_or_create(&state.pool, &profile.id).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?; let pos_goals = stats_svc::get_position_goals(&state.pool, &profile.id).await?;
@@ -36,9 +37,10 @@ pub struct HistoryQuery {
pub async fn get_statistics_history( pub async fn get_statistics_history(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Query(query): Query<HistoryQuery>, Query(query): Query<HistoryQuery>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let limit = query.limit.unwrap_or(20).clamp(1, 100); let limit = query.limit.unwrap_or(20).clamp(1, 100);
let matches = sqlx::query_as::<_, Match>( let matches = sqlx::query_as::<_, Match>(
+9 -10
View File
@@ -1,3 +1,4 @@
use crate::extractors::GameId;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
Json, Json,
@@ -27,10 +28,11 @@ pub struct ApplyChemStyleRequest {
/// POST /collection/:owned_card_id/chemistry-style /// POST /collection/:owned_card_id/chemistry-style
pub async fn post_apply_chemistry_style( pub async fn post_apply_chemistry_style(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(owned_card_id): Path<String>, Path(owned_card_id): Path<String>,
Json(req): Json<ApplyChemStyleRequest>, Json(req): Json<ApplyChemStyleRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let updated = upgrade_svc::apply_chemistry_style( let updated = upgrade_svc::apply_chemistry_style(
@@ -63,19 +65,15 @@ pub struct ChangePositionRequest {
/// POST /collection/:owned_card_id/position — costs 500 coins. /// POST /collection/:owned_card_id/position — costs 500 coins.
pub async fn post_change_position( pub async fn post_change_position(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(owned_card_id): Path<String>, Path(owned_card_id): Path<String>,
Json(req): Json<ChangePositionRequest>, Json(req): Json<ChangePositionRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let updated = upgrade_svc::change_position( let updated =
&state.pool, upgrade_svc::change_position(&state.pool, &club.id, &owned_card_id, &req.position).await?;
&club.id,
&owned_card_id,
&req.position,
)
.await?;
let card_def = state.card_db.get(&updated.card_id); let card_def = state.card_db.get(&updated.card_id);
@@ -97,10 +95,11 @@ pub struct ApplyTrainingRequest {
/// POST /collection/:owned_card_id/training — applies a training boost (up to +3 OVR total). /// POST /collection/:owned_card_id/training — applies a training boost (up to +3 OVR total).
pub async fn post_apply_training( pub async fn post_apply_training(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId,
Path(owned_card_id): Path<String>, Path(owned_card_id): Path<String>,
Json(req): Json<ApplyTrainingRequest>, Json(req): Json<ApplyTrainingRequest>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool).await?; 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let updated = let updated =
+186 -1
View File
@@ -1,6 +1,23 @@
use crate::{db::Pool, error::AppResult, models::pack::PackDefinition, services::pack as pack_svc}; use crate::{
db::Pool,
error::AppResult,
models::{card::Quality, club::Club, pack::PackDefinition},
services::{card_db::CardDb, club as club_svc, pack as pack_svc, profile as profile_svc},
};
use serde::Serialize;
use std::collections::BTreeMap;
use tracing::info; 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. /// Seeds the market with NPC listings if empty.
pub async fn maybe_seed(_pool: &Pool) -> AppResult<()> { pub async fn maybe_seed(_pool: &Pool) -> AppResult<()> {
// Any one-time startup seeds go here. // Any one-time startup seeds go here.
@@ -29,3 +46,171 @@ pub async fn grant_starter_pack(
Ok(()) 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,
})
}
+210 -35
View File
@@ -1,10 +1,12 @@
use crate::{ use crate::{
db::Pool, db::Pool,
error::AppResult, error::{AppError, AppResult},
models::achievement::{AchievementDefinition, PlayerAchievement}, models::achievement::{AchievementDefinition, PlayerAchievement},
services::{club as club_svc, notification}, services::{club as club_svc, notification},
}; };
use anyhow::Context; use anyhow::Context;
use sqlx::{Sqlite, Transaction};
use std::collections::{HashMap, HashSet};
use std::path::Path; use std::path::Path;
use uuid::Uuid; use uuid::Uuid;
@@ -29,58 +31,62 @@ pub fn load_achievement_definitions(data_dir: &str) -> anyhow::Result<Vec<Achiev
} }
/// Query the current value for the given trigger metric. /// Query the current value for the given trigger metric.
async fn metric_value(pool: &Pool, profile_id: &str, club_id: &str, trigger: &str) -> AppResult<i64> { async fn metric_value(
let v: i64 = match trigger { pool: &Pool,
"matches_played" => sqlx::query_scalar( profile_id: &str,
"SELECT matches_played FROM statistics WHERE profile_id = ?", club_id: &str,
) trigger: &str,
) -> AppResult<i64> {
let v: i64 =
match trigger {
"matches_played" => {
sqlx::query_scalar("SELECT matches_played FROM statistics WHERE profile_id = ?")
.bind(profile_id) .bind(profile_id)
.fetch_optional(pool) .fetch_optional(pool)
.await? .await?
.unwrap_or(0), .unwrap_or(0)
}
"matches_won" => sqlx::query_scalar( "matches_won" => {
"SELECT matches_won FROM statistics WHERE profile_id = ?", sqlx::query_scalar("SELECT matches_won FROM statistics WHERE profile_id = ?")
)
.bind(profile_id) .bind(profile_id)
.fetch_optional(pool) .fetch_optional(pool)
.await? .await?
.unwrap_or(0), .unwrap_or(0)
}
"goals_scored" => sqlx::query_scalar( "goals_scored" => {
"SELECT goals_scored FROM statistics WHERE profile_id = ?", sqlx::query_scalar("SELECT goals_scored FROM statistics WHERE profile_id = ?")
)
.bind(profile_id) .bind(profile_id)
.fetch_optional(pool) .fetch_optional(pool)
.await? .await?
.unwrap_or(0), .unwrap_or(0)
}
"packs_opened" => sqlx::query_scalar( "packs_opened" => {
"SELECT packs_opened FROM statistics WHERE profile_id = ?", sqlx::query_scalar("SELECT packs_opened FROM statistics WHERE profile_id = ?")
)
.bind(profile_id) .bind(profile_id)
.fetch_optional(pool) .fetch_optional(pool)
.await? .await?
.unwrap_or(0), .unwrap_or(0)
}
"sbcs_completed" => sqlx::query_scalar( "sbcs_completed" => {
"SELECT sbcs_completed FROM statistics WHERE profile_id = ?", sqlx::query_scalar("SELECT sbcs_completed FROM statistics WHERE profile_id = ?")
)
.bind(profile_id) .bind(profile_id)
.fetch_optional(pool) .fetch_optional(pool)
.await? .await?
.unwrap_or(0), .unwrap_or(0)
}
"cards_owned" => sqlx::query_scalar( "cards_owned" => {
"SELECT COUNT(*) FROM owned_cards WHERE club_id = ?", sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE club_id = ?")
)
.bind(club_id) .bind(club_id)
.fetch_one(pool) .fetch_one(pool)
.await?, .await?
}
"level" => sqlx::query_scalar( "level" => sqlx::query_scalar("SELECT level FROM profiles WHERE id = ?")
"SELECT level FROM profiles WHERE id = ?",
)
.bind(profile_id) .bind(profile_id)
.fetch_optional(pool) .fetch_optional(pool)
.await? .await?
@@ -165,11 +171,180 @@ pub async fn check_and_unlock(
club_svc::add_coins(pool, club_id, def.reward_coins).await?; club_svc::add_coins(pool, club_id, def.reward_coins).await?;
} }
let body = format!( let body = format!("{} Reward: {} coins.", def.description, def.reward_coins);
"{} Reward: {} coins.", let _ = notification::create(
def.description, def.reward_coins pool,
); "achievement",
let _ = notification::create(pool, "achievement", &format!("Achievement: {}", def.title), &body).await; &format!("Achievement: {}", def.title),
&body,
)
.await;
newly_unlocked.push(def.clone());
}
}
Ok(newly_unlocked)
}
/// Transaction-scoped [`metric_value`] — identical reads, run inside the
/// caller's transaction so achievement checks see the same uncommitted state the
/// rest of the match-completion transaction just wrote.
async fn metric_value_tx(
tx: &mut Transaction<'_, Sqlite>,
profile_id: &str,
club_id: &str,
trigger: &str,
) -> AppResult<i64> {
let v: i64 =
match trigger {
"matches_played" => {
sqlx::query_scalar("SELECT matches_played FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(&mut **tx)
.await?
.unwrap_or(0)
}
"matches_won" => {
sqlx::query_scalar("SELECT matches_won FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(&mut **tx)
.await?
.unwrap_or(0)
}
"goals_scored" => {
sqlx::query_scalar("SELECT goals_scored FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(&mut **tx)
.await?
.unwrap_or(0)
}
"packs_opened" => {
sqlx::query_scalar("SELECT packs_opened FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(&mut **tx)
.await?
.unwrap_or(0)
}
"sbcs_completed" => {
sqlx::query_scalar("SELECT sbcs_completed FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(&mut **tx)
.await?
.unwrap_or(0)
}
"cards_owned" => {
sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE club_id = ?")
.bind(club_id)
.fetch_one(&mut **tx)
.await?
}
"level" => sqlx::query_scalar("SELECT level FROM profiles WHERE id = ?")
.bind(profile_id)
.fetch_optional(&mut **tx)
.await?
.unwrap_or(1),
"objectives_completed" => sqlx::query_scalar(
"SELECT COUNT(*) FROM objective_progress WHERE profile_id = ? AND completed = 1",
)
.bind(profile_id)
.fetch_one(&mut **tx)
.await?,
"drafts_completed" => sqlx::query_scalar(
"SELECT COUNT(*) FROM draft_sessions WHERE profile_id = ? AND status = 'completed'",
)
.bind(profile_id)
.fetch_one(&mut **tx)
.await?,
_ => 0,
};
Ok(v)
}
/// Transaction-scoped [`check_and_unlock`] for the atomic match-completion path.
/// Unlocks are inserted, coins credited, and notifications written inside the
/// caller's transaction (mirroring the inline economy writes elsewhere), so a
/// later failure rolls back the whole match — no half-granted achievement.
pub async fn check_and_unlock_tx(
tx: &mut Transaction<'_, Sqlite>,
defs: &[AchievementDefinition],
profile_id: &str,
club_id: &str,
now: &str,
) -> AppResult<Vec<AchievementDefinition>> {
if defs.is_empty() {
return Ok(vec![]);
}
let unlocked_ids: Vec<String> =
sqlx::query_scalar("SELECT achievement_id FROM player_achievements")
.fetch_all(&mut **tx)
.await?;
let unlocked_set: HashSet<&str> = unlocked_ids.iter().map(|s| s.as_str()).collect();
let candidates: Vec<&AchievementDefinition> = defs
.iter()
.filter(|d| !unlocked_set.contains(d.id.as_str()))
.collect();
if candidates.is_empty() {
return Ok(vec![]);
}
let mut trigger_cache: HashMap<String, i64> = Default::default();
let mut newly_unlocked: Vec<AchievementDefinition> = Vec::new();
for def in candidates {
let value = match trigger_cache.get(&def.trigger) {
Some(&v) => v,
None => {
let v = metric_value_tx(tx, profile_id, club_id, &def.trigger).await?;
trigger_cache.insert(def.trigger.clone(), v);
v
}
};
if value >= def.threshold {
if def.reward_coins < 0 {
return Err(AppError::Internal(anyhow::anyhow!(
"achievement {} has a negative reward",
def.id
)));
}
let inserted = sqlx::query(
"INSERT OR IGNORE INTO player_achievements (id, achievement_id, unlocked_at) VALUES (?, ?, ?)",
)
.bind(Uuid::new_v4().to_string())
.bind(&def.id)
.bind(now)
.execute(&mut **tx)
.await?;
if inserted.rows_affected() == 0 {
continue;
}
if def.reward_coins > 0 {
let credited =
sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?")
.bind(def.reward_coins)
.bind(now)
.bind(club_id)
.execute(&mut **tx)
.await?;
if credited.rows_affected() != 1 {
return Err(AppError::NotFound("club not found".into()));
}
}
let body = format!("{} Reward: {} coins.", def.description, def.reward_coins);
sqlx::query(
"INSERT INTO notifications (id, kind, title, body, is_read, created_at) VALUES (?, 'achievement', ?, ?, 0, ?)",
)
.bind(Uuid::new_v4().to_string())
.bind(format!("Achievement: {}", def.title))
.bind(body)
.bind(now)
.execute(&mut **tx)
.await?;
newly_unlocked.push(def.clone()); newly_unlocked.push(def.clone());
} }
+41 -11
View File
@@ -38,19 +38,49 @@ impl CardDb {
Ok(Self { cards }) Ok(Self { cards })
} }
pub fn get(&self, id: &str) -> Option<&CardDefinition> { /// Merge a game's **opt-in development content pack** from
self.cards.get(id) /// `{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)
} }
#[allow(dead_code)] /// Merge an explicit PRODUCTION content pack file (a single
pub fn by_rarity(&self, rarity: &str) -> Vec<&CardDefinition> { /// `CardDefinition[]`). Unlike [`CardDb::load_game_dev`] this takes a direct
self.cards /// path (the real-profile import emits one) and is the production content
.values() /// path — not gated behind dev content. Returns the number merged.
.filter(|c| { pub fn load_pack(&mut self, path: &Path) -> Result<usize> {
let r = format!("{:?}", c.rarity).to_lowercase(); let content = std::fs::read_to_string(path)
r == rarity || rarity == "any" .with_context(|| format!("reading content pack {path:?}"))?;
}) let batch: Vec<CardDefinition> =
.collect() 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)
} }
pub fn all(&self) -> Vec<&CardDefinition> { pub fn all(&self) -> Vec<&CardDefinition> {
+36 -17
View File
@@ -1,4 +1,8 @@
use crate::{db::Pool, error::AppResult, services::{club, pack}}; use crate::{
db::Pool,
error::AppResult,
services::{club, pack},
};
use uuid::Uuid; use uuid::Uuid;
const STREAK_COINS: [i64; 7] = [500, 650, 800, 1000, 1200, 1500, 2000]; const STREAK_COINS: [i64; 7] = [500, 650, 800, 1000, 1200, 1500, 2000];
@@ -44,7 +48,7 @@ pub async fn get_status(pool: &Pool, profile_id: &str) -> AppResult<CheckinStatu
let last_day = &last_at[..10]; // YYYY-MM-DD let last_day = &last_at[..10]; // YYYY-MM-DD
let available = last_day != today.as_str(); let available = last_day != today.as_str();
let next_streak = compute_next_streak(last_streak, &last_at); let next_streak = compute_next_streak(last_streak, &last_at);
let idx = ((next_streak - 1) % 7) as usize; let idx = (next_streak - 1).rem_euclid(7) as usize;
Ok(CheckinStatus { Ok(CheckinStatus {
available, available,
streak_day: if available { next_streak } else { last_streak }, streak_day: if available { next_streak } else { last_streak },
@@ -56,11 +60,7 @@ pub async fn get_status(pool: &Pool, profile_id: &str) -> AppResult<CheckinStatu
} }
} }
pub async fn claim( pub async fn claim(pool: &Pool, profile_id: &str, club_id: &str) -> AppResult<CheckinResult> {
pool: &Pool,
profile_id: &str,
club_id: &str,
) -> AppResult<CheckinResult> {
let row: Option<(i64, String)> = sqlx::query_as( let row: Option<(i64, String)> = sqlx::query_as(
"SELECT streak_day, checked_in_at FROM daily_checkins \ "SELECT streak_day, checked_in_at FROM daily_checkins \
WHERE profile_id = ? ORDER BY checked_in_at DESC LIMIT 1", WHERE profile_id = ? ORDER BY checked_in_at DESC LIMIT 1",
@@ -82,20 +82,21 @@ pub async fn claim(
} }
} }
let last_streak = row.as_ref().map(|(s, last_at)| compute_next_streak(*s, last_at)).unwrap_or(1); let last_streak = row
let idx = ((last_streak - 1) % 7) as usize; .as_ref()
.map(|(s, last_at)| compute_next_streak(*s, last_at))
.unwrap_or(1);
let idx = (last_streak - 1).rem_euclid(7) as usize;
let coins = STREAK_COINS[idx]; let coins = STREAK_COINS[idx];
let pack_def = if idx == 6 { Some(STREAK_7_PACK) } else { None }; let pack_def = if idx == 6 { Some(STREAK_7_PACK) } else { None };
club::add_coins(pool, club_id, coins).await?; // Atomically claim today's check-in: the INSERT lands only if no row exists for
if let Some(def) = pack_def { // today, so two concurrent claims cannot both pay out (was a check-then-act race).
let _ = pack::grant_pack(pool, club_id, def).await;
}
let now = chrono::Utc::now().to_rfc3339(); let now = chrono::Utc::now().to_rfc3339();
sqlx::query( let inserted = sqlx::query(
"INSERT INTO daily_checkins (id, profile_id, club_id, streak_day, coins_awarded, pack_granted, checked_in_at) \ "INSERT INTO daily_checkins (id, profile_id, club_id, streak_day, coins_awarded, pack_granted, checked_in_at) \
VALUES (?, ?, ?, ?, ?, ?, ?)", SELECT ?, ?, ?, ?, ?, ?, ? \
WHERE NOT EXISTS (SELECT 1 FROM daily_checkins WHERE profile_id = ? AND substr(checked_in_at, 1, 10) = ?)",
) )
.bind(Uuid::new_v4().to_string()) .bind(Uuid::new_v4().to_string())
.bind(profile_id) .bind(profile_id)
@@ -104,8 +105,26 @@ pub async fn claim(
.bind(coins) .bind(coins)
.bind(pack_def) .bind(pack_def)
.bind(&now) .bind(&now)
.bind(profile_id)
.bind(&today)
.execute(pool) .execute(pool)
.await?; .await?
.rows_affected();
if inserted == 0 {
// A concurrent claim already recorded today's check-in — do not pay out again.
return Ok(CheckinResult {
coins_awarded: 0,
pack_awarded: None,
new_streak: last_streak,
already_claimed: true,
});
}
club::add_coins(pool, club_id, coins).await?;
if let Some(def) = pack_def {
let _ = pack::grant_pack(pool, club_id, def).await;
}
Ok(CheckinResult { Ok(CheckinResult {
coins_awarded: coins, coins_awarded: coins,
+621 -11
View File
@@ -1,7 +1,10 @@
use crate::{ use crate::{
db::Pool, db::Pool,
error::{AppError, AppResult}, error::{AppError, AppResult},
models::club::Club, models::{
card::{ActiveSlot, ContentKind, OwnedCard, OWNED_CARD_SELECT},
club::Club,
},
}; };
use chrono::Utc; use chrono::Utc;
@@ -86,24 +89,631 @@ pub async fn add_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64
} }
pub async fn spend_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64> { pub async fn spend_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64> {
if amount < 0 {
return Err(AppError::BadRequest(format!(
"cannot spend a negative amount: {amount}"
)));
}
let now = Utc::now();
// Atomic compare-and-swap: the `coins >= ?` guard makes the debit conditional in a
// single statement, so two concurrent spends can never both pass a stale balance
// check and drive coins negative (the old SELECT-then-UPDATE was a TOCTOU race).
let affected = sqlx::query(
"UPDATE clubs SET coins = coins - ?, updated_at = ? WHERE id = ? AND coins >= ?",
)
.bind(amount)
.bind(now)
.bind(club_id)
.bind(amount)
.execute(pool)
.await?
.rows_affected();
if affected == 0 {
// No row updated: the club is missing, or it could not afford the debit.
// Disambiguate so callers keep the NotFound vs BadRequest distinction.
let balance = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?") let balance = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
.bind(club_id) .bind(club_id)
.fetch_one(pool) .fetch_optional(pool)
.await?; .await?
.ok_or_else(|| AppError::NotFound(format!("club not found: {club_id}")))?;
if balance < amount {
return Err(AppError::BadRequest(format!( return Err(AppError::BadRequest(format!(
"insufficient coins: have {balance}, need {amount}" "insufficient coins: have {balance}, need {amount}"
))); )));
} }
let now = Utc::now(); let new_balance = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
sqlx::query("UPDATE clubs SET coins = coins - ?, updated_at = ? WHERE id = ?") .bind(club_id)
.bind(amount) .fetch_one(pool)
.bind(now) .await?;
Ok(new_balance)
}
// ─────────────────────── squad manager assignment ───────────────────────────
//
// Generic, ownership-backed canonical state: one owned item assigned as a
// squad's manager (migration 0023 `squad_managers`). Core stores the assignment
// durably and re-validates ownership on read; the FIFA 17 adapter owns the wire
// meaning of "manager" (itemType/contract/chemistry), never Core.
/// The club's most-recently-updated squad id (its "active" squad), matching the
/// selection `squad::get_squad` uses, or `None` when the club has no squad yet.
pub async fn active_squad_id(pool: &Pool, club_id: &str) -> AppResult<Option<String>> {
Ok(sqlx::query_scalar::<_, String>(
"SELECT id FROM squads WHERE club_id = ? ORDER BY updated_at DESC LIMIT 1",
)
.bind(club_id)
.fetch_optional(pool)
.await?)
}
/// The owned card assigned as the manager of `club_id`'s active squad, if any.
pub async fn get_squad_manager(pool: &Pool, club_id: &str) -> AppResult<Option<OwnedCard>> {
let Some(squad_id) = active_squad_id(pool, club_id).await? else {
return Ok(None);
};
get_squad_manager_for_squad(pool, &squad_id, club_id).await
}
/// The owned card assigned as `squad_id`'s manager, re-validated to still belong
/// to `club_id`. The club-ownership re-check means a stale assignment left by a
/// market transfer (which moves ownership by UPDATE, bypassing ON DELETE
/// CASCADE) never surfaces a manager the club no longer owns.
pub async fn get_squad_manager_for_squad(
pool: &Pool,
squad_id: &str,
club_id: &str,
) -> AppResult<Option<OwnedCard>> {
Ok(sqlx::query_as::<_, OwnedCard>(&format!(
"{OWNED_CARD_SELECT} \
WHERE id = (SELECT owned_card_id FROM squad_managers WHERE squad_id = ?) \
AND club_id = ?"
))
.bind(squad_id)
.bind(club_id)
.fetch_optional(pool)
.await?)
}
/// Assign `owned_card_id` as the manager of `club_id`'s active squad, replacing
/// any existing assignment. Fail-closed: both the squad and the owned card MUST
/// belong to `club_id`, so a client can neither manage another club's squad nor
/// assign a card it does not own. One manager per squad (the PK REPLACE), so a
/// re-assignment never accumulates duplicate rows.
pub async fn set_squad_manager(pool: &Pool, club_id: &str, owned_card_id: &str) -> AppResult<()> {
let squad_id = active_squad_id(pool, club_id)
.await?
.ok_or_else(|| AppError::NotFound("club has no squad to assign a manager to".into()))?;
set_squad_manager_for_squad(pool, club_id, &squad_id, owned_card_id).await
}
/// Squad-scoped variant of [`set_squad_manager`].
pub async fn set_squad_manager_for_squad(
pool: &Pool,
club_id: &str,
squad_id: &str,
owned_card_id: &str,
) -> AppResult<()> {
let squad_ok =
sqlx::query_scalar::<_, String>("SELECT id FROM squads WHERE id = ? AND club_id = ?")
.bind(squad_id)
.bind(club_id)
.fetch_optional(pool)
.await?;
if squad_ok.is_none() {
return Err(AppError::NotFound(format!("squad '{squad_id}' not found")));
}
let card_ok =
sqlx::query_scalar::<_, String>("SELECT id FROM owned_cards WHERE id = ? AND club_id = ?")
.bind(owned_card_id)
.bind(club_id)
.fetch_optional(pool)
.await?;
if card_ok.is_none() {
return Err(AppError::NotFound(format!(
"owned card '{owned_card_id}' not found"
)));
}
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT OR REPLACE INTO squad_managers (squad_id, owned_card_id, updated_at) \
VALUES (?, ?, ?)",
)
.bind(squad_id)
.bind(owned_card_id)
.bind(&now)
.execute(pool)
.await?;
Ok(())
}
/// Remove the manager assignment from `club_id`'s active squad (idempotent — a
/// club with no squad or no manager is a successful no-op).
pub async fn clear_squad_manager(pool: &Pool, club_id: &str) -> AppResult<()> {
sqlx::query(
"DELETE FROM squad_managers WHERE squad_id IN \
(SELECT id FROM squads WHERE club_id = ?)",
)
.bind(club_id) .bind(club_id)
.execute(pool) .execute(pool)
.await?; .await?;
Ok(())
Ok(balance - amount) }
// ─────────────────────── active club item designations ──────────────────────
//
// Generic, ownership-backed club state (migration 0026 `club_active_items`):
// which owned INSTANCE currently occupies each club-scoped role (home/away kit,
// badge, ball, stadium). Ownership itself never lives here — a designation is a
// pointer into `owned_cards`, revalidated against current ownership on every
// read, so a stale row can never project an item the club does not own.
//
// Core enforces the generic invariants (ownership, one instance per slot, slot
// admits exactly one `ContentKind`); a game adapter maps its own taxonomy onto
// `ContentKind` before it gets here.
/// Every active club-item designation, keyed by slot.
///
/// Slots with no designation are simply absent. Held as a `Vec` rather than a
/// map so the projection order is the canonical [`ActiveSlot::ALL`] order.
#[derive(Debug, Clone, Default)]
pub struct ActiveClubItems {
pub items: Vec<(ActiveSlot, OwnedCard)>,
}
impl ActiveClubItems {
/// The owned instance occupying `slot`, if any.
pub fn get(&self, slot: ActiveSlot) -> Option<&OwnedCard> {
self.items
.iter()
.find(|(s, _)| *s == slot)
.map(|(_, card)| card)
}
}
/// Read one slot's designation, revalidated against current club ownership.
async fn get_active_club_item(
pool: &Pool,
club_id: &str,
slot: ActiveSlot,
) -> AppResult<Option<OwnedCard>> {
Ok(sqlx::query_as::<_, OwnedCard>(&format!(
"{OWNED_CARD_SELECT} WHERE id = ( \
SELECT owned_card_id FROM club_active_items WHERE club_id = ? AND slot = ? \
) AND club_id = ?"
))
.bind(club_id)
.bind(slot.as_str())
.bind(club_id)
.fetch_optional(pool)
.await?)
}
/// Read every active club-item designation. Each is revalidated against current
/// ownership, so a stale/corrupt row never surfaces another club's item.
pub async fn get_active_club_items(pool: &Pool, club_id: &str) -> AppResult<ActiveClubItems> {
let mut items = Vec::new();
for slot in ActiveSlot::ALL {
if let Some(card) = get_active_club_item(pool, club_id, slot).await? {
items.push((slot, card));
}
}
Ok(ActiveClubItems { items })
}
/// Designate `owned_card_id` as `club_id`'s active item for `slot`, replacing any
/// existing designation for that slot.
///
/// Fail-closed, in one transaction:
/// * the instance MUST be owned by `club_id` (so a client cannot install
/// another club's item, nor an id that does not exist);
/// * its `content_kind` MUST be the kind the slot admits (a badge in
/// `home_kit` is rejected, not silently accepted);
/// * an instance already designated for a DIFFERENT slot is released first, so
/// the `owned_card_id UNIQUE` invariant is upheld by an explicit move rather
/// than a constraint error.
pub async fn set_active_club_item(
pool: &Pool,
club_id: &str,
slot: ActiveSlot,
owned_card_id: &str,
) -> AppResult<()> {
let mut tx = pool.begin().await?;
let owned = sqlx::query_as::<_, (String, ContentKind)>(
"SELECT id, content_kind FROM owned_cards WHERE id = ? AND club_id = ?",
)
.bind(owned_card_id)
.bind(club_id)
.fetch_optional(&mut *tx)
.await?;
let Some((_, kind)) = owned else {
return Err(AppError::NotFound(format!(
"owned card '{owned_card_id}' not found"
)));
};
let required = slot.required_kind();
if kind != required {
return Err(AppError::BadRequest(format!(
"slot '{slot}' requires content kind '{required}', but owned card \
'{owned_card_id}' is '{kind}'"
)));
}
// Release this instance from any other slot, then take the target slot.
sqlx::query("DELETE FROM club_active_items WHERE owned_card_id = ?")
.bind(owned_card_id)
.execute(&mut *tx)
.await?;
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT OR REPLACE INTO club_active_items \
(club_id, slot, owned_card_id, updated_at) VALUES (?, ?, ?, ?)",
)
.bind(club_id)
.bind(slot.as_str())
.bind(owned_card_id)
.bind(&now)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
/// Clear `club_id`'s designation for `slot` (idempotent — an already-empty slot
/// is a successful no-op).
pub async fn clear_active_club_item(pool: &Pool, club_id: &str, slot: ActiveSlot) -> AppResult<()> {
sqlx::query("DELETE FROM club_active_items WHERE club_id = ? AND slot = ?")
.bind(club_id)
.bind(slot.as_str())
.execute(pool)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db;
const TS: &str = "2026-01-01T00:00:00Z";
/// A file-backed pool (so a "restart" can reopen the same DB) with two clubs.
async fn fixture() -> (tempfile::TempDir, String, db::Pool) {
let dir = tempfile::tempdir().expect("tempdir");
let url = format!("sqlite://{}", dir.path().join("core.db").display());
let pool = db::init_pool(&url, 5).await.expect("init pool");
db::run_migrations(&pool).await.expect("migrations");
for (profile, club) in [("prof-a", "club-a"), ("prof-b", "club-b")] {
sqlx::query(
"INSERT INTO profiles (id, username, created_at, updated_at) VALUES (?, ?, ?, ?)",
)
.bind(profile)
.bind(profile)
.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(profile).bind(club).bind(1000i64).bind(TS).bind(TS)
.execute(&pool).await.expect("club");
}
for (id, club, definition, kind) in [
("mgr", "club-a", "def-mgr", ContentKind::Manager),
("mgr2", "club-a", "def-mgr", ContentKind::Manager),
("player", "club-a", "def-player", ContentKind::Player),
("kit-home", "club-a", "def-kit-home", ContentKind::Kit),
("kit-away", "club-a", "def-kit-away", ContentKind::Kit),
("kit-away-2", "club-a", "def-kit-away-2", ContentKind::Kit),
("badge", "club-a", "def-badge", ContentKind::Badge),
("ball", "club-a", "def-ball", ContentKind::Ball),
("stadium", "club-a", "def-stadium", ContentKind::Stadium),
("foreign", "club-b", "def-kit-foreign", ContentKind::Kit),
] {
sqlx::query(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at, content_kind) \
VALUES (?, ?, ?, 0, ?, ?)",
)
.bind(id)
.bind(club)
.bind(definition)
.bind(TS)
.bind(kind.as_str())
.execute(&pool)
.await
.expect("owned card");
}
// club-a has one squad.
sqlx::query("INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES ('sq-a', 'club-a', 'S', '4-4-2', ?, ?)")
.bind(TS).bind(TS).execute(&pool).await.expect("squad");
(dir, url, pool)
}
async fn manager_rows(pool: &db::Pool) -> i64 {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM squad_managers")
.fetch_one(pool)
.await
.unwrap()
}
async fn active_item_rows(pool: &db::Pool) -> i64 {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM club_active_items")
.fetch_one(pool)
.await
.unwrap()
}
#[tokio::test]
async fn manager_persists_across_reload_and_restart() {
let (dir, url, pool) = fixture().await;
// SAVE.
set_squad_manager(&pool, "club-a", "mgr")
.await
.expect("assign");
// RELOAD (same pool).
let got = get_squad_manager(&pool, "club-a").await.unwrap();
assert_eq!(got.as_ref().map(|c| c.id.as_str()), Some("mgr"));
// RESTART: close the pool and reopen the same DB file.
pool.close().await;
let reopened = db::init_pool(&url, 5).await.expect("reopen");
db::run_migrations(&reopened).await.expect("migrations");
let after = get_squad_manager(&reopened, "club-a").await.unwrap();
assert_eq!(
after.as_ref().map(|c| c.id.as_str()),
Some("mgr"),
"manager assignment must survive a server restart"
);
drop(dir);
}
#[tokio::test]
async fn reassignment_replaces_and_never_duplicates() {
let (_dir, _url, pool) = fixture().await;
set_squad_manager(&pool, "club-a", "mgr").await.unwrap();
set_squad_manager(&pool, "club-a", "mgr2").await.unwrap();
assert_eq!(manager_rows(&pool).await, 1, "one manager per squad");
let got = get_squad_manager(&pool, "club-a").await.unwrap();
assert_eq!(got.map(|c| c.id), Some("mgr2".to_string()));
}
#[tokio::test]
async fn clear_removes_and_no_resurrection() {
let (_dir, _url, pool) = fixture().await;
set_squad_manager(&pool, "club-a", "mgr").await.unwrap();
clear_squad_manager(&pool, "club-a").await.unwrap();
assert!(get_squad_manager(&pool, "club-a").await.unwrap().is_none());
assert_eq!(manager_rows(&pool).await, 0);
// Clearing again is an idempotent no-op.
clear_squad_manager(&pool, "club-a").await.unwrap();
}
#[tokio::test]
async fn rejects_card_the_club_does_not_own() {
let (_dir, _url, pool) = fixture().await;
let err = set_squad_manager(&pool, "club-a", "foreign").await;
assert!(err.is_err(), "cannot assign a card owned by another club");
assert_eq!(manager_rows(&pool).await, 0);
}
#[tokio::test]
async fn quick_sell_of_manager_cascades_the_assignment_away() {
let (_dir, _url, pool) = fixture().await;
set_squad_manager(&pool, "club-a", "mgr").await.unwrap();
// A quick-sell/discard DELETEs the owned row; ON DELETE CASCADE must
// remove the assignment so the sold manager is never resurrected.
sqlx::query("DELETE FROM owned_cards WHERE id = 'mgr'")
.execute(&pool)
.await
.expect("delete owned card");
assert_eq!(manager_rows(&pool).await, 0);
assert!(get_squad_manager(&pool, "club-a").await.unwrap().is_none());
}
#[tokio::test]
async fn no_manager_when_none_assigned() {
let (_dir, _url, pool) = fixture().await;
assert!(get_squad_manager(&pool, "club-a").await.unwrap().is_none());
}
// ── active club item designations ──
#[tokio::test]
async fn active_items_persist_across_reload_and_restart() {
let (dir, url, pool) = fixture().await;
for (slot, id) in [
(ActiveSlot::HomeKit, "kit-home"),
(ActiveSlot::AwayKit, "kit-away"),
(ActiveSlot::Badge, "badge"),
(ActiveSlot::Ball, "ball"),
(ActiveSlot::Stadium, "stadium"),
] {
set_active_club_item(&pool, "club-a", slot, id)
.await
.expect("designate");
}
let current = get_active_club_items(&pool, "club-a").await.unwrap();
assert_eq!(current.items.len(), 5, "every slot filled");
// Projection order is the canonical slot order, not DB insertion order.
assert_eq!(
current.items.iter().map(|(s, _)| *s).collect::<Vec<_>>(),
ActiveSlot::ALL.to_vec()
);
pool.close().await;
let reopened = db::init_pool(&url, 5).await.expect("reopen");
db::run_migrations(&reopened).await.expect("migrations");
let persisted = get_active_club_items(&reopened, "club-a").await.unwrap();
assert_eq!(
persisted.get(ActiveSlot::Stadium).map(|c| c.id.as_str()),
Some("stadium"),
"designations must survive a server restart"
);
drop(dir);
}
#[tokio::test]
async fn active_item_replace_and_clear_never_duplicate() {
let (_dir, _url, pool) = fixture().await;
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "kit-away")
.await
.unwrap();
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "kit-away-2")
.await
.unwrap();
assert_eq!(active_item_rows(&pool).await, 1, "one item per slot");
let current = get_active_club_items(&pool, "club-a").await.unwrap();
assert_eq!(
current.get(ActiveSlot::AwayKit).map(|c| c.id.as_str()),
Some("kit-away-2")
);
clear_active_club_item(&pool, "club-a", ActiveSlot::AwayKit)
.await
.unwrap();
assert_eq!(active_item_rows(&pool).await, 0);
// Clearing an empty slot is an idempotent no-op.
clear_active_club_item(&pool, "club-a", ActiveSlot::AwayKit)
.await
.unwrap();
}
#[tokio::test]
async fn active_item_rejects_unowned_card_and_leaves_state_intact() {
let (_dir, _url, pool) = fixture().await;
set_active_club_item(&pool, "club-a", ActiveSlot::HomeKit, "kit-home")
.await
.unwrap();
assert!(
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "foreign")
.await
.is_err(),
"another club's item cannot be designated"
);
assert!(
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "nope")
.await
.is_err(),
"a non-existent instance cannot be designated"
);
let unchanged = get_active_club_items(&pool, "club-a").await.unwrap();
assert_eq!(
unchanged.get(ActiveSlot::HomeKit).map(|c| c.id.as_str()),
Some("kit-home")
);
assert_eq!(active_item_rows(&pool).await, 1);
}
#[tokio::test]
async fn active_item_rejects_slot_kind_mismatch() {
let (_dir, _url, pool) = fixture().await;
// A badge is not a kit; a player is not a stadium.
for (slot, id) in [
(ActiveSlot::HomeKit, "badge"),
(ActiveSlot::Stadium, "player"),
(ActiveSlot::Ball, "kit-home"),
] {
let err = set_active_club_item(&pool, "club-a", slot, id)
.await
.expect_err("slot/kind mismatch must be refused");
assert!(
matches!(err, AppError::BadRequest(_)),
"expected a bad-request, got {err:?}"
);
}
assert_eq!(active_item_rows(&pool).await, 0);
}
/// Lifecycle invariant: one owned instance can occupy at most ONE slot.
/// Re-designating it moves it rather than duplicating it.
#[tokio::test]
async fn one_instance_cannot_occupy_two_slots() {
let (_dir, _url, pool) = fixture().await;
set_active_club_item(&pool, "club-a", ActiveSlot::HomeKit, "kit-home")
.await
.unwrap();
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "kit-home")
.await
.unwrap();
assert_eq!(active_item_rows(&pool).await, 1);
let current = get_active_club_items(&pool, "club-a").await.unwrap();
assert!(current.get(ActiveSlot::HomeKit).is_none());
assert_eq!(
current.get(ActiveSlot::AwayKit).map(|c| c.id.as_str()),
Some("kit-home")
);
// The schema itself refuses the impossible state, not just the service.
let raw = sqlx::query(
"INSERT INTO club_active_items (club_id, slot, owned_card_id, updated_at) \
VALUES ('club-a', 'home_kit', 'kit-home', ?)",
)
.bind(TS)
.execute(&pool)
.await;
assert!(
raw.is_err(),
"owned_card_id UNIQUE must reject a second slot"
);
}
/// Lifecycle invariant: a designation can never point at an item the club
/// does not own — neither after a quick sell (DELETE) nor after a transfer
/// (UPDATE of club_id, which no FK action can observe).
#[tokio::test]
async fn delete_and_transfer_clear_active_designations() {
let (_dir, _url, pool) = fixture().await;
set_active_club_item(&pool, "club-a", ActiveSlot::HomeKit, "kit-home")
.await
.unwrap();
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "kit-away")
.await
.unwrap();
sqlx::query("DELETE FROM owned_cards WHERE id = 'kit-home'")
.execute(&pool)
.await
.expect("quick sell kit");
let after_delete = get_active_club_items(&pool, "club-a").await.unwrap();
assert!(after_delete.get(ActiveSlot::HomeKit).is_none());
assert_eq!(
after_delete.get(ActiveSlot::AwayKit).map(|c| c.id.as_str()),
Some("kit-away")
);
assert_eq!(active_item_rows(&pool).await, 1);
sqlx::query("UPDATE owned_cards SET club_id = 'club-b' WHERE id = 'kit-away'")
.execute(&pool)
.await
.expect("transfer kit");
assert_eq!(active_item_rows(&pool).await, 0, "transfer clears the slot");
let after_transfer = get_active_club_items(&pool, "club-a").await.unwrap();
assert!(after_transfer.items.is_empty());
}
/// A designation whose owned row is forced out of the club WITHOUT the
/// trigger firing (raw row surgery mimicking corruption) must still never
/// project: reads revalidate ownership.
#[tokio::test]
async fn read_revalidates_ownership_of_a_stale_designation() {
let (_dir, _url, pool) = fixture().await;
set_active_club_item(&pool, "club-a", ActiveSlot::Badge, "badge")
.await
.unwrap();
sqlx::query("UPDATE club_active_items SET owned_card_id = 'foreign' WHERE slot = 'badge'")
.execute(&pool)
.await
.expect("corrupt the designation");
let items = get_active_club_items(&pool, "club-a").await.unwrap();
assert!(
items.get(ActiveSlot::Badge).is_none(),
"another club's item must never be projected"
);
}
} }
File diff suppressed because it is too large Load Diff
+24 -7
View File
@@ -88,13 +88,18 @@ pub async fn start_draft(
let first_position = &pick_order[0]; let first_position = &pick_order[0];
let candidates = pick_candidates(card_db, first_position, min_overall, CANDIDATES_PER_SLOT); 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 { let session = DraftSession {
id: Uuid::new_v4().to_string(), id: Uuid::new_v4().to_string(),
profile_id: profile_id.to_string(), profile_id: profile_id.to_string(),
difficulty: difficulty.to_string(), difficulty: difficulty.to_string(),
pick_order: serde_json::to_string(&pick_order).unwrap(), pick_order: pick_order_json,
picks: "[]".to_string(), picks: "[]".to_string(),
current_candidates: Some(serde_json::to_string(&candidates).unwrap()), current_candidates: Some(candidates_json),
status: "active".to_string(), status: "active".to_string(),
reward_coins: 0, reward_coins: 0,
reward_pack_id: None, reward_pack_id: None,
@@ -179,23 +184,36 @@ pub async fn pick_card(
let (new_candidates, new_status, reward_coins, reward_pack_id, squad_rating, completed_at) = let (new_candidates, new_status, reward_coins, reward_pack_id, squad_rating, completed_at) =
if all_filled { if all_filled {
let (coins, pack, avg) = compute_reward(card_db, &picks); let (coins, pack, avg) = compute_reward(card_db, &picks);
(None, "completed".to_string(), coins, pack, avg, Some(chrono::Utc::now().to_rfc3339())) (
None,
"completed".to_string(),
coins,
pack,
avg,
Some(chrono::Utc::now().to_rfc3339()),
)
} else { } else {
let min_overall = difficulty_min_overall(&session.difficulty); let min_overall = difficulty_min_overall(&session.difficulty);
let next_pos = &pick_order[next_index]; let next_pos = &pick_order[next_index];
let next_candidates = let next_candidates =
pick_candidates(card_db, next_pos, min_overall, CANDIDATES_PER_SLOT); 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(serde_json::to_string(&next_candidates).unwrap()), Some(candidates_json),
"active".to_string(), "active".to_string(),
0, 0,
None, None,
0, 0,
None, None,
) )
}
}; };
let picks_json = serde_json::to_string(&picks).unwrap(); let picks_json = serde_json::to_string(&picks)
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
sqlx::query( sqlx::query(
"UPDATE draft_sessions SET picks = ?, current_candidates = ?, status = ?, \ "UPDATE draft_sessions SET picks = ?, current_candidates = ?, status = ?, \
@@ -284,8 +302,7 @@ async fn fetch_session(pool: &Pool, profile_id: &str, session_id: &str) -> AppRe
} }
fn render_session(session: &DraftSession, card_db: &CardDb) -> serde_json::Value { fn render_session(session: &DraftSession, card_db: &CardDb) -> serde_json::Value {
let pick_order: Vec<String> = let pick_order: Vec<String> = serde_json::from_str(&session.pick_order).unwrap_or_default();
serde_json::from_str(&session.pick_order).unwrap_or_default();
let picks: Vec<String> = serde_json::from_str(&session.picks).unwrap_or_default(); let picks: Vec<String> = serde_json::from_str(&session.picks).unwrap_or_default();
let candidates: Vec<String> = session let candidates: Vec<String> = session
.current_candidates .current_candidates
File diff suppressed because it is too large Load Diff
+26 -6
View File
@@ -26,7 +26,11 @@ pub async fn get_active_session(
.map_err(Into::into) .map_err(Into::into)
} }
pub async fn get_session(pool: &Pool, session_id: &str, profile_id: &str) -> AppResult<FutChampsSession> { pub async fn get_session(
pool: &Pool,
session_id: &str,
profile_id: &str,
) -> AppResult<FutChampsSession> {
sqlx::query_as::<_, FutChampsSession>(&format!( sqlx::query_as::<_, FutChampsSession>(&format!(
"{SESSION_SELECT} WHERE id = ? AND profile_id = ?" "{SESSION_SELECT} WHERE id = ? AND profile_id = ?"
)) ))
@@ -239,16 +243,30 @@ pub async fn claim_rivals_reward(
pack_defs: &[PackDefinition], pack_defs: &[PackDefinition],
) -> AppResult<serde_json::Value> { ) -> AppResult<serde_json::Value> {
// Fetch current season row (must exist) // Fetch current season row (must exist)
let row: Option<(i64, i64, i64)> = sqlx::query_as( let row: Option<(i64, i64, i64, Option<String>)> = sqlx::query_as(
"SELECT division, rivals_week_claimed, rivals_total_points FROM seasons WHERE profile_id = ?", "SELECT division, rivals_week_claimed, rivals_total_points, rivals_last_claimed_at \
FROM seasons WHERE profile_id = ?",
) )
.bind(profile_id) .bind(profile_id)
.fetch_optional(pool) .fetch_optional(pool)
.await?; .await?;
let (division, week_claimed, total_pts) = let (division, week_claimed, total_pts, last_claimed_at) =
row.ok_or_else(|| AppError::NotFound("no season found — play a match first".into()))?; 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 next_week = week_claimed + 1;
let coins = rivals_weekly_coins(division); let coins = rivals_weekly_coins(division);
let new_balance = club_svc::add_coins(pool, club_id, coins).await?; let new_balance = club_svc::add_coins(pool, club_id, coins).await?;
@@ -271,11 +289,13 @@ pub async fn claim_rivals_reward(
None None
}; };
let now = chrono::Utc::now().to_rfc3339();
sqlx::query( sqlx::query(
"UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100 \ "UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100, \
WHERE profile_id = ?", rivals_last_claimed_at = ? WHERE profile_id = ?",
) )
.bind(next_week) .bind(next_week)
.bind(&now)
.bind(profile_id) .bind(profile_id)
.execute(pool) .execute(pool)
.await?; .await?;
+34
View File
@@ -0,0 +1,34 @@
//! 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)
}
+489
View File
@@ -0,0 +1,489 @@
//! 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 std::collections::BTreeMap;
use crate::db::Pool;
use crate::models::card::ContentKind;
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,
/// Generic content classification. Absent = `player`, which is what every
/// pre-taxonomy import produced; the adapter maps its own taxonomy (FIFA 17
/// `cardsubtypeid`, resource ranges, …) onto this before calling Core.
#[serde(default)]
pub content_kind: ContentKind,
/// Optional per-instance stack size. Absent / `null` means "not a stack"; it
/// never collapses two instances into one row.
///
/// NOT a consumable's wire `amount`: in FIFA 17 that field is the
/// definition's effect magnitude (a "+15 training" card), and every copy is
/// its own instance carrying the same value, so storing it here would claim
/// the club owns fifteen of them.
#[serde(default)]
pub quantity: Option<i64>,
}
#[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");
}
// A stack size is either absent ("not a stack") or a real positive count.
// Reject an explicit 0/negative up front rather than letting the column
// CHECK surface it as an opaque constraint failure mid-transaction.
for o in &req.owned {
if let Some(q) = o.quantity {
if q < 1 {
bail!(
"owned card {} has quantity {q}; a stack size must be omitted or >= 1",
o.owned_item_id
);
}
}
}
// ── 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, \
content_kind, quantity) \
VALUES (?, ?, ?, 0, NULL, ?, ?, ?)",
)
.bind(&o.owned_item_id)
.bind(&club_id)
.bind(&o.card_id)
.bind(&now)
.bind(o.content_kind.as_str())
.bind(o.quantity)
.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,
})
}
/// One adapter-supplied classification: "every owned row of this definition is
/// really this kind of content".
#[derive(Debug, Clone, Deserialize)]
pub struct ContentKindAssignment {
pub card_id: String,
pub content_kind: ContentKind,
}
/// Request for [`reclassify_owned_content`].
#[derive(Debug, Clone, Deserialize)]
pub struct ReclassifyRequest {
pub game_id: String,
pub assignments: Vec<ContentKindAssignment>,
/// Compute the outcome and roll back instead of committing. Lets an operator
/// see exactly what a production run would touch before it touches it.
#[serde(default)]
pub dry_run: bool,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct ReclassifyOutcome {
/// Rows whose `content_kind` actually changed. On a dry run, the rows that
/// WOULD change; nothing is committed.
pub updated: usize,
/// Rows already carrying the requested kind (a rerun updates nothing).
pub unchanged: usize,
/// Assignments naming a definition this game owns no copy of.
pub unmatched_definitions: Vec<String>,
/// True when the transaction was rolled back rather than committed.
pub dry_run: bool,
/// Per-kind tally of the rows that changed, so an operator can sanity-check
/// the shape of the change ("3 staff, 17 consumable") before committing.
pub updated_by_kind: BTreeMap<String, usize>,
}
/// Correct the `content_kind` of ALREADY-IMPORTED owned rows, in one transaction.
///
/// A profile import is once-only (same fingerprint no-ops, a different one is
/// refused), so a taxonomy fix cannot arrive by re-importing. Core defaults an
/// unstated row to `player`, which means every pre-taxonomy import durably
/// recorded coaches, kits and consumables as players — wrong in the ownership
/// authority even where a catalog-driven wire looked right.
///
/// Core stays generic: the caller supplies `card_id -> kind`, because only the
/// game adapter can map its own taxonomy. Idempotent, and scoped to one game's
/// clubs so a shared database cannot be reclassified across games.
pub async fn reclassify_owned_content(
pool: &Pool,
req: &ReclassifyRequest,
) -> Result<ReclassifyOutcome> {
if req.assignments.is_empty() {
bail!("reclassify request has zero assignments");
}
let mut tx = pool.begin().await?;
let mut updated = 0usize;
let mut unchanged = 0usize;
let mut unmatched = Vec::new();
let mut by_kind: BTreeMap<String, usize> = BTreeMap::new();
for a in &req.assignments {
// Scope by game through the owning club, so the same definition id in
// another game is never touched.
let present: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM owned_cards o JOIN clubs c ON c.id = o.club_id \
JOIN profiles p ON p.id = c.profile_id \
WHERE p.game_id = ? AND o.card_id = ?",
)
.bind(&req.game_id)
.bind(&a.card_id)
.fetch_one(&mut *tx)
.await
.context("count owned rows for definition")?;
if present == 0 {
unmatched.push(a.card_id.clone());
continue;
}
let changed = sqlx::query(
"UPDATE owned_cards SET content_kind = ? \
WHERE card_id = ? AND content_kind != ? AND club_id IN \
(SELECT c.id FROM clubs c JOIN profiles p ON p.id = c.profile_id \
WHERE p.game_id = ?)",
)
.bind(a.content_kind.as_str())
.bind(&a.card_id)
.bind(a.content_kind.as_str())
.bind(&req.game_id)
.execute(&mut *tx)
.await
.context("update owned content_kind")?
.rows_affected() as usize;
updated += changed;
unchanged += present as usize - changed;
if changed > 0 {
*by_kind
.entry(a.content_kind.as_str().to_string())
.or_default() += changed;
}
}
// A dry run does the real UPDATEs and then throws them away, so the counts
// it reports are measured rather than predicted — the same statements, the
// same WHERE clauses, just no commit.
if req.dry_run {
tx.rollback().await?;
} else {
tx.commit().await?;
}
Ok(ReclassifyOutcome {
updated,
unchanged,
unmatched_definitions: unmatched,
dry_run: req.dry_run,
updated_by_kind: by_kind,
})
}
+497
View File
@@ -0,0 +1,497 @@
//! The CLOSED vocabulary of instance mutations Core can execute for a caller
//! that is not in-process.
//!
//! [`crate::services::consume::consume_item`] takes an
//! [`ItemMutation`] — an in-process closure, which cannot cross an HTTP
//! boundary. A game host applying a consumable over HTTP therefore cannot SUPPLY
//! its effect; it can only DESCRIBE one, and Core executes the description.
//!
//! That does not move the game's formula into Core. The caller still owns every
//! number: how many match-contracts a given card grants a given target is the
//! adapter's reversed per-game table, and it arrives here as `amount`. Core owns
//! only what it can prove without knowing the game — the arithmetic, the clamp,
//! the ownership scope, the loan invariant and the transaction. This is the same
//! split quick-sell already uses (the host prices the item, Core moves it).
//!
//! The enum is closed ON PURPOSE. A generic "set field X to value Y" escape
//! hatch would hand the host arbitrary write access to Core state and make every
//! present and future invariant unenforceable; a new effect is a new variant,
//! validated here, reviewed here.
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sqlx::SqliteConnection;
use crate::{
error::{AppError, AppResult},
models::card::OwnedCard,
services::consume::{ConsumeContext, ItemMutation, MutationFuture},
};
/// One wire-describable mutation of a target instance.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum InstanceEffect {
/// Add match-contracts to the target, saturating at `cap`.
///
/// `default_when_unset` is what a target Core tracks no contract for counts
/// as — the caller's pack-fresh starting value (FIFA 17: 7). Core has no such
/// default of its own, which is precisely why `owned_cards.contract_matches`
/// is nullable; see migration 0028.
AddContractMatches {
amount: i64,
cap: i64,
default_when_unset: i64,
},
}
impl ItemMutation for InstanceEffect {
fn apply<'c>(
&'c self,
tx: &'c mut SqliteConnection,
ctx: &'c ConsumeContext,
) -> MutationFuture<'c> {
match self {
InstanceEffect::AddContractMatches {
amount,
cap,
default_when_unset,
} => Box::pin(add_contract_matches(
tx,
ctx,
*amount,
*cap,
*default_when_unset,
)),
}
}
}
/// The target instance this effect mutates. An effect that writes to an instance
/// has nothing to write to when the application is club-scoped, so that is a
/// refusal rather than a silent no-op that still spends the source.
fn require_target<'a>(ctx: &'a ConsumeContext, effect: &str) -> AppResult<&'a OwnedCard> {
ctx.target
.as_ref()
.ok_or_else(|| AppError::BadRequest(format!("effect '{effect}' requires a target item")))
}
async fn add_contract_matches(
tx: &mut SqliteConnection,
ctx: &ConsumeContext,
amount: i64,
cap: i64,
default_when_unset: i64,
) -> AppResult<Value> {
let target = require_target(ctx, "add_contract_matches")?;
if amount < 1 {
return Err(AppError::BadRequest(format!(
"add_contract_matches amount must be >= 1, got {amount}"
)));
}
if cap < 1 {
return Err(AppError::BadRequest(format!(
"add_contract_matches cap must be >= 1, got {cap}"
)));
}
if default_when_unset < 0 {
return Err(AppError::BadRequest(format!(
"add_contract_matches default_when_unset must be >= 0, got {default_when_unset}"
)));
}
// A loan item is borrowed for a fixed number of matches
// (`loan_matches_remaining`); topping up its contract would pretend to extend
// something Core does not own. Core models loans, so the invariant is Core's.
if target.is_loan {
return Err(AppError::BadRequest(format!(
"contracts cannot be applied to a loan item ('{}')",
target.id
)));
}
// Read-modify-write INSIDE the caller's transaction, deliberately re-reading
// rather than trusting the snapshot in `ctx`: the read and the write then sit
// in one contiguous critical section under the same write lock, so two
// concurrent applies serialise instead of both computing from one `before`
// and losing an update.
let before = sqlx::query_scalar::<_, i64>(
"SELECT COALESCE(contract_matches, ?) FROM owned_cards WHERE id = ? AND club_id = ?",
)
.bind(default_when_unset)
.bind(&target.id)
.bind(&ctx.club_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| AppError::NotFound(format!("target item '{}' not found", target.id)))?;
// Saturating, because `before + amount` is caller-supplied arithmetic and an
// overflow must not panic a request thread; the clamp makes the sum's exact
// magnitude irrelevant anyway.
let after = before.saturating_add(amount).min(cap);
let updated =
sqlx::query("UPDATE owned_cards SET contract_matches = ? WHERE id = ? AND club_id = ?")
.bind(after)
.bind(&target.id)
.bind(&ctx.club_id)
.execute(&mut *tx)
.await?
.rows_affected();
if updated != 1 {
return Err(AppError::Conflict(format!(
"target item '{}' changed under us",
target.id
)));
}
// `granted` is what the caller's table awarded, NOT `after - before`: the cap
// can swallow part of it, and the two numbers answer different questions
// (what the card was worth vs. what the instance now holds).
Ok(json!({
"kind": "add_contract_matches",
"granted": amount,
"before": before,
"after": after,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db;
use crate::models::card::ContentKind;
use crate::services::consume::{
consume_item, ConsumeRequest, ConsumeTarget, SourceConsumption,
};
const TS: &str = "2026-01-01T00:00:00Z";
/// One club holding contract consumables and three player targets: one Core
/// tracks no contract for, one part-way through its contract, and one on loan.
async fn fixture() -> (tempfile::TempDir, db::Pool) {
let dir = tempfile::tempdir().expect("tempdir");
let url = format!("sqlite://{}", dir.path().join("core.db").display());
let pool = db::init_pool(&url, 5).await.expect("init pool");
db::run_migrations(&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 ('club', 'prof', 'club', 0, ?, ?)",
)
.bind(TS)
.bind(TS)
.execute(&pool)
.await
.expect("club");
for (id, kind, is_loan, contract) in [
("card-1", ContentKind::Consumable, 0, None),
("card-2", ContentKind::Consumable, 0, None),
("card-3", ContentKind::Consumable, 0, None),
("fresh", ContentKind::Player, 0, None),
("used", ContentKind::Player, 0, Some(90i64)),
("loaned", ContentKind::Player, 1, None),
] {
sqlx::query(
"INSERT INTO owned_cards \
(id, club_id, card_id, is_loan, acquired_at, content_kind, contract_matches) \
VALUES (?, 'club', ?, ?, ?, ?, ?)",
)
.bind(id)
.bind(format!("def-{id}"))
.bind(is_loan)
.bind(TS)
.bind(kind.as_str())
.bind(contract)
.execute(&pool)
.await
.expect("owned card");
}
(dir, pool)
}
fn apply_request<'a>(
identity: &'a str,
source: &'a str,
target: &'a str,
) -> ConsumeRequest<'a> {
ConsumeRequest {
action_identity: identity,
source_owned_card_id: source,
expected_source_kind: ContentKind::Consumable,
consumption: SourceConsumption::DestroyInstance,
target: ConsumeTarget::OwnedCard {
owned_card_id: target,
expected_kind: ContentKind::Player,
},
}
}
fn grant(amount: i64) -> InstanceEffect {
InstanceEffect::AddContractMatches {
amount,
cap: 99,
default_when_unset: 7,
}
}
async fn contract_of(pool: &db::Pool, id: &str) -> Option<i64> {
sqlx::query_scalar::<_, Option<i64>>(
"SELECT contract_matches FROM owned_cards WHERE id = ?",
)
.bind(id)
.fetch_one(pool)
.await
.expect("read contract")
}
/// A target Core tracks no contract for counts as the CALLER's pack-fresh
/// default, not as zero — the whole reason the column is nullable.
#[tokio::test]
async fn an_unset_target_starts_from_the_callers_default() {
let (_dir, pool) = fixture().await;
let out = consume_item(
&pool,
"prof",
"club",
&apply_request("act-1", "card-1", "fresh"),
&grant(15),
)
.await
.expect("apply");
assert!(out.applied);
assert!(out.source_destroyed);
assert_eq!(
out.effect,
json!({ "kind": "add_contract_matches", "granted": 15, "before": 7, "after": 22 })
);
assert_eq!(contract_of(&pool, "fresh").await, Some(22));
}
/// A stored value is added to, never replaced by the default.
#[tokio::test]
async fn an_existing_value_is_added_to() {
let (_dir, pool) = fixture().await;
let out = consume_item(
&pool,
"prof",
"club",
&apply_request("act-2", "card-1", "used"),
&grant(3),
)
.await
.expect("apply");
assert_eq!(
out.effect,
json!({ "kind": "add_contract_matches", "granted": 3, "before": 90, "after": 93 })
);
assert_eq!(contract_of(&pool, "used").await, Some(93));
}
/// The cap clamps the STORED total but not the REPORTED grant: they answer
/// different questions, and a caller reconciling its own wire response needs
/// the amount its table awarded.
#[tokio::test]
async fn the_cap_clamps_the_total_but_not_the_reported_grant() {
let (_dir, pool) = fixture().await;
let out = consume_item(
&pool,
"prof",
"club",
&apply_request("act-3", "card-1", "used"),
&grant(28),
)
.await
.expect("apply");
assert_eq!(
out.effect,
json!({ "kind": "add_contract_matches", "granted": 28, "before": 90, "after": 99 })
);
assert_eq!(contract_of(&pool, "used").await, Some(99));
}
/// A loan is borrowed for a fixed run of matches; its contract is not Core's
/// to extend. The refusal must also unwind the charge.
#[tokio::test]
async fn a_loan_target_is_refused_and_the_source_survives() {
let (_dir, pool) = fixture().await;
let err = consume_item(
&pool,
"prof",
"club",
&apply_request("act-loan", "card-1", "loaned"),
&grant(15),
)
.await
.expect_err("a loan cannot take a contract");
assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}");
assert_eq!(contract_of(&pool, "loaned").await, None);
let sources =
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards WHERE id = 'card-1'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(sources, 1, "a refused effect must not spend the source");
}
/// This effect writes to an instance, so a club-scoped application has
/// nothing to write to — refuse rather than spend the source for nothing.
#[tokio::test]
async fn a_club_scoped_application_is_refused() {
let (_dir, pool) = fixture().await;
let err = consume_item(
&pool,
"prof",
"club",
&ConsumeRequest {
action_identity: "act-club",
source_owned_card_id: "card-1",
expected_source_kind: ContentKind::Consumable,
consumption: SourceConsumption::DestroyInstance,
target: ConsumeTarget::Club,
},
&grant(15),
)
.await
.expect_err("no target to contract");
assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}");
}
/// Core validates the caller's numbers instead of trusting them: a zero or
/// negative grant, a zero cap and a negative default are all nonsense, and a
/// nonsense application must not silently succeed as a no-op.
#[tokio::test]
async fn nonsensical_parameters_are_refused() {
let (_dir, pool) = fixture().await;
for (identity, effect) in [
(
"bad-amount-0",
InstanceEffect::AddContractMatches {
amount: 0,
cap: 99,
default_when_unset: 7,
},
),
(
"bad-amount-neg",
InstanceEffect::AddContractMatches {
amount: -5,
cap: 99,
default_when_unset: 7,
},
),
(
"bad-cap",
InstanceEffect::AddContractMatches {
amount: 1,
cap: 0,
default_when_unset: 7,
},
),
(
"bad-default",
InstanceEffect::AddContractMatches {
amount: 1,
cap: 99,
default_when_unset: -1,
},
),
] {
let Err(err) = consume_item(
&pool,
"prof",
"club",
&apply_request(identity, "card-1", "fresh"),
&effect,
)
.await
else {
panic!("{identity} must be refused");
};
assert!(
matches!(err, AppError::BadRequest(_)),
"{identity}: got {err:?}"
);
}
assert_eq!(
contract_of(&pool, "fresh").await,
None,
"a refused application leaves the target untracked"
);
}
/// The replay guard covers the effect too: a repeated `action_identity`
/// echoes the recorded outcome, adds no second grant, and spends no second
/// card.
#[tokio::test]
async fn a_replay_neither_grants_nor_charges_twice() {
let (_dir, pool) = fixture().await;
let first = consume_item(
&pool,
"prof",
"club",
&apply_request("act-replay", "card-1", "fresh"),
&grant(15),
)
.await
.expect("first");
assert!(first.applied);
let replay = consume_item(
&pool,
"prof",
"club",
&apply_request("act-replay", "card-1", "fresh"),
&grant(15),
)
.await
.expect("replay");
assert!(!replay.applied, "a replay must not re-apply");
assert_eq!(
replay.effect, first.effect,
"the recorded outcome is echoed"
);
assert_eq!(contract_of(&pool, "fresh").await, Some(22), "granted once");
assert_eq!(
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards WHERE id LIKE 'card-%'")
.fetch_one(&pool)
.await
.unwrap(),
2,
"exactly one consumable was spent"
);
}
/// The wire vocabulary is closed: the documented body parses, and an
/// undescribed effect is rejected at the boundary rather than reaching a
/// fallback.
#[test]
fn the_effect_vocabulary_is_closed() {
let parsed: InstanceEffect = serde_json::from_str(
r#"{"kind":"add_contract_matches","amount":15,"cap":99,"default_when_unset":7}"#,
)
.expect("the documented effect body must parse");
assert!(matches!(
parsed,
InstanceEffect::AddContractMatches {
amount: 15,
cap: 99,
default_when_unset: 7
}
));
assert!(
serde_json::from_str::<InstanceEffect>(r#"{"kind":"set_rating","value":99}"#).is_err(),
"an effect Core does not implement must be refused, never guessed"
);
}
}
+349
View File
@@ -0,0 +1,349 @@
//! Game-independent owned-inventory query: semantic filtering, deterministic
//! ordering, and offset/limit pagination over a club's owned items.
//!
//! This layer is deliberately free of any game-specific concepts. It never sees
//! raw FIFA (or any other game's) numeric entity ids — a game adapter is
//! responsible for translating its wire query into the *semantic* values here
//! (quality tier, entity **names**, semantic offset/limit). The canonical
//! ordering is imposed by Core so pagination is correct and repeatable
//! regardless of what (if any) sort the client requests; see the module tests
//! and `docs`/vault for why the client's `sort` key is treated as UNKNOWN.
//!
//! Order of operations is load-bearing: **filter → order → paginate**. Paginating
//! before filtering is the production bug this replaces (a client that pages an
//! unfiltered/unsorted set re-reads page one forever and amplifies requests).
use serde::Deserialize;
use crate::models::card::{ContentKind, Quality};
/// Semantic owned-inventory query. All values are game-independent: a quality
/// tier, entity **names** (not ids), and semantic offset/limit. Every filter is
/// optional; combined filters are ANDed. Absent field = no constraint.
#[derive(Debug, Default, Deserialize)]
pub struct OwnedItemQuery {
/// Quality tier (gold/silver/bronze). Serialized lowercase.
#[serde(default)]
pub quality: Option<Quality>,
/// Owned-content kind (player/consumable/kit/…). Serialized lowercase.
#[serde(default)]
pub content_kind: Option<ContentKind>,
/// Playing position, e.g. "ST" (matched case-insensitively).
#[serde(default)]
pub position: Option<String>,
/// Nation name, e.g. "Argentina" (matched case-insensitively).
#[serde(default)]
pub nation: Option<String>,
/// League name, e.g. "Premier League" (matched case-insensitively).
#[serde(default)]
pub league: Option<String>,
/// Club name, e.g. "Chelsea" (matched case-insensitively).
#[serde(default)]
pub club: Option<String>,
/// Number of leading items to skip after filtering + ordering.
#[serde(default)]
pub offset: Option<i64>,
/// Maximum number of items to return in the page.
#[serde(default)]
pub limit: Option<i64>,
}
/// One owned item projected to the attributes needed for querying, plus the
/// response body to hand back verbatim once it survives the filter+page.
#[derive(Clone)]
pub struct OwnedItemView {
pub owned_card_id: String,
/// What kind of content this instance is; lets a caller filter without
/// re-deriving the taxonomy from definition fields.
pub content_kind: ContentKind,
/// Base card overall (drives quality tier).
pub base_overall: u8,
/// Effective overall (base + training bonus); drives ordering.
pub effective_overall: i64,
pub position: String,
pub nation: String,
pub league: String,
pub club: String,
pub body: serde_json::Value,
}
impl OwnedItemView {
fn quality(&self) -> Quality {
Quality::from_overall(self.base_overall)
}
}
/// Result of applying a query: the requested page plus the count of items that
/// matched the filter **before** pagination (what a client needs to page).
pub struct QueryPage {
pub items: Vec<serde_json::Value>,
pub total: usize,
pub offset: usize,
pub limit: Option<usize>,
}
/// Does an item satisfy every present filter (AND semantics)?
fn matches(item: &OwnedItemView, q: &OwnedItemQuery) -> bool {
let quality_ok = q.quality.map(|want| item.quality() == want).unwrap_or(true);
let kind_ok = q
.content_kind
.map(|want| item.content_kind == want)
.unwrap_or(true);
let pos_ok = q
.position
.as_ref()
.map(|p| item.position.eq_ignore_ascii_case(p))
.unwrap_or(true);
let nation_ok = q
.nation
.as_ref()
.map(|n| item.nation.eq_ignore_ascii_case(n))
.unwrap_or(true);
let league_ok = q
.league
.as_ref()
.map(|l| item.league.eq_ignore_ascii_case(l))
.unwrap_or(true);
let club_ok = q
.club
.as_ref()
.map(|c| item.club.eq_ignore_ascii_case(c))
.unwrap_or(true);
quality_ok && kind_ok && pos_ok && nation_ok && league_ok && club_ok
}
/// Apply the query: filter (AND) → deterministic order → paginate.
///
/// Ordering is `(effective_overall DESC, owned_card_id ASC)` — a total order, so
/// pages never overlap or repeat. `offset`/`limit` are clamped to sane
/// non-negative values (the wire never sends negatives; clamping keeps a
/// malformed request from panicking).
pub fn apply_query(mut items: Vec<OwnedItemView>, q: &OwnedItemQuery) -> QueryPage {
// 1. filter
items.retain(|it| matches(it, q));
let total = items.len();
// 2. deterministic total order (independent of input/DB order)
items.sort_by(|a, b| {
b.effective_overall
.cmp(&a.effective_overall)
.then_with(|| a.owned_card_id.cmp(&b.owned_card_id))
});
// 3. paginate
let offset = q.offset.unwrap_or(0).max(0) as usize;
let limit = q.limit.map(|l| l.max(0) as usize);
let page: Vec<serde_json::Value> = items
.into_iter()
.skip(offset)
.take(limit.unwrap_or(usize::MAX))
.map(|it| it.body)
.collect();
QueryPage {
items: page,
total,
offset,
limit,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn view(
id: &str,
overall: u8,
position: &str,
nation: &str,
league: &str,
club: &str,
) -> OwnedItemView {
OwnedItemView {
owned_card_id: id.to_string(),
content_kind: ContentKind::Player,
base_overall: overall,
effective_overall: overall as i64,
position: position.to_string(),
nation: nation.to_string(),
league: league.to_string(),
club: club.to_string(),
body: json!({ "owned_card_id": id, "overall": overall }),
}
}
fn ids(page: &QueryPage) -> Vec<String> {
page.items
.iter()
.map(|b| b["owned_card_id"].as_str().unwrap().to_string())
.collect()
}
fn fixture() -> Vec<OwnedItemView> {
vec![
view("a", 84, "ST", "England", "Premier League", "Northgate"),
view("b", 86, "CDM", "Ghana", "Premier League", "Chelsea"),
view("c", 72, "ST", "Brazil", "Brasileirao", "Santos"),
view("d", 60, "CM", "Italy", "Serie B", "Modena"),
view("e", 89, "LW", "Argentina", "Primera Division", "Boca"),
]
}
/// A club holds mixed content; a caller asking for one kind must get exactly
/// that kind, and the unfiltered read must still return everything.
#[test]
fn content_kind_filters_mixed_inventory() {
let mut items = fixture();
let mut kit = view("k", 0, "", "", "", "");
kit.content_kind = ContentKind::Kit;
let mut style = view("s", 0, "", "", "", "");
style.content_kind = ContentKind::Consumable;
items.push(kit);
items.push(style);
let all = apply_query(items.clone(), &OwnedItemQuery::default());
assert_eq!(all.total, 7, "no filter returns every kind");
let kits = apply_query(
items.clone(),
&OwnedItemQuery {
content_kind: Some(ContentKind::Kit),
..Default::default()
},
);
assert_eq!(ids(&kits), ["k"]);
let players = apply_query(
items.clone(),
&OwnedItemQuery {
content_kind: Some(ContentKind::Player),
..Default::default()
},
);
assert_eq!(players.total, 5);
let none = apply_query(
items,
&OwnedItemQuery {
content_kind: Some(ContentKind::Stadium),
..Default::default()
},
);
assert_eq!(
none.total, 0,
"a kind the club owns none of is empty, not everything"
);
}
#[test]
fn no_filter_returns_all_in_overall_desc_order() {
let p = apply_query(fixture(), &OwnedItemQuery::default());
assert_eq!(p.total, 5);
assert_eq!(ids(&p), ["e", "b", "a", "c", "d"]); // 89,86,84,72,60
}
#[test]
fn quality_gold_selects_overall_75_plus() {
let q = OwnedItemQuery {
quality: Some(Quality::Gold),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(ids(&p), ["e", "b", "a"]);
}
#[test]
fn filters_are_anded() {
let q = OwnedItemQuery {
league: Some("Premier League".into()),
position: Some("ST".into()),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(ids(&p), ["a"]); // only the PL ST, not the PL CDM
}
#[test]
fn case_insensitive_name_match() {
let q = OwnedItemQuery {
club: Some("chelsea".into()),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(ids(&p), ["b"]);
}
#[test]
fn empty_when_nothing_matches() {
let q = OwnedItemQuery {
nation: Some("Argentina".into()),
club: Some("Chelsea".into()),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(p.total, 0);
assert!(p.items.is_empty());
}
#[test]
fn filter_runs_before_pagination() {
// Gold set is [e,b,a]; page (offset 1, limit 1) over the FILTERED set is [b].
// If pagination ran first, offset/limit would slice the full 5-item set.
let q = OwnedItemQuery {
quality: Some(Quality::Gold),
offset: Some(1),
limit: Some(1),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert_eq!(p.total, 3, "total is the filtered count, not the page size");
assert_eq!(ids(&p), ["b"]);
}
#[test]
fn pages_do_not_overlap_and_advance() {
let page = |off| {
apply_query(
fixture(),
&OwnedItemQuery {
offset: Some(off),
limit: Some(2),
..Default::default()
},
)
};
let p0 = page(0);
let p1 = page(2);
assert_eq!(ids(&p0), ["e", "b"]);
assert_eq!(ids(&p1), ["a", "c"]);
// start advancing must NOT re-serve page one
assert_ne!(ids(&p0), ids(&p1));
assert_eq!(p0.total, 5);
assert_eq!(p1.total, 5);
}
#[test]
fn offset_past_end_is_empty_not_wrapped() {
let q = OwnedItemQuery {
offset: Some(100),
limit: Some(11),
..Default::default()
};
let p = apply_query(fixture(), &q);
assert!(p.items.is_empty());
assert_eq!(p.total, 5);
}
#[test]
fn ordering_is_stable_on_overall_ties() {
let items = vec![
view("z", 80, "ST", "N", "L", "C"),
view("a", 80, "ST", "N", "L", "C"),
view("m", 80, "ST", "N", "L", "C"),
];
let p = apply_query(items, &OwnedItemQuery::default());
assert_eq!(ids(&p), ["a", "m", "z"]); // tie broken by owned id asc
}
}
+46 -19
View File
@@ -43,11 +43,12 @@ pub async fn refresh_npc_listings(
card_db: &CardDb, card_db: &CardDb,
event_defs: &[EventDefinition], event_defs: &[EventDefinition],
) -> AppResult<usize> { ) -> AppResult<usize> {
// Clean up expired and unsold listings // Clean up expired listings and previous NPC listings.
// Player-posted listings (is_npc = 0) are intentionally preserved.
sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')") sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')")
.execute(pool) .execute(pool)
.await?; .await?;
sqlx::query("DELETE FROM market_listings WHERE sold = 0") sqlx::query("DELETE FROM market_listings WHERE sold = 0 AND is_npc = 1")
.execute(pool) .execute(pool)
.await?; .await?;
@@ -107,8 +108,8 @@ pub async fn refresh_npc_listings(
for listing in &listings_to_insert { for listing in &listings_to_insert {
sqlx::query( sqlx::query(
"INSERT INTO market_listings \ "INSERT INTO market_listings \
(id, card_id, seller_name, price, listed_at, expires_at, sold) \ (id, card_id, seller_name, price, listed_at, expires_at, sold, is_npc) \
VALUES (?, ?, ?, ?, ?, ?, 0)", VALUES (?, ?, ?, ?, ?, ?, 0, 1)",
) )
.bind(&listing.id) .bind(&listing.id)
.bind(&listing.card_id) .bind(&listing.card_id)
@@ -140,12 +141,25 @@ pub async fn buy_listing(
.await? .await?
.ok_or_else(|| AppError::NotFound("listing not found or already sold".into()))?; .ok_or_else(|| AppError::NotFound("listing not found or already sold".into()))?;
club::spend_coins(pool, club_id, listing.price).await?; // Atomically claim the listing (flip sold 0->1) before charging, so two concurrent
// buyers cannot both mint the same card. If the debit then fails, release the claim.
sqlx::query("UPDATE market_listings SET sold = 1 WHERE id = ?") let claimed = sqlx::query("UPDATE market_listings SET sold = 1 WHERE id = ? AND sold = 0")
.bind(&listing.id) .bind(&listing.id)
.execute(pool) .execute(pool)
.await?; .await?
.rows_affected();
if claimed == 0 {
return Err(AppError::NotFound(
"listing not found or already sold".into(),
));
}
if let Err(e) = club::spend_coins(pool, club_id, listing.price).await {
let _ = sqlx::query("UPDATE market_listings SET sold = 0 WHERE id = ?")
.bind(&listing.id)
.execute(pool)
.await;
return Err(e);
}
let owned_id = Uuid::new_v4().to_string(); let owned_id = Uuid::new_v4().to_string();
sqlx::query( sqlx::query(
@@ -190,21 +204,31 @@ pub async fn sell_card(
club_id: &str, club_id: &str,
req: &SellCardRequest, req: &SellCardRequest,
) -> AppResult<i64> { ) -> AppResult<i64> {
let owned = sqlx::query_as::<_, crate::models::card::OwnedCard>( if req.price < 0 {
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \ return Err(AppError::BadRequest("price must be non-negative".into()));
chemistry_style, position_override, training_bonus \ }
FROM owned_cards WHERE id = ? AND club_id = ?",
) let owned = sqlx::query_as::<_, crate::models::card::OwnedCard>(&format!(
"{} WHERE id = ? AND club_id = ?",
crate::models::card::OWNED_CARD_SELECT
))
.bind(&req.owned_card_id) .bind(&req.owned_card_id)
.bind(club_id) .bind(club_id)
.fetch_optional(pool) .fetch_optional(pool)
.await? .await?
.ok_or_else(|| AppError::NotFound("owned card not found".into()))?; .ok_or_else(|| AppError::NotFound("owned card not found".into()))?;
sqlx::query("DELETE FROM owned_cards WHERE id = ?") // Atomically claim the card: guard the DELETE with the owner + rows_affected so two
// concurrent sells of the same card cannot both credit (double payout).
let deleted = sqlx::query("DELETE FROM owned_cards WHERE id = ? AND club_id = ?")
.bind(&req.owned_card_id) .bind(&req.owned_card_id)
.bind(club_id)
.execute(pool) .execute(pool)
.await?; .await?
.rows_affected();
if deleted == 0 {
return Err(AppError::NotFound("owned card not found".into()));
}
let coins = (req.price as f64 * 0.4) as i64; let coins = (req.price as f64 * 0.4) as i64;
let new_balance = club::add_coins(pool, club_id, coins).await?; let new_balance = club::add_coins(pool, club_id, coins).await?;
@@ -285,9 +309,10 @@ pub async fn get_listings_by_seller(
let with_cards = listings let with_cards = listings
.into_iter() .into_iter()
.filter_map(|l| { .filter_map(|l| {
card_db card_db.get(&l.card_id).map(|card| MarketListingWithCard {
.get(&l.card_id) listing: l,
.map(|card| MarketListingWithCard { listing: l, card: card.clone() }) card: card.clone(),
})
}) })
.collect(); .collect();
Ok(with_cards) Ok(with_cards)
@@ -303,7 +328,9 @@ pub async fn cancel_listing(pool: &Pool, club_id: &str, listing_id: &str) -> App
.bind(club_id) .bind(club_id)
.fetch_optional(pool) .fetch_optional(pool)
.await? .await?
.ok_or_else(|| AppError::NotFound(format!("listing '{listing_id}' not found or already sold")))?; .ok_or_else(|| {
AppError::NotFound(format!("listing '{listing_id}' not found or already sold"))
})?;
sqlx::query("DELETE FROM market_listings WHERE id = ?") sqlx::query("DELETE FROM market_listings WHERE id = ?")
.bind(listing_id) .bind(listing_id)
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -2,18 +2,25 @@ pub mod achievement;
pub mod card_db; pub mod card_db;
pub mod checkin; pub mod checkin;
pub mod club; pub mod club;
pub mod notification; pub mod consume;
pub mod draft; pub mod draft;
pub mod economy;
pub mod event; pub mod event;
pub mod fut_champs; pub mod fut_champs;
pub mod season; pub mod game_ext;
pub mod import;
pub mod instance_effect;
pub mod inventory;
pub mod market; pub mod market;
pub mod match_service; pub mod match_service;
pub mod notification;
pub mod objective; pub mod objective;
pub mod pack; pub mod pack;
pub mod profile; pub mod profile;
pub mod sbc; pub mod sbc;
pub mod season;
pub mod settings; pub mod settings;
pub mod squad; pub mod squad;
pub mod squad_rules;
pub mod statistics; pub mod statistics;
pub mod upgrades; pub mod upgrades;
+68 -4
View File
@@ -6,6 +6,7 @@ use crate::{
}, },
}; };
use anyhow::Context; use anyhow::Context;
use sqlx::{Sqlite, Transaction};
use std::path::Path; use std::path::Path;
use uuid::Uuid; use uuid::Uuid;
@@ -67,10 +68,7 @@ pub async fn increment_metric(
) -> AppResult<Vec<String>> { ) -> AppResult<Vec<String>> {
let mut completed_ids = Vec::new(); let mut completed_ids = Vec::new();
for def in defs for def in defs.iter().filter(|d| d.metric.as_str() == metric) {
.iter()
.filter(|d| format!("{:?}", d.metric).to_lowercase() == metric)
{
let existing = sqlx::query_as::<_, ObjectiveProgress>( 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 = ?" "SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?"
) )
@@ -123,6 +121,72 @@ pub async fn increment_metric(
Ok(completed_ids) Ok(completed_ids)
} }
/// Transaction-scoped [`increment_metric`] for the atomic match-completion path.
/// Same semantics, but every read/write runs inside the caller's transaction so
/// objective progress commits (or rolls back) together with the coins, XP, and
/// statistics of the same match. `now` is threaded so one match stamps a single
/// timestamp.
pub async fn increment_metric_tx(
tx: &mut Transaction<'_, Sqlite>,
profile_id: &str,
defs: &[ObjectiveDefinition],
metric: &str,
amount: i64,
now: &str,
) -> AppResult<Vec<String>> {
let mut completed_ids = Vec::new();
for def in defs.iter().filter(|d| d.metric.as_str() == 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 = ?"
)
.bind(profile_id)
.bind(&def.id)
.fetch_optional(&mut **tx)
.await?;
if let Some(prog) = existing {
if prog.completed {
continue;
}
let new_val = (prog.current + amount).min(def.target);
let now_complete = new_val >= def.target;
sqlx::query(
"UPDATE objective_progress SET current = ?, completed = ?, updated_at = ? WHERE id = ?"
)
.bind(new_val)
.bind(now_complete)
.bind(now)
.bind(&prog.id)
.execute(&mut **tx)
.await?;
if now_complete {
completed_ids.push(def.id.clone());
}
} else {
let new_val = amount.min(def.target);
let now_complete = new_val >= def.target;
let id = Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO objective_progress (id, profile_id, objective_id, current, completed, claimed, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?)"
)
.bind(&id)
.bind(profile_id)
.bind(&def.id)
.bind(new_val)
.bind(now_complete)
.bind(now)
.execute(&mut **tx)
.await?;
if now_complete {
completed_ids.push(def.id.clone());
}
}
}
Ok(completed_ids)
}
pub async fn claim_objective( pub async fn claim_objective(
pool: &Pool, pool: &Pool,
profile_id: &str, profile_id: &str,
+15 -6
View File
@@ -71,7 +71,16 @@ pub async fn open_pack(
.await? .await?
.ok_or_else(|| AppError::NotFound(format!("pack {pack_id} not found")))?; .ok_or_else(|| AppError::NotFound(format!("pack {pack_id} not found")))?;
if pack.opened { // Atomically claim the pack before minting any cards: only one concurrent opener
// flips opened 0->1, so a double-open cannot mint the reward twice (duplication).
let claimed =
sqlx::query("UPDATE packs SET opened = 1 WHERE id = ? AND club_id = ? AND opened = 0")
.bind(pack_id)
.bind(club_id)
.execute(pool)
.await?
.rows_affected();
if claimed == 0 {
return Err(AppError::BadRequest("pack already opened".into())); return Err(AppError::BadRequest("pack already opened".into()));
} }
@@ -90,8 +99,8 @@ pub async fn open_pack(
.all() .all()
.into_iter() .into_iter()
.filter(|c| { .filter(|c| {
let r = format!("{:?}", c.rarity).to_lowercase(); let r = c.rarity.as_str();
rarities.contains(&r) rarities.contains(&r.to_string())
}) })
.cloned() .cloned()
.collect() .collect()
@@ -124,11 +133,11 @@ pub async fn open_pack(
} }
} }
let card_ids_json = serde_json::to_string(&cards.iter().map(|c| &c.id).collect::<Vec<_>>()) let card_ids_json =
.unwrap_or_default(); serde_json::to_string(&cards.iter().map(|c| &c.id).collect::<Vec<_>>()).unwrap_or_default();
let now = chrono::Utc::now().to_rfc3339(); let now = chrono::Utc::now().to_rfc3339();
sqlx::query("UPDATE packs SET opened = 1, opened_cards = ?, opened_at = ? WHERE id = ?") sqlx::query("UPDATE packs SET opened_cards = ?, opened_at = ? WHERE id = ?")
.bind(&card_ids_json) .bind(&card_ids_json)
.bind(&now) .bind(&now)
.bind(pack_id) .bind(pack_id)
+27 -9
View File
@@ -5,33 +5,41 @@ use crate::{
}; };
use chrono::Utc; use chrono::Utc;
pub async fn get_active_profile(pool: &Pool) -> AppResult<Profile> { /// 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> {
sqlx::query_as::<_, Profile>( sqlx::query_as::<_, Profile>(
"SELECT id, username, level, xp, created_at, updated_at FROM profiles ORDER BY created_at ASC LIMIT 1" "SELECT id, username, level, xp, game_id, created_at, updated_at \
FROM profiles WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
) )
.bind(game_id)
.fetch_optional(pool) .fetch_optional(pool)
.await? .await?
.ok_or_else(|| AppError::NotFound("no profile exists; call POST /auth/local first".into())) .ok_or_else(|| AppError::NotFound("no profile exists; call POST /auth/local first".into()))
} }
pub async fn create_profile(pool: &Pool, username: &str) -> AppResult<Profile> { pub async fn create_profile(pool: &Pool, username: &str, game_id: &str) -> AppResult<Profile> {
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles") // 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)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
if existing > 0 { if existing > 0 {
return Err(AppError::Conflict( return Err(AppError::Conflict(
"a profile already exists; OpenFUT is single-player only".into(), "a profile already exists for this game; OpenFUT is single-player per game".into(),
)); ));
} }
let profile = Profile::new(username); let profile = Profile::new(username, game_id);
sqlx::query( sqlx::query(
"INSERT INTO profiles (id, username, level, xp, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)" "INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?)",
) )
.bind(&profile.id) .bind(&profile.id)
.bind(&profile.username) .bind(&profile.username)
.bind(profile.level) .bind(profile.level)
.bind(profile.xp) .bind(profile.xp)
.bind(&profile.game_id)
.bind(profile.created_at) .bind(profile.created_at)
.bind(profile.updated_at) .bind(profile.updated_at)
.execute(pool) .execute(pool)
@@ -59,7 +67,13 @@ pub async fn add_xp_with_levelup(
club_id: &str, club_id: &str,
xp_to_add: i64, xp_to_add: i64,
) -> AppResult<Vec<LevelUpEvent>> { ) -> AppResult<Vec<LevelUpEvent>> {
let profile = get_active_profile(pool).await?; 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 old_level = level_for_xp(profile.xp); let old_level = level_for_xp(profile.xp);
let new_total_xp = profile.xp + xp_to_add; let new_total_xp = profile.xp + xp_to_add;
let new_level = level_for_xp(new_total_xp); let new_level = level_for_xp(new_total_xp);
@@ -86,7 +100,11 @@ pub async fn add_xp_with_levelup(
} }
tracing::info!(profile_id, new_level = lvl, coins, "level up"); tracing::info!(profile_id, new_level = lvl, coins, "level up");
events.push(LevelUpEvent { new_level: lvl, coins_granted: coins, pack_granted: pack }); events.push(LevelUpEvent {
new_level: lvl,
coins_granted: coins,
pack_granted: pack,
});
} }
Ok(events) Ok(events)
+1169 -47
View File
File diff suppressed because it is too large Load Diff
+72 -35
View File
@@ -1,9 +1,9 @@
use crate::{ use crate::{
db::Pool, db::Pool,
error::AppResult, error::{AppError, AppResult},
models::season::{Season, SeasonEndSummary, SeasonHistoryEntry, SeasonResult}, models::season::{Season, SeasonEndSummary, SeasonHistoryEntry, SeasonResult},
services::{club, pack},
}; };
use sqlx::{Sqlite, Transaction};
use uuid::Uuid; use uuid::Uuid;
/// Get the current season for a profile, creating it if it doesn't exist. /// Get the current season for a profile, creating it if it doesn't exist.
@@ -20,7 +20,11 @@ pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult<Season> {
.bind(&now) .bind(&now)
.execute(pool) .execute(pool)
.await?; .await?;
Ok(fetch(pool, profile_id).await?.expect("just inserted")) fetch(pool, profile_id).await?.ok_or_else(|| {
AppError::Internal(anyhow::anyhow!(
"season row missing immediately after insert"
))
})
} }
async fn fetch(pool: &Pool, profile_id: &str) -> AppResult<Option<Season>> { async fn fetch(pool: &Pool, profile_id: &str) -> AppResult<Option<Season>> {
@@ -34,21 +38,34 @@ async fn fetch(pool: &Pool, profile_id: &str) -> AppResult<Option<Season>> {
Ok(s) Ok(s)
} }
/// Record a match result in the season; end the season if the quota is met. /// Record a match in Core's season model inside the caller's transaction,
/// ending the season when the quota is met.
/// ///
/// Returns the updated season and an optional end-of-season summary. /// Mirrors [`record_match`] but every write — the season row, the rollover, the
pub async fn record_match( /// end-of-season coin and pack award, and the history entry — commits or rolls
pool: &Pool, /// back with the match that caused it. Creates the season row if absent, so a
/// first match does not need a separate call.
pub async fn record_match_tx(
tx: &mut Transaction<'_, Sqlite>,
club_id: &str, club_id: &str,
profile_id: &str, profile_id: &str,
outcome: &str, outcome: &str,
) -> AppResult<(Season, Option<SeasonEndSummary>)> { now: &str,
) -> AppResult<Option<SeasonEndSummary>> {
sqlx::query(
"INSERT OR IGNORE INTO seasons (profile_id, division, season_number, season_points, \
matches_played, wins, draws, losses, started_at) VALUES (?, 5, 1, 0, 0, 0, 0, 0, ?)",
)
.bind(profile_id)
.bind(now)
.execute(&mut **tx)
.await?;
let points = match outcome { let points = match outcome {
"win" => 3, "win" => 3,
"draw" => 1, "draw" => 1,
_ => 0, _ => 0,
}; };
sqlx::query( sqlx::query(
"UPDATE seasons SET \ "UPDATE seasons SET \
season_points = season_points + ?, \ season_points = season_points + ?, \
@@ -63,28 +80,28 @@ pub async fn record_match(
.bind(outcome) .bind(outcome)
.bind(outcome) .bind(outcome)
.bind(profile_id) .bind(profile_id)
.execute(pool) .execute(&mut **tx)
.await?; .await?;
let season = fetch(pool, profile_id).await?.expect("season must exist"); let season = fetch_tx(tx, profile_id).await?.ok_or_else(|| {
AppError::Internal(anyhow::anyhow!(
"season row missing after record_match_tx update"
))
})?;
if !season.is_complete() { if !season.is_complete() {
return Ok((season, None)); return Ok(None);
} }
// Season complete — calculate result and start next
let result = season.end_result(); let result = season.end_result();
let old_div = season.division; let old_div = season.division;
let coins = season.season_reward_coins(); let coins = season.season_reward_coins();
let pack_id = season.season_reward_pack(); let pack_id = season.season_reward_pack();
let new_div = match result { let new_div = match result {
SeasonResult::Promoted => (old_div - 1).max(1), SeasonResult::Promoted => (old_div - 1).max(1),
SeasonResult::Relegated => (old_div + 1).min(10), SeasonResult::Relegated => (old_div + 1).min(10),
SeasonResult::Maintained => old_div, SeasonResult::Maintained => old_div,
}; };
let new_season = season.season_number + 1; let new_season = season.season_number + 1;
let now = chrono::Utc::now().to_rfc3339();
sqlx::query( sqlx::query(
"UPDATE seasons SET division = ?, season_number = ?, season_points = 0, \ "UPDATE seasons SET division = ?, season_number = ?, season_points = 0, \
@@ -93,33 +110,46 @@ pub async fn record_match(
) )
.bind(new_div) .bind(new_div)
.bind(new_season) .bind(new_season)
.bind(&now) .bind(now)
.bind(profile_id) .bind(profile_id)
.execute(pool) .execute(&mut **tx)
.await?; .await?;
// Grant rewards if coins > 0 {
club::add_coins(pool, club_id, coins).await?; let credited =
sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?")
.bind(coins)
.bind(now)
.bind(club_id)
.execute(&mut **tx)
.await?;
if credited.rows_affected() != 1 {
return Err(AppError::NotFound("club not found".into()));
}
}
if let Some(pack_def) = pack_id { if let Some(pack_def) = pack_id {
let dummy_pack_id = Uuid::new_v4().to_string(); sqlx::query(
// Grant via pack system so it shows in inventory "INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, 0, ?)",
let _ = dummy_pack_id; // will use grant_pack instead )
pack::grant_pack(pool, club_id, pack_def).await?; .bind(Uuid::new_v4().to_string())
.bind(club_id)
.bind(pack_def)
.bind(now)
.execute(&mut **tx)
.await?;
} }
// Persist history entry before rolling over
let result_str = match result { let result_str = match result {
SeasonResult::Promoted => "promoted", SeasonResult::Promoted => "promoted",
SeasonResult::Maintained => "maintained", SeasonResult::Maintained => "maintained",
SeasonResult::Relegated => "relegated", SeasonResult::Relegated => "relegated",
}; };
let history_id = Uuid::new_v4().to_string(); sqlx::query(
let _ = sqlx::query(
"INSERT INTO season_history (id, profile_id, season_number, division, season_points, \ "INSERT INTO season_history (id, profile_id, season_number, division, season_points, \
wins, draws, losses, result, new_division, coins_awarded, pack_awarded, ended_at) \ wins, draws, losses, result, new_division, coins_awarded, pack_awarded, ended_at) \
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
) )
.bind(&history_id) .bind(Uuid::new_v4().to_string())
.bind(profile_id) .bind(profile_id)
.bind(season.season_number) .bind(season.season_number)
.bind(old_div) .bind(old_div)
@@ -131,21 +161,28 @@ pub async fn record_match(
.bind(new_div) .bind(new_div)
.bind(coins) .bind(coins)
.bind(pack_id) .bind(pack_id)
.bind(&now) .bind(now)
.execute(pool) .execute(&mut **tx)
.await; .await?;
let summary = SeasonEndSummary { Ok(Some(SeasonEndSummary {
result, result,
old_division: old_div, old_division: old_div,
new_division: new_div, new_division: new_div,
new_season_number: new_season, new_season_number: new_season,
coins_awarded: coins, coins_awarded: coins,
pack_awarded: pack_id.map(String::from), pack_awarded: pack_id.map(String::from),
}; }))
}
let updated = fetch(pool, profile_id).await?.expect("season must exist"); async fn fetch_tx(tx: &mut Transaction<'_, Sqlite>, profile_id: &str) -> AppResult<Option<Season>> {
Ok((updated, Some(summary))) Ok(sqlx::query_as::<_, Season>(
"SELECT profile_id, division, season_number, season_points, matches_played, \
wins, draws, losses, started_at FROM seasons WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(&mut **tx)
.await?)
} }
/// Return past seasons for a profile, newest first (max 20). /// Return past seasons for a profile, newest first (max 20).
+904 -42
View File
File diff suppressed because it is too large Load Diff
+380
View File
@@ -0,0 +1,380 @@
//! Squad evaluation, behind a game-rules boundary.
//!
//! # Why this is a trait and not a function in `squad.rs`
//!
//! Chemistry, rating and star rating are **game-specific**. FUT chemistry
//! changed substantially between FIFA generations, so a single formula
//! compiled into generic Core would quietly make Core a FIFA-something server.
//! Core is allowed to understand that a squad *has* an evaluation; it is not
//! allowed to know how any particular game computes one.
//!
//! ```text
//! Core owns the squad, slots, items, persistence
//! | and the SEMANTIC concept of an evaluation
//! v
//! SquadRules how a specific game computes it
//! |
//! +-- DefaultSquadRules OpenFUT's own rules (the implementation that
//! | already existed in Core)
//! +-- Fifa17SquadRules NOT YET WRITTEN. The FIFA 17 algorithm is not
//! proven, and inventing one would be worse than
//! having none.
//! ```
//!
//! # Pure data in, evaluation out
//!
//! Rules take a [`SquadSnapshot`] — already resolved by Core from the database
//! — rather than a pool and a card database. That keeps every rules
//! implementation synchronous, dependency-free and testable without fixtures,
//! and it stops a game's rules from reaching into Core's storage.
//!
//! # Client-reported values are not evaluations
//!
//! FIFA 17 sends its own `chemistry`, `rating` and `starRating` on every squad
//! save. Those are observations about what the client believes, captured for
//! shadow validation, and they are deliberately a *different type* from
//! [`SquadEvaluation`] so no later code can pass one where the other belongs.
use serde::{Deserialize, Serialize};
/// One player in a squad, reduced to the attributes rules are allowed to see.
///
/// Deliberately not `OwnedCard` + `CardDefinition`: rules should not be able to
/// reach storage identifiers, loan state or acquisition history.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SquadPlayerCard {
/// Core's owned-card id. Present so an evaluation can attribute per-player
/// results; rules must not interpret its contents.
pub owned_card_id: String,
pub card_id: String,
pub name: String,
pub overall: u8,
pub position: String,
pub nation: String,
pub league: String,
pub club: String,
/// Slot this player occupies, in Core's numbering.
pub slot: i64,
pub on_bench: bool,
}
/// Everything a rules implementation may consider.
#[derive(Debug, Clone, Default)]
pub struct SquadSnapshot {
pub formation: String,
pub players: Vec<SquadPlayerCard>,
}
impl SquadSnapshot {
pub fn starters(&self) -> impl Iterator<Item = &SquadPlayerCard> {
self.players.iter().filter(|p| !p.on_bench)
}
}
/// The semantic result Core understands.
///
/// `chemistry` has no fixed scale here on purpose — `chemistry_max` travels
/// with it, because a later game may not use 100.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SquadEvaluation {
pub chemistry: i64,
pub chemistry_max: i64,
pub rating: i64,
pub star_rating: i64,
/// Per-player detail, for UIs and for diagnosing a rules mismatch.
pub players: Vec<PlayerEvaluation>,
/// Which rules produced this, so a stored or logged evaluation is never
/// ambiguous about its own provenance.
pub rules: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlayerEvaluation {
pub owned_card_id: String,
pub chemistry: i64,
pub detail: Vec<(String, i64)>,
}
/// What a game client claimed about a squad it sent.
///
/// **Never canonical.** A separate type from [`SquadEvaluation`] specifically so
/// that assigning one to the other does not compile. A modified client can put
/// anything here; OpenFUT has no independent knowledge of what it means until
/// its own rules run.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ClientReportedEvaluation {
pub client_reported_chemistry: Option<i64>,
pub client_reported_rating: Option<i64>,
pub client_reported_star_rating: Option<i64>,
}
/// Result of comparing what the client claimed against what the server derived.
///
/// A mismatch is **not** silently reconciled in either direction: the server's
/// value stands as canonical and the disagreement is reported so the rules
/// model can be investigated against the exact squad that produced it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvaluationComparison {
pub field: String,
pub client: i64,
pub server: i64,
}
impl ClientReportedEvaluation {
/// Fields where the client and the server disagree. Empty means agreement
/// on every field the client actually sent.
pub fn compare(&self, server: &SquadEvaluation) -> Vec<EvaluationComparison> {
let mut out = Vec::new();
let mut check = |field: &str, client: Option<i64>, srv: i64| {
if let Some(c) = client {
if c != srv {
out.push(EvaluationComparison {
field: field.to_string(),
client: c,
server: srv,
});
}
}
};
check(
"chemistry",
self.client_reported_chemistry,
server.chemistry,
);
check("rating", self.client_reported_rating, server.rating);
check(
"star_rating",
self.client_reported_star_rating,
server.star_rating,
);
out
}
}
/// How a specific game evaluates a squad.
pub trait SquadRules: Send + Sync {
/// Stable identifier recorded in [`SquadEvaluation::rules`].
fn name(&self) -> &'static str;
fn evaluate(&self, snapshot: &SquadSnapshot) -> SquadEvaluation;
}
/// OpenFUT's own rules — the implementation that already lived in Core.
///
/// Moved here unchanged in behaviour rather than rewritten: it is the default
/// for clients that have no game-specific rules, and changing its numbers while
/// relocating it would have made the move unreviewable.
///
/// Link scoring: club +3 each (max 6), league +1 each (max 4), nation +1 each
/// (max 3), per player capped at 10, team total capped at 100.
pub struct DefaultSquadRules;
impl SquadRules for DefaultSquadRules {
fn name(&self) -> &'static str {
"openfut-default-v2"
}
fn evaluate(&self, snapshot: &SquadSnapshot) -> SquadEvaluation {
let starters: Vec<&SquadPlayerCard> = snapshot.starters().collect();
let mut players = Vec::with_capacity(starters.len());
let mut total: i64 = 0;
for (i, p) in starters.iter().enumerate() {
let count = |f: fn(&SquadPlayerCard) -> &String, v: &String| {
starters
.iter()
.enumerate()
.filter(|(j, o)| *j != i && f(o) == v)
.count() as i64
};
let club_links = count(|c| &c.club, &p.club);
let league_links = count(|c| &c.league, &p.league);
let nation_links = count(|c| &c.nation, &p.nation);
let club_pts = (club_links * 3).min(6);
let league_pts = league_links.min(4);
let nation_pts = nation_links.min(3);
let chem = (club_pts + league_pts + nation_pts).min(10);
total += chem;
players.push(PlayerEvaluation {
owned_card_id: p.owned_card_id.clone(),
chemistry: chem,
detail: vec![
("club_links".into(), club_links),
("league_links".into(), league_links),
("nation_links".into(), nation_links),
("club_pts".into(), club_pts),
("league_pts".into(), league_pts),
("nation_pts".into(), nation_pts),
],
});
}
// Mean overall of the starters, rounded down. Empty squad rates 0
// rather than dividing by zero.
let rating = if starters.is_empty() {
0
} else {
starters.iter().map(|p| p.overall as i64).sum::<i64>() / starters.len() as i64
};
SquadEvaluation {
chemistry: total.min(100),
chemistry_max: 100,
rating,
// 0-5 from the rating band. Coarse on purpose: this is OpenFUT's
// own presentation value, not a reconstruction of any game's.
star_rating: match rating {
0 => 0,
1..=64 => 1,
65..=74 => 2,
75..=81 => 3,
82..=87 => 4,
_ => 5,
},
players,
rules: self.name().to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn p(slot: i64, club: &str, league: &str, nation: &str, overall: u8) -> SquadPlayerCard {
SquadPlayerCard {
owned_card_id: format!("owned-{slot}"),
card_id: format!("card-{slot}"),
name: format!("P{slot}"),
overall,
position: "ST".into(),
nation: nation.into(),
league: league.into(),
club: club.into(),
slot,
on_bench: false,
}
}
#[test]
fn an_empty_squad_evaluates_without_dividing_by_zero() {
let e = DefaultSquadRules.evaluate(&SquadSnapshot::default());
assert_eq!(e.chemistry, 0);
assert_eq!(e.rating, 0);
assert_eq!(e.star_rating, 0);
assert!(e.players.is_empty());
}
#[test]
fn bench_players_do_not_contribute() {
let mut snap = SquadSnapshot {
formation: "4-4-2".into(),
players: vec![p(0, "A", "L", "N", 80), p(1, "A", "L", "N", 80)],
};
let with_both = DefaultSquadRules.evaluate(&snap);
snap.players[1].on_bench = true;
let with_bench = DefaultSquadRules.evaluate(&snap);
assert!(
with_bench.chemistry < with_both.chemistry,
"a benched team-mate must not create links: {with_bench:?}"
);
assert_eq!(with_bench.players.len(), 1);
}
#[test]
fn links_are_capped_per_category_and_per_player() {
// Eleven identical players: club links alone would be 30 pts uncapped.
let players: Vec<_> = (0..11).map(|i| p(i, "A", "L", "N", 90)).collect();
let e = DefaultSquadRules.evaluate(&SquadSnapshot {
formation: "4-4-2".into(),
players,
});
for pe in &e.players {
assert_eq!(pe.chemistry, 10, "per-player cap is 10: {pe:?}");
}
assert_eq!(e.chemistry, 100);
assert_eq!(e.chemistry_max, 100);
}
#[test]
fn team_chemistry_is_capped_at_the_maximum() {
// 15 starters would total 150 uncapped.
let players: Vec<_> = (0..15).map(|i| p(i, "A", "L", "N", 90)).collect();
let e = DefaultSquadRules.evaluate(&SquadSnapshot {
formation: "x".into(),
players,
});
assert_eq!(e.chemistry, 100);
}
#[test]
fn unrelated_players_earn_no_chemistry() {
let players = vec![
p(0, "A", "L1", "N1", 80),
p(1, "B", "L2", "N2", 80),
p(2, "C", "L3", "N3", 80),
];
let e = DefaultSquadRules.evaluate(&SquadSnapshot {
formation: "x".into(),
players,
});
assert_eq!(e.chemistry, 0);
assert_eq!(e.rating, 80);
}
#[test]
fn the_evaluation_names_the_rules_that_produced_it() {
let e = DefaultSquadRules.evaluate(&SquadSnapshot::default());
assert_eq!(e.rules, "openfut-default-v2");
assert_eq!(DefaultSquadRules.name(), "openfut-default-v2");
}
/// The comparison must report disagreement rather than reconcile it.
#[test]
fn a_client_that_disagrees_is_reported_not_reconciled() {
let server = DefaultSquadRules.evaluate(&SquadSnapshot {
formation: "x".into(),
players: vec![p(0, "A", "L", "N", 80)],
});
let claimed = ClientReportedEvaluation {
client_reported_chemistry: Some(52),
client_reported_rating: Some(server.rating),
client_reported_star_rating: None,
};
let diff = claimed.compare(&server);
assert_eq!(diff.len(), 1, "{diff:?}");
assert_eq!(diff[0].field, "chemistry");
assert_eq!(diff[0].client, 52);
assert_eq!(diff[0].server, server.chemistry);
// And the server's own value is untouched by the comparison.
assert_eq!(server.chemistry, 0);
}
/// A field the client did not send cannot disagree.
#[test]
fn absent_client_fields_are_not_treated_as_zero() {
let server = DefaultSquadRules.evaluate(&SquadSnapshot {
formation: "x".into(),
players: vec![p(0, "A", "L", "N", 80)],
});
assert!(ClientReportedEvaluation::default()
.compare(&server)
.is_empty());
}
#[test]
fn agreement_reports_nothing() {
let server = DefaultSquadRules.evaluate(&SquadSnapshot {
formation: "x".into(),
players: vec![p(0, "A", "L", "N", 80)],
});
let claimed = ClientReportedEvaluation {
client_reported_chemistry: Some(server.chemistry),
client_reported_rating: Some(server.rating),
client_reported_star_rating: Some(server.star_rating),
};
assert!(claimed.compare(&server).is_empty());
}
}
+93 -1
View File
@@ -1,6 +1,7 @@
use crate::{db::Pool, error::AppResult, models::statistics::Statistics}; use crate::{db::Pool, error::AppResult, models::statistics::Statistics};
use sqlx::{Sqlite, Transaction};
const SELECT_STATS: &str = "SELECT profile_id, matches_played, matches_won, matches_drawn, matches_lost, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, win_streak, best_win_streak, updated_at FROM statistics WHERE profile_id = ?"; const SELECT_STATS: &str = "SELECT profile_id, matches_played, matches_won, matches_drawn, matches_lost, matches_dnf, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, win_streak, best_win_streak, updated_at FROM statistics WHERE profile_id = ?";
pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult<Statistics> { pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult<Statistics> {
if let Some(s) = sqlx::query_as::<_, Statistics>(SELECT_STATS) if let Some(s) = sqlx::query_as::<_, Statistics>(SELECT_STATS)
@@ -77,6 +78,77 @@ pub async fn record_match(
Ok(()) Ok(())
} }
/// Record a completed match within an existing transaction (the atomic
/// match-completion path). `outcome` is `win` | `draw` | `loss` | `dnf`. A DNF
/// (abandon/quit) increments its own bucket — never `matches_lost` — and, like a
/// loss, resets the win streak. All-or-nothing with the caller's transaction; it
/// never commits on its own, so a later failure rolls this back with everything
/// else.
pub async fn record_match_tx(
tx: &mut Transaction<'_, Sqlite>,
profile_id: &str,
outcome: &str,
goals_for: i64,
goals_against: i64,
coins: i64,
now: &str,
) -> AppResult<()> {
sqlx::query("INSERT OR IGNORE INTO statistics (profile_id, updated_at) VALUES (?, ?)")
.bind(profile_id)
.bind(now)
.execute(&mut **tx)
.await?;
let current_streak: i64 =
sqlx::query_scalar("SELECT win_streak FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_one(&mut **tx)
.await?;
let (w, d, l, dnf) = match outcome {
"win" => (1i64, 0i64, 0i64, 0i64),
"draw" => (0, 1, 0, 0),
"dnf" => (0, 0, 0, 1),
_ => (0, 0, 1, 0),
};
let new_streak = if outcome == "win" {
current_streak + 1
} else {
0
};
sqlx::query(
"UPDATE statistics SET
matches_played = matches_played + 1,
matches_won = matches_won + ?,
matches_drawn = matches_drawn + ?,
matches_lost = matches_lost + ?,
matches_dnf = matches_dnf + ?,
goals_scored = goals_scored + ?,
goals_conceded = goals_conceded + ?,
total_coins_earned = total_coins_earned + ?,
win_streak = ?,
best_win_streak = MAX(best_win_streak, ?),
updated_at = ?
WHERE profile_id = ?",
)
.bind(w)
.bind(d)
.bind(l)
.bind(dnf)
.bind(goals_for)
.bind(goals_against)
.bind(coins)
.bind(new_streak)
.bind(new_streak)
.bind(now)
.bind(profile_id)
.execute(&mut **tx)
.await?;
Ok(())
}
pub async fn increment_packs_opened(pool: &Pool, profile_id: &str) -> AppResult<()> { pub async fn increment_packs_opened(pool: &Pool, profile_id: &str) -> AppResult<()> {
get_or_create(pool, profile_id).await?; get_or_create(pool, profile_id).await?;
let now = chrono::Utc::now().to_rfc3339(); let now = chrono::Utc::now().to_rfc3339();
@@ -121,6 +193,26 @@ pub async fn record_position_goals(
Ok(()) Ok(())
} }
/// Transaction-scoped [`record_position_goals`] for the atomic match-completion
/// path.
pub async fn record_position_goals_tx(
tx: &mut Transaction<'_, Sqlite>,
profile_id: &str,
positions: &[String],
) -> AppResult<()> {
for position in positions {
sqlx::query(
"INSERT INTO position_goals (profile_id, position, goals) VALUES (?, ?, 1) \
ON CONFLICT(profile_id, position) DO UPDATE SET goals = goals + 1",
)
.bind(profile_id)
.bind(position)
.execute(&mut **tx)
.await?;
}
Ok(())
}
pub async fn get_position_goals(pool: &Pool, profile_id: &str) -> AppResult<Vec<(String, i64)>> { pub async fn get_position_goals(pool: &Pool, profile_id: &str) -> AppResult<Vec<(String, i64)>> {
let rows: Vec<(String, i64)> = sqlx::query_as( let rows: Vec<(String, i64)> = sqlx::query_as(
"SELECT position, goals FROM position_goals WHERE profile_id = ? ORDER BY goals DESC", "SELECT position, goals FROM position_goals WHERE profile_id = ? ORDER BY goals DESC",
+4 -11
View File
@@ -1,24 +1,17 @@
use crate::{ use crate::{
db::Pool, db::Pool,
error::{AppError, AppResult}, error::{AppError, AppResult},
models::card::OwnedCard, models::card::{OwnedCard, OWNED_CARD_SELECT},
models::chemistry_style::ChemistryStyle, models::chemistry_style::ChemistryStyle,
}; };
const OWNED_CARD_SELECT: &str =
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
chemistry_style, position_override, training_bonus \
FROM owned_cards";
pub const MAX_TRAINING_BONUS: i64 = 3; pub const MAX_TRAINING_BONUS: i64 = 3;
/// Cost in coins to change a player's position. /// Cost in coins to change a player's position.
pub const POSITION_CHANGE_COST: i64 = 500; pub const POSITION_CHANGE_COST: i64 = 500;
async fn fetch_owned(pool: &Pool, owned_card_id: &str, club_id: &str) -> AppResult<OwnedCard> { async fn fetch_owned(pool: &Pool, owned_card_id: &str, club_id: &str) -> AppResult<OwnedCard> {
sqlx::query_as::<_, OwnedCard>(&format!( sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"))
"{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"
))
.bind(owned_card_id) .bind(owned_card_id)
.bind(club_id) .bind(club_id)
.fetch_optional(pool) .fetch_optional(pool)
@@ -64,8 +57,8 @@ pub async fn change_position(
new_position: &str, new_position: &str,
) -> AppResult<OwnedCard> { ) -> AppResult<OwnedCard> {
let valid_positions = [ let valid_positions = [
"GK", "RB", "LB", "CB", "RWB", "LWB", "CDM", "CM", "CAM", "RM", "LM", "RW", "LW", "GK", "RB", "LB", "CB", "RWB", "LWB", "CDM", "CM", "CAM", "RM", "LM", "RW", "LW", "CF",
"CF", "ST", "ST",
]; ];
if !valid_positions.contains(&new_position) { if !valid_positions.contains(&new_position) {
return Err(AppError::BadRequest(format!( return Err(AppError::BadRequest(format!(
+65
View File
@@ -0,0 +1,65 @@
//! 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
@@ -0,0 +1,104 @@
//! 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
@@ -0,0 +1,181 @@
//! FIFA 17 development content pack + ownership seed (Commit 5).
//!
//! Proves: the dev pack is opt-in and isolated from default content; the seed
//! creates a `game_id=fifa17` profile/club and grants real Core `OwnedCard`s
//! (never FIFA wire ids); it is idempotent and leaves the default profile alone;
//! and the seeded inventory can exercise the retail `/club` filter + pagination.
use openfut_core::db::Pool;
use openfut_core::services::card_db::CardDb;
async fn pool() -> Pool {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("in-memory sqlite");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrations");
pool
}
fn dev_card_db() -> CardDb {
let mut db = CardDb::load("data").expect("default cards");
db.load_game_dev("data", "fifa17").expect("dev pack");
db
}
// ── Content isolation ────────────────────────────────────────────────────────
#[test]
fn default_load_never_contains_dev_pack() {
// The default global loader reads only data/cards — the dev pack under
// data/games/fifa17/dev must be invisible unless explicitly requested.
let default = CardDb::load("data").expect("default cards");
let leaked: Vec<_> = default
.cards
.keys()
.filter(|k| k.starts_with("fifa17_"))
.collect();
assert!(
leaked.is_empty(),
"default content must not include FIFA17 dev cards: {leaked:?}"
);
assert!(
!default.cards.is_empty(),
"default synthetic catalogue still loads"
);
}
#[test]
fn opt_in_load_adds_dev_pack_only() {
let default_n = CardDb::load("data").unwrap().cards.len();
let db = dev_card_db();
let dev: Vec<_> = db
.cards
.keys()
.filter(|k| k.starts_with("fifa17_"))
.collect();
assert_eq!(dev.len(), 32, "the curated dev pack is 32 definitions");
assert_eq!(
db.cards.len(),
default_n + 32,
"dev pack is additive; default content unchanged"
);
}
#[test]
fn dev_definitions_carry_semantic_names_not_raw_ids() {
let db = dev_card_db();
for c in db.cards.values().filter(|c| c.id.starts_with("fifa17_")) {
// Semantic Core fields are names, never raw FIFA numeric entity ids.
assert!(
c.nation.parse::<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");
}
+479
View File
@@ -0,0 +1,479 @@
//! 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::models::card::ContentKind;
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(),
content_kind: ContentKind::Player,
quantity: None,
})
.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(),
content_kind: ContentKind::Player,
quantity: None,
});
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:#}");
}
/// A profile import is once-only, so a taxonomy fix cannot arrive by
/// re-importing: the same fingerprint no-ops and a different one is refused.
/// Every pre-taxonomy import therefore left coaches, kits and consumables
/// durably recorded as players — wrong in the ownership authority even where a
/// catalog-driven wire still looked right.
#[tokio::test]
async fn reclassify_corrects_already_imported_rows_and_is_idempotent() {
use openfut_core::services::import::{
reclassify_owned_content, ContentKindAssignment, ReclassifyRequest,
};
let pool = fresh_pool().await;
let db = CardDb::load("data").unwrap();
let ids = valid_ids(3);
// Imported before the taxonomy existed: everything landed as `player`.
let ow = owned(&ids);
let req = request("g_reclass", "fp-reclass", ow, None);
apply_profile_import(&pool, &db, &req)
.await
.expect("import");
let kind_of = |card: String| {
let pool = pool.clone();
async move {
sqlx::query_scalar::<_, String>(
"SELECT content_kind FROM owned_cards WHERE card_id = ?",
)
.bind(card)
.fetch_one(&pool)
.await
.unwrap()
}
};
assert_eq!(kind_of(ids[0].clone()).await, "player");
let rc = ReclassifyRequest {
game_id: "g_reclass".into(),
dry_run: false,
assignments: vec![
ContentKindAssignment {
card_id: ids[0].clone(),
content_kind: ContentKind::Staff,
},
ContentKindAssignment {
card_id: ids[1].clone(),
content_kind: ContentKind::Consumable,
},
ContentKindAssignment {
card_id: "fifa17_definition_nobody_owns".into(),
content_kind: ContentKind::Kit,
},
],
};
let out = reclassify_owned_content(&pool, &rc)
.await
.expect("reclassify");
assert_eq!(out.updated, 2);
assert_eq!(out.unchanged, 0);
assert_eq!(
out.unmatched_definitions,
vec!["fifa17_definition_nobody_owns".to_string()],
"an assignment nobody owns is reported, never invented"
);
assert_eq!(kind_of(ids[0].clone()).await, "staff");
assert_eq!(kind_of(ids[1].clone()).await, "consumable");
// Untouched definitions keep their kind.
assert_eq!(kind_of(ids[2].clone()).await, "player");
// Rerunning converges: nothing left to change.
let again = reclassify_owned_content(&pool, &rc).await.expect("rerun");
assert_eq!(again.updated, 0);
assert_eq!(again.unchanged, 2);
}
/// Reclassification is scoped to one game, so a shared database cannot have
/// another game's identically-named definition rewritten underneath it.
#[tokio::test]
async fn reclassify_never_crosses_a_game_boundary() {
use openfut_core::services::import::{
reclassify_owned_content, ContentKindAssignment, ReclassifyRequest,
};
let pool = fresh_pool().await;
let db = CardDb::load("data").unwrap();
let ids = valid_ids(2);
apply_profile_import(&pool, &db, &request("g_a", "fp-a", owned(&ids), None))
.await
.expect("import a");
// Same definitions, but owned-item ids are globally unique.
let mut b_owned = owned(&ids);
for o in &mut b_owned {
o.owned_item_id = format!("b-{}", o.owned_item_id);
}
apply_profile_import(&pool, &db, &request("g_b", "fp-b", b_owned, None))
.await
.expect("import b");
let out = reclassify_owned_content(
&pool,
&ReclassifyRequest {
game_id: "g_a".into(),
dry_run: false,
assignments: vec![ContentKindAssignment {
card_id: ids[0].clone(),
content_kind: ContentKind::Kit,
}],
},
)
.await
.expect("reclassify");
assert_eq!(out.updated, 1, "only game A's copy");
let kinds: Vec<String> = sqlx::query_scalar(
"SELECT o.content_kind FROM owned_cards o \
JOIN clubs c ON c.id = o.club_id JOIN profiles p ON p.id = c.profile_id \
WHERE p.game_id = 'g_b' AND o.card_id = ?",
)
.bind(&ids[0])
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(kinds, vec!["player".to_string()], "game B untouched");
}
/// A dry run must report the SAME counts a real run would, and leave the
/// database byte-for-byte unchanged. It runs the real UPDATEs and rolls back, so
/// the numbers are measured rather than predicted — which is the only reason an
/// operator can trust them before touching a frozen production database.
#[tokio::test]
async fn reclassify_dry_run_reports_the_real_counts_and_commits_nothing() {
use openfut_core::services::import::{
reclassify_owned_content, ContentKindAssignment, ReclassifyRequest,
};
let pool = fresh_pool().await;
let db = CardDb::load("data").unwrap();
let ids = valid_ids(3);
apply_profile_import(&pool, &db, &request("g_dry", "fp-dry", owned(&ids), None))
.await
.expect("import");
let kinds = || {
let pool = pool.clone();
async move {
sqlx::query_scalar::<_, String>("SELECT content_kind FROM owned_cards ORDER BY card_id")
.fetch_all(&pool)
.await
.unwrap()
}
};
let before = kinds().await;
let mut rc = ReclassifyRequest {
game_id: "g_dry".into(),
dry_run: true,
assignments: vec![
ContentKindAssignment {
card_id: ids[0].clone(),
content_kind: ContentKind::Staff,
},
ContentKindAssignment {
card_id: ids[1].clone(),
content_kind: ContentKind::Consumable,
},
],
};
let dry = reclassify_owned_content(&pool, &rc).await.expect("dry run");
assert!(dry.dry_run);
assert_eq!(dry.updated, 2);
assert_eq!(
dry.updated_by_kind,
[("consumable".to_string(), 1), ("staff".to_string(), 1)]
.into_iter()
.collect(),
"the per-kind shape is what an operator sanity-checks"
);
assert_eq!(kinds().await, before, "a dry run commits NOTHING");
// The real run then reports exactly what the dry run promised.
rc.dry_run = false;
let real = reclassify_owned_content(&pool, &rc)
.await
.expect("real run");
assert!(!real.dry_run);
assert_eq!(real.updated, dry.updated);
assert_eq!(real.updated_by_kind, dry.updated_by_kind);
assert_ne!(kinds().await, before, "the real run DID commit");
}
+1913 -135
View File
File diff suppressed because it is too large Load Diff
+365
View File
@@ -0,0 +1,365 @@
//! Owned-content model migrations (0025 content_kind/quantity, 0026
//! club_active_items, 0027 consumable_applications, 0028 contract_matches).
//!
//! Two things must hold on a DB that already contains real ownership:
//! * every pre-existing owned row survives and reads back as a `player` with no
//! stack size and no tracked contract (the band is a pure widening, never a
//! rewrite and never a backfill of someone else's default);
//! * every existing kit designation lands in `club_active_items` under its
//! generalised slot token, and the old table + trigger are gone.
//!
//! 0028 additionally must NOT be a table rebuild: `owned_cards` carries 0026's
//! `clear_club_active_item_before_transfer` trigger, and a DROP/recreate would
//! take it along silently. The trigger assertions below therefore run AFTER the
//! whole band, not just after 0026.
//!
//! The first is proved against a COPY of a real populated club snapshot (1986
//! owned rows) when `OPENFUT_CORE_SNAPSHOT_DB` points at one; the second is
//! proved by staging a DB at migration 0025, writing 0024-era kit rows, and then
//! letting the remaining migrations run.
use std::borrow::Cow;
use openfut_core::models::card::{ActiveSlot, ContentKind};
use sqlx::migrate::Migrator;
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::{Row, SqlitePool};
const OWNED_CONTENT_MIGRATION: i64 = 25;
async fn pool_for(path: &std::path::Path) -> SqlitePool {
let opts = sqlx::sqlite::SqliteConnectOptions::new()
.filename(path)
.create_if_missing(true)
.foreign_keys(true);
SqlitePoolOptions::new()
.max_connections(1)
.connect_with(opts)
.await
.unwrap_or_else(|e| panic!("open {}: {e}", path.display()))
}
/// The full migrator, truncated after `version`. Used to stage a DB in the state
/// it had BEFORE the migrations under test, so their data carry-over is exercised
/// on rows that really pre-date them.
fn migrator_upto(version: i64) -> Migrator {
let full = sqlx::migrate!("./migrations");
let subset: Vec<_> = full
.iter()
.filter(|m| m.version < version)
.cloned()
.collect();
Migrator {
migrations: Cow::Owned(subset),
ignore_missing: true,
locking: true,
}
}
async fn table_exists(pool: &SqlitePool, name: &str) -> bool {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?")
.bind(name)
.fetch_one(pool)
.await
.unwrap()
> 0
}
async fn trigger_names(pool: &SqlitePool) -> Vec<String> {
sqlx::query_scalar::<_, String>(
"SELECT name FROM sqlite_master WHERE type='trigger' ORDER BY name",
)
.fetch_all(pool)
.await
.unwrap()
}
/// Stage a DB at pre-0025 state with two 0024-era kit designations, then run the
/// rest of the migrations: the designations MUST be carried over, not dropped.
#[tokio::test]
async fn kit_assignments_migrate_into_club_active_items() {
let dir = tempfile::tempdir().expect("tempdir");
let db = dir.path().join("staged.db");
let pool = pool_for(&db).await;
migrator_upto(OWNED_CONTENT_MIGRATION)
.run(&pool)
.await
.expect("migrate to pre-0025");
assert!(
table_exists(&pool, "club_kit_assignments").await,
"staging must actually be at the 0024 schema"
);
let ts = "2026-01-01T00:00:00Z";
sqlx::query("INSERT INTO profiles (id, username, created_at, updated_at) VALUES ('p','p',?,?)")
.bind(ts)
.bind(ts)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) \
VALUES ('c','p','c',0,?,?)",
)
.bind(ts)
.bind(ts)
.execute(&pool)
.await
.unwrap();
for id in ["kit-h", "kit-a", "spare"] {
sqlx::query(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) \
VALUES (?, 'c', ?, 0, ?)",
)
.bind(id)
.bind(format!("def-{id}"))
.bind(ts)
.execute(&pool)
.await
.unwrap();
}
for (slot, owned) in [("home", "kit-h"), ("away", "kit-a")] {
sqlx::query(
"INSERT INTO club_kit_assignments (club_id, slot, owned_card_id, updated_at) \
VALUES ('c', ?, ?, ?)",
)
.bind(slot)
.bind(owned)
.bind(ts)
.execute(&pool)
.await
.unwrap();
}
// Now the migrations under test.
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrate to head");
assert!(
!table_exists(&pool, "club_kit_assignments").await,
"the old kit table must be gone"
);
assert!(table_exists(&pool, "club_active_items").await);
assert!(table_exists(&pool, "consumable_applications").await);
let rows =
sqlx::query("SELECT slot, owned_card_id, updated_at FROM club_active_items ORDER BY slot")
.fetch_all(&pool)
.await
.unwrap();
let carried: Vec<(String, String, String)> = rows
.iter()
.map(|r| (r.get(0), r.get(1), r.get(2)))
.collect();
assert_eq!(
carried,
vec![
(
ActiveSlot::AwayKit.as_str().into(),
"kit-a".to_string(),
ts.to_string()
),
(
ActiveSlot::HomeKit.as_str().into(),
"kit-h".to_string(),
ts.to_string()
),
],
"home -> home_kit, away -> away_kit, timestamps preserved"
);
// 0024's trigger is replaced, never merely orphaned: an ownership transfer
// must still clear the designation (and must not fail on a missing table).
let names = trigger_names(&pool).await;
assert!(
!names.contains(&"clear_club_kit_assignment_before_transfer".to_string()),
"the old trigger must be dropped, got {names:?}"
);
assert!(
names.contains(&"clear_club_active_item_before_transfer".to_string()),
"the generalised trigger must exist, got {names:?}"
);
sqlx::query(
"INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) \
VALUES ('c2','p','c2',0,?,?)",
)
.bind(ts)
.bind(ts)
.execute(&pool)
.await
.unwrap();
sqlx::query("UPDATE owned_cards SET club_id = 'c2' WHERE id = 'kit-h'")
.execute(&pool)
.await
.expect("transfer must succeed after the trigger swap");
let remaining =
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM club_active_items WHERE club_id='c'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(remaining, 1, "the transferred kit's designation is cleared");
// Backfilled ownership reads back as the default kind with no stack size.
let (kind, quantity) = sqlx::query_as::<_, (ContentKind, Option<i64>)>(
"SELECT content_kind, quantity FROM owned_cards WHERE id = 'spare'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(kind, ContentKind::Player);
assert_eq!(quantity, None);
// 0028: the column exists and every row that pre-dates it reads back NULL.
// NULL is not zero — it means Core tracks no contract for the instance, so a
// backfill here would have invented one game's pack-fresh number for all of
// them.
let contract = sqlx::query_scalar::<_, Option<i64>>(
"SELECT contract_matches FROM owned_cards WHERE id = 'spare'",
)
.fetch_one(&pool)
.await
.expect("0028 must have added contract_matches");
assert_eq!(contract, None, "a pre-existing row tracks no contract");
assert!(
sqlx::query("UPDATE owned_cards SET contract_matches = -1 WHERE id = 'spare'")
.execute(&pool)
.await
.is_err(),
"contract_matches CHECK must reject a negative count"
);
// And the new column constraints are real, not documentation.
assert!(
sqlx::query("UPDATE owned_cards SET content_kind = 'coach' WHERE id = 'spare'")
.execute(&pool)
.await
.is_err(),
"content_kind CHECK must reject a token outside the vocabulary"
);
assert!(
sqlx::query("UPDATE owned_cards SET quantity = 0 WHERE id = 'spare'")
.execute(&pool)
.await
.is_err(),
"quantity CHECK must reject a non-positive stack"
);
assert!(
sqlx::query(
"INSERT INTO club_active_items (club_id, slot, owned_card_id, updated_at) \
VALUES ('c', 'league_logo', 'spare', ?)"
)
.bind(ts)
.execute(&pool)
.await
.is_err(),
"slot CHECK must reject a token outside the recovered equipped-state set"
);
drop(dir);
}
/// The migrations must apply cleanly to a COPY of a REAL populated club DB,
/// leave every owned row intact, and carry a real kit designation over.
///
/// The snapshot predates migration 0024, so the copy is first brought up to the
/// 0024 schema and given two kit designations pointing at REAL owned instances;
/// only then do the migrations under test run. That way the carry-over is proved
/// on production ownership, not on synthetic rows.
///
/// Point `OPENFUT_CORE_SNAPSHOT_DB` at a real `core.db` to run it; without that
/// the test reports the skip rather than passing silently on nothing.
#[tokio::test]
async fn migrations_apply_to_a_real_populated_snapshot() {
let Ok(source) = std::env::var("OPENFUT_CORE_SNAPSHOT_DB") else {
eprintln!(
"SKIPPED migrations_apply_to_a_real_populated_snapshot: set \
OPENFUT_CORE_SNAPSHOT_DB=/path/to/core.db to run it"
);
return;
};
let dir = tempfile::tempdir().expect("tempdir");
let copy = dir.path().join("core.db");
// Copy, never open the source: the snapshot is read-only evidence.
std::fs::copy(&source, &copy).unwrap_or_else(|e| panic!("copy {source}: {e}"));
let pool = pool_for(&copy).await;
let before = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards")
.fetch_one(&pool)
.await
.expect("snapshot must already hold ownership");
assert!(
before > 0,
"the snapshot must be populated to prove anything"
);
// Bring the copy to the 0024 schema and designate two REAL owned instances
// as this club's kits, exactly as the pre-generalisation server would have.
migrator_upto(OWNED_CONTENT_MIGRATION)
.run(&pool)
.await
.expect("migrate the snapshot to pre-0025");
let real: Vec<(String, String)> =
sqlx::query_as("SELECT id, club_id FROM owned_cards ORDER BY id LIMIT 2")
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(real.len(), 2, "need two real owned instances");
let ts = "2026-01-01T00:00:00Z";
for (slot, (owned_id, club_id)) in ["home", "away"].into_iter().zip(&real) {
sqlx::query(
"INSERT INTO club_kit_assignments (club_id, slot, owned_card_id, updated_at) \
VALUES (?, ?, ?, ?)",
)
.bind(club_id)
.bind(slot)
.bind(owned_id)
.bind(ts)
.execute(&pool)
.await
.expect("stage a real kit designation");
}
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrations must apply to real populated data");
let (after, players, stacked, contracted) = sqlx::query_as::<_, (i64, i64, i64, i64)>(
"SELECT COUNT(*), \
SUM(CASE WHEN content_kind = 'player' THEN 1 ELSE 0 END), \
SUM(CASE WHEN quantity IS NOT NULL THEN 1 ELSE 0 END), \
SUM(CASE WHEN contract_matches IS NOT NULL THEN 1 ELSE 0 END) \
FROM owned_cards",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(after, before, "no owned row may be lost or duplicated");
assert_eq!(players, before, "every backfilled row is a player");
assert_eq!(stacked, 0, "no pre-existing row gains a stack size");
assert_eq!(contracted, 0, "no pre-existing row gains a contract count");
assert!(table_exists(&pool, "club_active_items").await);
assert!(!table_exists(&pool, "club_kit_assignments").await);
assert!(table_exists(&pool, "consumable_applications").await);
let carried: Vec<(String, String)> =
sqlx::query_as("SELECT slot, owned_card_id FROM club_active_items ORDER BY slot")
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(
carried,
vec![
(ActiveSlot::AwayKit.as_str().into(), real[1].0.clone()),
(ActiveSlot::HomeKit.as_str().into(), real[0].0.clone()),
],
"real kit designations must land in club_active_items"
);
eprintln!(
"snapshot: {after} owned rows survive as content_kind='player'; \
designations carried over: {carried:?}"
);
drop(dir);
}