11 Commits

Author SHA1 Message Date
funman300 20e281e0cf feat(squad): support a role-only partial update distinct from replacement
CI / Build, lint & test (push) Successful in 3m28s
`/squad/replace` is a full replacement: it deletes every assignment and
reinserts the supplied slots, and 9bdc163 correctly made it refuse a
replacement carrying no slots so a broken client cannot write its emptiness
back. That guard is load-bearing and is not touched here.

But FIFA 17 sends TWO operations down one wire path. Its captain/kick-taker
screen emits a body with no `players` at all -- `{id, custom, captain,
kicktakers}` -- and the host presented that to `/squad/replace` as a
replacement with zero slots. The guard did exactly its job and refused it, so
every captain/kick-taker change died with a 400 (surfaced to the client as a
502) and the user's edit was silently lost. Confirmed by bisect against the
captures: the same body returned 200 on 08-24 18:06:59 and 502 at 20:05:30,
either side of the Core deploy carrying the guard.

The operation was mis-described, so the fix is to stop mis-describing it, not
to relax the guard. `patch_squad_roles` updates only the captain flag and the
opaque extension, in one transaction, issuing no statement that can insert,
delete or reorder an assignment row -- player slots, the squad manager and club
actives are untouched by construction rather than by care.

Two details that matter:

  * the captain is part of `squad_fingerprint`, so a captain move MUST
    re-anchor the extension or every later read reports it stale;
  * the captain is validated against THIS squad's assignments before any
    write, so an invalid target leaves captain AND extension unapplied rather
    than half-applying the patch.

Tests cover both halves: the captain moves without disturbing assignments and
re-anchors the fingerprint, and an unfielded captain is refused with the prior
captain and the prior extension payload both intact. The empty-replacement
guard regression test continues to pass unchanged.
2026-08-25 01:52:36 +00:00
funman300 8819cc76a1 fix(core): keep an absent manager field distinct from an explicit removal
CI / Build, lint & test (push) Successful in 3m24s
PUT /club/manager took `owned_card_id: Option<String>`, so serde collapsed
"field absent" and "field explicitly null" into the same None, and the route
treated both as a clear. A caller that simply had nothing to say about the
manager therefore DELETED the assignment.

That is the second destructive squad-save path. WAL forensics on the staging
DB pin it to commit frame 468, squad_managers 1 row -> 0, in a transaction
touching only squad_managers and its indexes - disjoint from the player wipe
at frame 465, which touched squads/squad_players/game_entity_ext. The two
wipes came from two different writes, and only the first was guarded.

The three states are now distinct:

  {}                            leave the manager exactly as it is
  {"owned_card_id": null}       explicitly remove it (still supported)
  {"owned_card_id": "<id>"}     assign that owned card

A deliberate removal is a legitimate operation and is preserved; only the
"absent means delete" reading is gone.

set_squad_manager_for_squad now runs its two existence checks and the insert
in ONE transaction. Validating on the pool and then inserting left a window in
which the squad or the card could disappear between check and write.

Tests cover assign, reassign, idempotent re-assign, absent-is-a-no-op,
explicit-null-still-removes, unowned-manager-refused, absent-against-no-manager
not over-guarded, and that no manager write disturbs player assignments. A
malformed body is asserted to be a parser rejection, distinguishable from the
guard. With the fix reverted the absent-field test fails.
2026-08-24 19:58:54 +00:00
funman300 9bdc1633a0 fix(core): refuse a squad replacement that would empty a populated squad
CI / Build, lint & test (push) Successful in 3m38s
/squad/replace is a full replacement: it deletes every assignment and
reinserts the supplied slots. Nothing validated that the supplied list was
non-empty, so a caller sending no slots silently wiped the squad and got
200/ok back.

This happened for real. A FIFA 17 client whose in-memory squad had been
destroyed by a bad parse wrote its emptiness back twice; WAL forensics on the
staging DB pin the damage to commit frame 465, squad_players 18 rows -> 0,
logged as route=squad-replace status=200 outcome=ok. The squad is the
authority's state, so mirroring a broken client's model is unrecoverable.

No product flow empties a squad: a full-replacement client sends its complete
slot array, and no caller or test in the tree builds an empty slot list. So an
empty list means the caller's model is broken, and the write is refused with
BadRequest. The check runs inside the transaction, so a concurrent write
cannot slip between the count and the delete, and a newly created squad
counts zero and is unaffected.

The regression test asserts both halves: the empty replacement is rejected,
and the existing assignments survive it. With the guard removed the test fails
with 200 and slots_written 0 - the exact production symptom.
2026-08-24 19:27:44 +00:00
funman300 1df03d4287 feat(match): consume one-match training effects for the players who played
CI / Build, lint & test (push) Successful in 3m19s
FIFA 17 training is a ONE-MATCH effect and the trigger is the PLAYER PLAYING,
not the match completing: a card applied to someone who stays on the bench or in
the reserves "will continue to benefit from the training effect until he plays"
(DOCUMENTED, fifauteam's contemporaneous FIFA 17 guide, corroborated across two
of its pages).

So Core expires exactly the instances the caller names, and never a whole club.
The participant list is supplied rather than derived here, deliberately:

- The FIFA 17 match wire carries NO lineup. Across 36,149 captured requests the
  19 match creates carry 5 keys and the 13 ends carry 6; the tokens "lineup" and
  "substitut" appear ZERO times, while "kitNumber" appears 1311 times and the
  same extraction recovers 23 instance ids from PUT /squad/0 in that same pcap.
  The absence is measured against a working positive control, not assumed.
- Core must not resolve it from the squad at completion either: the squad at end
  is provably not the squad that started (a captured match began 20:33:20 and
  the next squad save landed 12 minutes later with no /match/end between).

Empty participants therefore expires nothing, so a caller that cannot identify
who played is inert instead of destructive.

The mutation sits inside the existing single match transaction, under the same
is_economic guard as coins and statistics, so NoContest voids it exactly as it
voids everything else, and a rollback leaves boosts intact.

Tests: participant scoping (the benched player keeps his boost), empty-participant
inertness, club scoping, replay (a resubmitted completion does not consume a
freshly reapplied boost), NoContest, and a BeforeCommit fault that fires AFTER
the delete to prove the split-brain state "match rejected but training consumed"
cannot occur.
2026-08-23 02:58:29 +00:00
funman300 90210702c3 feat(core): training replaces, and the rare card boosts all six
CI / Build, lint & test (push) Successful in 3m25s
Corrects two decisions the previous revision got wrong, both now settled by
the published FIFA 17 training guide -- the same source class and publisher
this project already accepted for the contract matrix, and which
cross-validates 6/6 against fcc_trainingcards on rows we had misclassified.

"You can only boost one attribute or all six... When you apply a new training
card to a player, he loses the improved attributes of previous training
cards. It does not accumulate, it replaces."

So a second card REPLACES rather than being refused, and two slots must never
be boosted at once -- our old composite key permitted exactly that, and
staging demonstrated it by holding a slot-4 and a slot-1 effect together.

Migration 0030 reshapes the table to one row per instance keyed on
owned_card_id alone, with attribute_index nullable for the rare all-six card,
and carries forward the most recent effect where several existed -- the
replaces rule applied retroactively. 0029 is left intact rather than
rewritten: it is already applied to supervised staging, where migration
history matters.

Apply is now delete-then-insert inside the one transaction, so the old effect
cannot survive a failed insert and the two cannot coexist. The outcome
records what it displaced instead of overwriting history silently.

The previous refusal was OUR policy standing in for an unknown, and it was
never evidence about FIFA 17. It is retired now that the behaviour is
recovered, not because the 409 was inconvenient.
2026-08-22 23:33:43 +00:00
funman300 a45155e0c5 feat(core): per-instance attribute training as a closed effect
CI / Build, lint & test (push) Successful in 3m4s
FIFA 17 training cards boost ONE attribute of ONE owned player. Core gains
the state to hold that and the vocabulary to be asked for it, without
learning any FIFA rule.

`owned_card_training` (migration 0029) keys on (owned_card_id,
attribute_index), so a second training on a slot that already carries one is
a constraint violation rather than a silent choice between stacking and
replacing. Whether FIFA 17 stacks, replaces, merges or refuses is UNKNOWN --
no shipped table describes it and the client holds no consumable-effect
logic to reverse it from -- so the schema enforces the unknown and the apply
turns it into a refusal that consumes nothing. Relaxing that later is one
line; unpicking accumulated wrong state would not be.

`InstanceEffect::ApplyTraining { attribute_index, amount, max_amount }`
names a SLOT in Core's own six-attribute model, never a FIFA attribute: that
"GK speed is slot 4" is the adapter's reversed knowledge and stays there.
The caller declares its family's authored ceiling and Core holds it to it,
which is what stops a host describing a boost no card could grant through a
vocabulary that exists to prevent exactly that.

The immutable definition is never written. `/collection` gains
`effective_attributes` (base + training, clamped to the 1..=99 domain) and
the raw effects, loaded for the whole club in one query rather than the N+1
this projection has suffered before. The legacy `training_bonus` column --
an overall-rating upgrade written only by a non-transactional route no
adapter calls -- is deliberately not reused.

Tests cover the happy path, same-slot refusal leaving the card intact,
distinct slots coexisting, over-ceiling and out-of-range refusals, loan
refusal, replay, FK cascade, and two 12-round races: apply vs quick-sell on
one card, and two concurrent applies of one card. Both prove exactly one
winner, one effect, one audit row.
2026-08-22 22:59:24 +00:00
funman300 82c3d2c85a feat(core): own the EA-authored non-player definition rating
CI / Build, lint & test (push) Successful in 3m23s
Adds `CardDefinition.source_rating: Option<u8>` -- the authoritative rating a
NON-PLAYER definition carries in EA's own tables (a staff card's `value` from
managercards/*coachcards/physiocards, a consumable's rating).

WHY NOT `overall`. `overall` feeds quick-sell pricing and squad projection, and
it is deliberately 0 for every non-player. Reusing it would silently revalue
staff, which is out of scope for the manager-contract milestone. `source_rating`
is a separate number read only by tier rules, so both pricing paths stay
byte-identical: Core's `quick_sell_coins(card.overall)` and the host's
`legacy_discard_value(item.rating)` see exactly what they saw before, and
`effective_overall` is unchanged.

MUST stay Option: CardDefinition has no `#[serde(default)]`, so a required field
would reject every already-shipped content pack, whereas a missing Option
deserializes to None. A test pins that backwards compatibility, because it is
the property that lets Core and the emitter be deployed independently.

No migration: Core does not persist definitions at all -- they are JSON content
packs parsed at startup into an immutable in-memory CardDb. `/collection`
embeds the serialized definition wholesale, so `card.source_rating` reaches the
host with no projection change.
2026-08-22 20:08:09 +00:00
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
26 changed files with 3309 additions and 184 deletions
+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);
+49
View File
@@ -0,0 +1,49 @@
-- Per-instance attribute training on an owned instance.
--
-- WHY A TABLE AND NOT COLUMNS. A training effect is (attribute slot, amount),
-- and a game may author one per slot. Six nullable columns would encode the
-- slot in the schema and force a migration to add a seventh; a row per slot
-- keeps the slot a value. It is also the smallest shape that lets the PRIMARY
-- KEY do the work described below.
--
-- WHY THE PRIMARY KEY IS (owned_card_id, attribute_index). Whether FIFA 17
-- REPLACES, STACKS, MERGES or REFUSES a second training on an attribute that
-- already carries one is UNKNOWN: no shipped table encodes it, and the client
-- holds no consumable-effect logic at all to reverse (no binary in the install
-- reads `fcc_trainingcards`, so effects are server-authoritative). Rather than
-- pick one of those behaviours and ship a guess as though it were recovered,
-- the key makes a second application to the SAME slot a constraint violation,
-- which the apply path turns into an explicit refusal that consumes nothing.
-- The unknown is therefore enforced by the schema instead of being papered over.
-- When the behaviour is proven, the change is a deliberate one-line relaxation
-- plus the arithmetic it implies — not an unpicking of accumulated bad state.
--
-- `attribute_index` is a slot in CORE's own six-attribute card model, in the
-- declaration order of `CardDefinition` (0 pace, 1 shooting, 2 passing,
-- 3 dribbling, 4 defending, 5 physical). It is deliberately NOT a FIFA
-- attribute name: mapping "GK speed" onto slot 4 is the FIFA 17 adapter's
-- reversed knowledge, and Core stays game-neutral by only ever indexing its own
-- model. The CHECK pins the slot to that model's width.
--
-- `amount` is bounded at 99 because it is added to an attribute whose domain is
-- 1..=99; a larger stored value could not mean anything. The tighter, per-game
-- ceiling (FIFA 17 authors only 5/10/15) is validated at apply time, where the
-- game's table is in scope, not here.
--
-- ON DELETE CASCADE is load-bearing: the pool enables `foreign_keys`
-- (`db.rs:20`), so quick-selling or otherwise destroying a trained instance
-- takes its training with it and cannot leave a row pointing at a dead item.
CREATE TABLE owned_card_training (
owned_card_id TEXT NOT NULL REFERENCES owned_cards(id) ON DELETE CASCADE,
attribute_index INTEGER NOT NULL CHECK (attribute_index BETWEEN 0 AND 5),
amount INTEGER NOT NULL CHECK (amount >= 1 AND amount <= 99),
-- The definition that granted it, kept for audit and for the eventual
-- lifecycle work; Core never interprets it.
source_card_id TEXT NOT NULL,
applied_at TEXT NOT NULL,
PRIMARY KEY (owned_card_id, attribute_index)
);
-- The projection reads every effect for a set of instances on each /collection
-- call, so the lookup is by instance.
CREATE INDEX idx_owned_card_training_owned ON owned_card_training(owned_card_id);
@@ -0,0 +1,66 @@
-- Reshape attribute training to AT MOST ONE effect per instance, replaceable.
--
-- WHY THIS SUPERSEDES 0029'S SHAPE. 0029 keyed on (owned_card_id,
-- attribute_index) and recorded that same-slot behaviour was UNKNOWN, enforcing
-- the unknown as a refusal. That was the honest shape while the semantics were
-- unrecovered. They are now recovered, and BOTH halves of 0029's shape are
-- wrong:
--
-- * "You can only boost one attribute or all six. You can not do it with 2, 3,
-- 4 or 5 attributes." -- so two effects must never coexist on one instance,
-- which the old composite key permitted (and which staging demonstrated by
-- holding a slot-4 and a slot-1 effect at once).
-- * "When you apply a new training card to a player, he loses the improved
-- attributes of previous training cards. It does not accumulate, it
-- replaces." -- so a second apply REPLACES, it does not refuse.
--
-- Both quotes are from the contemporaneous FIFA 17-specific training guide
-- (fifauteam, published 2016-09-08), corroborated by the shipped table: each
-- family has exactly 21 rows = 7 card types x 3 levels, and the 7th type in each
-- family (subtypes 57 and 67) is the only one flagged `weightrare = 2` with
-- amounts 3/6/10, matching the documented RARE "ALL" card at +3/+6/+10.
-- DOCUMENTED, corroborated TABLE_PROVEN. It is NOT LIVE_PROVEN against EA.
--
-- 0029 is left intact rather than rewritten: it is already applied to the
-- supervised staging environment, so migration history matters there.
--
-- NEW SHAPE. One row per instance, so "one attribute or all six" is a
-- representable invariant instead of a convention:
-- attribute_index INTEGER NULL -- a slot in Core's six-attribute model, or
-- NULL meaning ALL SIX slots (the rare card).
-- The PRIMARY KEY on owned_card_id alone is what makes a second application a
-- REPLACE (delete-then-insert inside the one apply transaction) rather than an
-- accumulation.
--
-- The 1..=15 amount bound is NOT tightened here: 15 is the single-attribute
-- ceiling while the all-six card authors at most 10, and which ceiling applies
-- depends on the card family -- a per-game rule that belongs at apply time where
-- the game's table is in scope, not in the schema.
--
-- DATA CARRIED FORWARD: where an instance somehow holds several effects (only
-- reachable on staging under 0029's shape), the MOST RECENT survives, which is
-- exactly the "replaces" rule applied retroactively.
CREATE TABLE owned_card_training_new (
owned_card_id TEXT NOT NULL PRIMARY KEY REFERENCES owned_cards(id) ON DELETE CASCADE,
attribute_index INTEGER CHECK (attribute_index IS NULL OR attribute_index BETWEEN 0 AND 5),
amount INTEGER NOT NULL CHECK (amount >= 1 AND amount <= 99),
source_card_id TEXT NOT NULL,
applied_at TEXT NOT NULL
);
INSERT INTO owned_card_training_new
(owned_card_id, attribute_index, amount, source_card_id, applied_at)
SELECT t.owned_card_id, t.attribute_index, t.amount, t.source_card_id, t.applied_at
FROM owned_card_training t
JOIN (
SELECT owned_card_id, MAX(applied_at) AS newest
FROM owned_card_training
GROUP BY owned_card_id
) pick
ON pick.owned_card_id = t.owned_card_id
AND pick.newest = t.applied_at
GROUP BY t.owned_card_id;
DROP TABLE owned_card_training;
ALTER TABLE owned_card_training_new RENAME TO owned_card_training;
+5
View File
@@ -192,6 +192,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
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),
@@ -237,6 +241,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.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("/squad/roles", put(routes::squad::put_squad_roles))
.route("/squads", get(routes::squad::get_squads))
.route("/squads/:squad_id", get(routes::squad::get_squad_by_id))
.route("/squads/:squad_id", delete(routes::squad::delete_squad))
+23
View File
@@ -57,6 +57,29 @@ async fn main() -> Result<()> {
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);
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
+19 -1
View File
@@ -231,6 +231,20 @@ pub struct CardDefinition {
pub physical: u8,
pub rarity: Rarity,
pub image_path: Option<String>,
/// EA's authored definition rating for a NON-PLAYER: a staff card's `value`
/// from its shipped family table, or a consumable's own rating.
///
/// This is deliberately NOT `overall`. `overall` feeds pricing and squad
/// projection, so it stays 0 for every non-player; `source_rating` is the
/// separate authoritative number the game's own tier rules read (bronze
/// `<65`, silver `65..=74`, gold `>=75`). `None` for players, whose rating
/// IS `overall`, and `None` whenever Core tracks no authored value — a
/// caller MUST fail closed rather than substitute a tier.
///
/// MUST stay `Option`: `CardDefinition` has no `#[serde(default)]`, so a
/// required field would reject every already-shipped content pack, whereas
/// a missing `Option` deserializes to `None`.
pub source_rating: Option<u8>,
}
/// One owned content INSTANCE (stored in DB).
@@ -252,6 +266,10 @@ pub struct OwnedCard {
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`].
@@ -262,7 +280,7 @@ pub struct OwnedCard {
/// 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 FROM owned_cards";
training_bonus, content_kind, quantity, contract_matches FROM owned_cards";
#[cfg(test)]
mod tests {
+21
View File
@@ -133,6 +133,23 @@ pub struct CompleteMatchRequest {
/// its own wire). Only a caller using Core's season model opts in.
#[serde(default)]
pub advance_season: bool,
/// Owned-card instances that TOOK THE FIELD in this match, whose one-match
/// training effects it consumes.
///
/// Supplied by the caller rather than derived here, and deliberately so.
/// FIFA 17's training rule keys on the player PLAYING, and who played is
/// game-specific knowledge Core does not have: its match wire carries no
/// lineup at all (LIVE_PROVEN over 36,149 captured requests). Core must also
/// not resolve it from the squad at completion time, because the squad at
/// end is provably not the squad that started — a captured match began at
/// 20:33:20 and the next squad save landed 12 minutes later with no
/// `/match/end` in between. The adapter therefore snapshots at kickoff and
/// passes the result here.
///
/// Empty expires nothing, so a caller that cannot identify participants is
/// simply inert instead of clearing a whole club.
#[serde(default)]
pub participants: Vec<String>,
}
/// Outcome of [`crate::services::match_service::complete_match`].
@@ -158,6 +175,10 @@ pub struct MatchCompletionResult {
/// 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>,
/// Owned card instances whose one-match training effect this match consumed.
/// Empty when the caller passed no participants, and empty on a replay —
/// the effect is consumed exactly once, by the first completion.
pub expired_training: 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>,
+19 -1
View File
@@ -13,7 +13,7 @@ use crate::{
services::{
club as club_svc, economy as economy_svc,
inventory::{self, OwnedItemQuery, OwnedItemView},
profile as profile_svc,
profile as profile_svc, training as training_svc,
},
};
@@ -119,6 +119,11 @@ pub async fn get_collection(
.fetch_all(&state.pool)
.await?;
// One query for the whole club, not one per item: this projection walks
// every owned row, and a per-item lookup here is the N+1 it has suffered
// before.
let training = training_svc::load_for_club(&state.pool, &club.id).await?;
// An owned row whose definition is absent from the loaded content CANNOT be
// projected (there is nothing to project), but it must never vanish in
// silence: that silent `filter_map` drop is how a real club once served
@@ -141,6 +146,13 @@ pub async fn get_collection(
};
let effective_overall = def.overall as i64 + o.training_bonus;
let effective_position = o.position_override.as_deref().unwrap_or(&def.position);
// Attribute training is per-instance state, so the finished attributes
// belong in the envelope beside the finished rating. The raw effect goes
// out too: a caller that needs to show WHICH attribute was trained
// cannot recover that by differencing against a definition it may not
// have. At most ONE effect per instance -- FIFA 17 replaces rather than
// accumulates, so this is an Option, not a list.
let effect = training.get(&o.id);
let body = json!({
"owned_card_id": o.id,
"content_kind": o.content_kind,
@@ -151,8 +163,14 @@ pub async fn get_collection(
"chemistry_style": o.chemistry_style,
"position_override": o.position_override,
"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_position": effective_position,
"effective_attributes": training_svc::effective_attributes_json(def, effect),
"training": effect,
"card": def,
});
views.push(OwnedItemView {
+31 -7
View File
@@ -139,15 +139,36 @@ pub async fn get_squad_manager(
Ok(Json(json!({ "manager": manager })))
}
/// A manager write. The three states are DISTINCT and must stay that way:
///
/// | body | meaning |
/// | --- | --- |
/// | `{}` — field absent | say nothing about the manager; leave it as it is |
/// | `{"owned_card_id": null}` | explicitly remove the current manager |
/// | `{"owned_card_id": "<id>"}` | assign that owned card |
///
/// A plain `Option<String>` collapsed the first two into `None`, so a caller
/// that simply had nothing to say silently deleted the assignment. That is how a
/// FIFA 17 client with a destroyed squad model wiped a real manager row. The
/// double option keeps "absent" and "null" apart.
#[derive(Deserialize)]
pub struct SetManagerRequest {
/// The owned card to assign as manager, or `null`/absent to clear it.
pub owned_card_id: Option<String>,
#[serde(default, deserialize_with = "deserialize_present_option")]
pub owned_card_id: Option<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.
/// Deserialize a field that is present-but-null into `Some(None)`, leaving an
/// absent field as `None` (supplied by `#[serde(default)]`).
fn deserialize_present_option<'de, D>(d: D) -> Result<Option<Option<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<String>::deserialize(d).map(Some)
}
/// Assign, explicitly remove, or leave unchanged 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,
@@ -156,10 +177,13 @@ pub async fn put_squad_manager(
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) => {
Some(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?,
// Explicit null: a deliberate removal, which is a legitimate operation.
Some(None) => club_svc::clear_squad_manager(&state.pool, &club.id).await?,
// Absent: this request expresses no manager decision. Touch nothing.
None => {}
}
let manager = club_svc::get_squad_manager(&state.pool, &club.id).await?;
Ok(Json(json!({ "manager": manager })))
+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))
}
+1
View File
@@ -2,6 +2,7 @@ pub mod achievements;
pub mod auth;
pub mod cards;
pub mod club;
pub mod consumables;
pub mod division;
pub mod draft;
pub mod economy;
+42
View File
@@ -235,3 +235,45 @@ pub async fn put_squad_replace(
"slots_written": out.slots_written,
})))
}
#[derive(Deserialize)]
pub struct RolePatchReq {
/// Owned card to flag as captain. Omitted means "leave the captain alone" —
/// it is NEVER a request to clear it. Clearing has no established client
/// semantics and is deliberately not invented here.
#[serde(default)]
pub captain_owned_card_id: Option<String>,
pub extension: OpaqueExtensionWrite,
}
/// `PUT /squad/roles` — patch ONLY role assignments (captain) plus the opaque
/// game extension, atomically.
///
/// Distinct from `/squad/replace` on purpose. A role-only update carries no slot
/// array, and describing it as a replacement with zero slots trips the
/// empty-replacement guard — which is correct behaviour for a replacement and
/// wrong for a patch. This route never touches player assignments, the squad
/// manager, or club actives.
pub async fn put_squad_roles(
State(state): State<AppState>,
game: GameId,
Json(req): Json<RolePatchReq>,
) -> 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 out = squad_svc::patch_squad_roles(
&state.pool,
game.as_str(),
&club.id,
req.captain_owned_card_id.as_deref(),
&req.extension,
)
.await?;
Ok(Json(json!({
"squad_id": out.squad.id,
"canonical_fingerprint": out.canonical_fingerprint,
"captain_changed": out.captain_changed,
})))
}
+9 -3
View File
@@ -195,11 +195,16 @@ pub async fn set_squad_manager_for_squad(
squad_id: &str,
owned_card_id: &str,
) -> AppResult<()> {
// One transaction: both existence checks and the write. Validating on the
// pool and then inserting left a window in which the squad or the card could
// be removed between the check and the write, persisting an assignment whose
// preconditions no longer held.
let mut tx = pool.begin().await?;
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)
.fetch_optional(&mut *tx)
.await?;
if squad_ok.is_none() {
return Err(AppError::NotFound(format!("squad '{squad_id}' not found")));
@@ -208,7 +213,7 @@ pub async fn set_squad_manager_for_squad(
sqlx::query_scalar::<_, String>("SELECT id FROM owned_cards WHERE id = ? AND club_id = ?")
.bind(owned_card_id)
.bind(club_id)
.fetch_optional(pool)
.fetch_optional(&mut *tx)
.await?;
if card_ok.is_none() {
return Err(AppError::NotFound(format!(
@@ -223,8 +228,9 @@ pub async fn set_squad_manager_for_squad(
.bind(squad_id)
.bind(owned_card_id)
.bind(&now)
.execute(pool)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
+262 -166
View File
@@ -2,17 +2,24 @@
//!
//! ONE Core transaction that does, in this order and nothing else:
//!
//! 1. validate the SOURCE — it exists, belongs to the club, and is the
//! 1. answer a REPLAY — if `(profile_id, action_identity)` is already recorded,
//! echo that outcome and touch nothing. This precedes every validation on
//! purpose: a completed application has already DESTROYED its source, so
//! checking the source first would answer "not found" to a retried request
//! that in fact succeeded;
//! 2. validate the SOURCE — it exists, belongs to the club, and is the
//! `ContentKind` the caller expected;
//! 2. validate the TARGET — nothing at all (`ConsumeTarget::Club`) or an owned
//! 3. validate the TARGET — nothing at all (`ConsumeTarget::Club`) or an owned
//! instance that exists, belongs to the club, and is the expected kind;
//! 3. write the replay guard — `UNIQUE(profile_id, action_identity)` on
//! 4. write the replay guard — `UNIQUE(profile_id, action_identity)` on
//! `consumable_applications`, so a duplicate is refused BEFORE anything is
//! mutated or consumed (same discipline as `match_completions`);
//! 4. apply the caller's mutation to the target;
//! 5. consume the source EXACTLY ONCE — destroy the instance, or decrement its
//! mutated or consumed (same discipline as `match_completions`). Step 1 is
//! a courtesy; THIS is the guarantee, and it holds against a writer on any
//! other connection or process;
//! 5. apply the caller's mutation to the target;
//! 6. consume the source EXACTLY ONCE — destroy the instance, or decrement its
//! stack and destroy it at zero;
//! 6. commit.
//! 7. commit.
//!
//! Anything failing at any step rolls the whole thing back: the source is never
//! spent without the effect landing, and the effect never lands without the
@@ -21,9 +28,10 @@
//! Core deliberately supplies **no per-category formula**. What a fitness card,
//! a contract, a chemistry style or a position modifier actually DOES to its
//! target is the calling game adapter's reversed behaviour, passed in as
//! [`ItemMutation`]; an unreversed behaviour must not be invented here, and the
//! honest stopping point for one is ownership + projection, i.e. not calling
//! this function at all.
//! [`ItemMutation`] — either in-process, or described over the wire through the
//! closed, Core-validated vocabulary in [`crate::services::instance_effect`]. An
//! unreversed behaviour must not be invented here, and the honest stopping point
//! for one is ownership + projection, i.e. not calling this function at all.
use std::future::Future;
use std::pin::Pin;
@@ -38,6 +46,7 @@ use crate::{
db::Pool,
error::{AppError, AppResult},
models::card::{ContentKind, OwnedCard, OWNED_CARD_SELECT},
services::economy,
};
/// What the transaction does to the source instance once the effect is applied.
@@ -169,6 +178,16 @@ fn require_kind(card: &OwnedCard, expected: ContentKind, role: &str) -> AppResul
Ok(())
}
/// Whether this call did the work or found the identity already recorded.
///
/// The replay branch cannot read the recorded outcome while the transaction is
/// still open on this connection, so it is reported out of the transaction and
/// answered once the connection is free again.
enum Applied {
Fresh(ConsumeOutcome),
Replay,
}
/// Apply one consumable to one target, exactly once. See the module docs.
pub async fn consume_item<M: ItemMutation>(
pool: &Pool,
@@ -197,187 +216,231 @@ pub async fn consume_item<M: ItemMutation>(
}
}
let mut tx = pool.begin().await?;
// ONE connection with an explicit BEGIN IMMEDIATE, never a DEFERRED
// `pool.begin()`: this transaction performs three reads before its first
// write, and a deferred transaction only upgrades to a write at that first
// write — where SQLite answers SQLITE_BUSY *immediately*, bypassing
// `busy_timeout` (the full reasoning is on `economy::finish`). Two clients
// applying consumables at once is ordinary traffic, so take the write lock
// up front and let a rival wait instead of fail.
let mut conn = pool.acquire().await?;
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
// 1. source: owned by this club, and the kind the caller expected.
let source = fetch_owned(&mut tx, req.source_owned_card_id, club_id).await?;
require_kind(&source, req.expected_source_kind, "source item")?;
// A source that is fielded in a squad cannot be consumed: `squad_players`
// holds a FK onto `owned_cards(id)`, so the DELETE below would fail anyway.
// Refuse explicitly instead of surfacing SQLITE_CONSTRAINT, and never
// silently evict a lineup as a side effect of spending an item.
let fielded =
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM squad_players WHERE owned_card_id = ?")
.bind(req.source_owned_card_id)
.fetch_one(&mut *tx)
.await?;
if fielded > 0 {
return Err(AppError::Conflict(format!(
"source item '{}' is fielded in a squad and cannot be consumed",
req.source_owned_card_id
)));
}
// 2. target.
let target = match req.target {
ConsumeTarget::OwnedCard {
owned_card_id,
expected_kind,
} => {
let card = fetch_owned(&mut tx, owned_card_id, club_id).await?;
require_kind(&card, expected_kind, "target item")?;
Some(card)
let result = async {
// 1. a replay is answered before anything is validated: a completed
// application has already destroyed (or drawn down) its source, so
// validating first would answer NotFound to a retry of a request that
// actually succeeded. The write lock is already held, so this read
// cannot race the guard INSERT below — which stays as the real
// guarantee for a writer on any other connection.
if recorded(&mut conn, profile_id, req.action_identity).await? {
return Ok(Applied::Replay);
}
ConsumeTarget::Club => None,
};
// 3. replay guard FIRST — before the mutation and before the consumption, so
// a duplicate cannot apply a second effect or spend a second charge. The
// recorded `effect` is filled in below, once the mutation has produced it.
let application_id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let guard = sqlx::query(
"INSERT INTO consumable_applications \
(id, profile_id, action_identity, source_owned_card_id, source_card_id, \
source_content_kind, source_consumed, source_quantity_after, \
target_owned_card_id, effect, applied_at) \
VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?, '', ?)",
)
.bind(&application_id)
.bind(profile_id)
.bind(req.action_identity)
.bind(&source.id)
.bind(&source.card_id)
.bind(source.content_kind.as_str())
.bind(target.as_ref().map(|t| t.id.as_str()))
.bind(&now)
.execute(&mut *tx)
.await;
match guard {
Ok(_) => {}
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
tx.rollback().await?;
return already_applied(pool, profile_id, req.action_identity).await;
// 2. source: owned by this club, and the kind the caller expected.
let source = fetch_owned(&mut conn, req.source_owned_card_id, club_id).await?;
require_kind(&source, req.expected_source_kind, "source item")?;
// A source that is fielded in a squad cannot be consumed: `squad_players`
// holds a FK onto `owned_cards(id)`, so the DELETE below would fail anyway.
// Refuse explicitly instead of surfacing SQLITE_CONSTRAINT, and never
// silently evict a lineup as a side effect of spending an item.
let fielded = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM squad_players WHERE owned_card_id = ?",
)
.bind(req.source_owned_card_id)
.fetch_one(&mut *conn)
.await?;
if fielded > 0 {
return Err(AppError::Conflict(format!(
"source item '{}' is fielded in a squad and cannot be consumed",
req.source_owned_card_id
)));
}
Err(e) => {
tx.rollback().await?;
return Err(e.into());
}
}
// 4. the caller's effect on the target, inside this transaction.
let ctx = ConsumeContext {
profile_id: profile_id.to_string(),
club_id: club_id.to_string(),
source,
target,
};
let effect = mutation.apply(&mut tx, &ctx).await?;
// 5. consume the source exactly once. Both paths assert rows_affected == 1,
// so a concurrent spend of the same instance (which lost the SQLite write
// lock and now sees the row gone / already decremented) fails instead of
// granting a second effect.
let (source_destroyed, source_quantity_after) = match req.consumption {
SourceConsumption::DestroyInstance => {
let deleted = sqlx::query("DELETE FROM owned_cards WHERE id = ? AND club_id = ?")
.bind(&ctx.source.id)
.bind(club_id)
.execute(&mut *tx)
.await?
.rows_affected();
if deleted != 1 {
return Err(AppError::Conflict(format!(
"source item '{}' was already consumed",
ctx.source.id
)));
// 3. target.
let target = match req.target {
ConsumeTarget::OwnedCard {
owned_card_id,
expected_kind,
} => {
let card = fetch_owned(&mut conn, owned_card_id, club_id).await?;
require_kind(&card, expected_kind, "target item")?;
Some(card)
}
(true, None)
ConsumeTarget::Club => None,
};
// 4. replay guard — the durable one. It precedes the mutation and the
// consumption, so a duplicate that got past step 1 on another
// connection still cannot apply a second effect or spend a second
// charge. The recorded `effect` is filled in below, once the mutation
// has produced it.
let application_id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let guard = sqlx::query(
"INSERT INTO consumable_applications \
(id, profile_id, action_identity, source_owned_card_id, source_card_id, \
source_content_kind, source_consumed, source_quantity_after, \
target_owned_card_id, effect, applied_at) \
VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?, '', ?)",
)
.bind(&application_id)
.bind(profile_id)
.bind(req.action_identity)
.bind(&source.id)
.bind(&source.card_id)
.bind(source.content_kind.as_str())
.bind(target.as_ref().map(|t| t.id.as_str()))
.bind(&now)
.execute(&mut *conn)
.await;
match guard {
Ok(_) => {}
// A collision writes nothing, so this transaction has nothing to undo
// and simply ends; the recorded outcome is read back afterwards.
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => return Ok(Applied::Replay),
Err(e) => return Err(e.into()),
}
SourceConsumption::DecrementStack { amount } => {
let Some(have) = ctx.source.quantity else {
return Err(AppError::BadRequest(format!(
"source item '{}' carries no stack size; it can only be destroyed",
ctx.source.id
)));
};
if have < amount {
return Err(AppError::Conflict(format!(
"source item '{}' holds {have}, cannot consume {amount}",
ctx.source.id
)));
}
let remaining = have - amount;
if remaining == 0 {
let deleted = sqlx::query(
"DELETE FROM owned_cards WHERE id = ? AND club_id = ? AND quantity = ?",
)
.bind(&ctx.source.id)
.bind(club_id)
.bind(have)
.execute(&mut *tx)
.await?
.rows_affected();
// 5. the caller's effect on the target, inside this transaction.
let ctx = ConsumeContext {
profile_id: profile_id.to_string(),
club_id: club_id.to_string(),
source,
target,
};
let effect = mutation.apply(&mut conn, &ctx).await?;
// 6. consume the source exactly once. Both paths assert rows_affected == 1,
// so a concurrent spend of the same instance (which lost the SQLite write
// lock and now sees the row gone / already decremented) fails instead of
// granting a second effect.
let (source_destroyed, source_quantity_after) = match req.consumption {
SourceConsumption::DestroyInstance => {
let deleted = sqlx::query("DELETE FROM owned_cards WHERE id = ? AND club_id = ?")
.bind(&ctx.source.id)
.bind(club_id)
.execute(&mut *conn)
.await?
.rows_affected();
if deleted != 1 {
return Err(AppError::Conflict(format!(
"source item '{}' changed under us",
"source item '{}' was already consumed",
ctx.source.id
)));
}
(true, None)
} else {
let updated = sqlx::query(
"UPDATE owned_cards SET quantity = ? WHERE id = ? AND club_id = ? \
AND quantity = ?",
)
.bind(remaining)
.bind(&ctx.source.id)
.bind(club_id)
.bind(have)
.execute(&mut *tx)
.await?
.rows_affected();
if updated != 1 {
}
SourceConsumption::DecrementStack { amount } => {
let Some(have) = ctx.source.quantity else {
return Err(AppError::BadRequest(format!(
"source item '{}' carries no stack size; it can only be destroyed",
ctx.source.id
)));
};
if have < amount {
return Err(AppError::Conflict(format!(
"source item '{}' changed under us",
"source item '{}' holds {have}, cannot consume {amount}",
ctx.source.id
)));
}
(false, Some(remaining))
let remaining = have - amount;
if remaining == 0 {
let deleted = sqlx::query(
"DELETE FROM owned_cards WHERE id = ? AND club_id = ? AND quantity = ?",
)
.bind(&ctx.source.id)
.bind(club_id)
.bind(have)
.execute(&mut *conn)
.await?
.rows_affected();
if deleted != 1 {
return Err(AppError::Conflict(format!(
"source item '{}' changed under us",
ctx.source.id
)));
}
(true, None)
} else {
let updated = sqlx::query(
"UPDATE owned_cards SET quantity = ? WHERE id = ? AND club_id = ? \
AND quantity = ?",
)
.bind(remaining)
.bind(&ctx.source.id)
.bind(club_id)
.bind(have)
.execute(&mut *conn)
.await?
.rows_affected();
if updated != 1 {
return Err(AppError::Conflict(format!(
"source item '{}' changed under us",
ctx.source.id
)));
}
(false, Some(remaining))
}
}
}
};
};
let effect_text = serde_json::to_string(&effect)?;
sqlx::query(
"UPDATE consumable_applications \
SET effect = ?, source_consumed = ?, source_quantity_after = ? WHERE id = ?",
let effect_text = serde_json::to_string(&effect)?;
sqlx::query(
"UPDATE consumable_applications \
SET effect = ?, source_consumed = ?, source_quantity_after = ? WHERE id = ?",
)
.bind(&effect_text)
.bind(i64::from(source_destroyed))
.bind(source_quantity_after)
.bind(&application_id)
.execute(&mut *conn)
.await?;
Ok(Applied::Fresh(ConsumeOutcome {
applied: true,
action_identity: req.action_identity.to_string(),
source_owned_card_id: ctx.source.id.clone(),
source_destroyed,
source_quantity_after,
target_owned_card_id: ctx.target.as_ref().map(|t| t.id.clone()),
effect,
}))
}
.await;
match economy::finish(&mut conn, result).await? {
Applied::Fresh(outcome) => Ok(outcome),
// Read the recorded outcome on the SAME connection: acquiring a second one
// while still holding this one deadlocks a pool saturated with racing
// appliers, which is exactly the case a replay shows up in.
Applied::Replay => already_applied(&mut conn, profile_id, req.action_identity).await,
}
}
/// Has this identity already been applied? Read inside the transaction, under
/// the write lock, so the answer cannot go stale before the guard INSERT.
async fn recorded(
conn: &mut SqliteConnection,
profile_id: &str,
action_identity: &str,
) -> AppResult<bool> {
let hits = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM consumable_applications \
WHERE profile_id = ? AND action_identity = ?",
)
.bind(&effect_text)
.bind(i64::from(source_destroyed))
.bind(source_quantity_after)
.bind(&application_id)
.execute(&mut *tx)
.bind(profile_id)
.bind(action_identity)
.fetch_one(&mut *conn)
.await?;
tx.commit().await?;
Ok(ConsumeOutcome {
applied: true,
action_identity: req.action_identity.to_string(),
source_owned_card_id: ctx.source.id.clone(),
source_destroyed,
source_quantity_after,
target_owned_card_id: ctx.target.as_ref().map(|t| t.id.clone()),
effect,
})
Ok(hits > 0)
}
/// Echo the recorded outcome of an application that already happened. Mutates
/// nothing and reports `applied = false`.
async fn already_applied(
pool: &Pool,
conn: &mut SqliteConnection,
profile_id: &str,
action_identity: &str,
) -> AppResult<ConsumeOutcome> {
@@ -388,7 +451,7 @@ async fn already_applied(
)
.bind(profile_id)
.bind(action_identity)
.fetch_optional(pool)
.fetch_optional(&mut *conn)
.await?
.ok_or_else(|| {
AppError::Internal(anyhow::anyhow!(
@@ -621,6 +684,39 @@ mod tests {
);
}
/// A retry of a request that ALREADY succeeded must replay, not 404. With
/// `DestroyInstance` the source no longer exists by then, so answering the
/// replay has to precede source validation — otherwise a client that lost the
/// response to a successful apply is told its item was never there.
#[tokio::test]
async fn a_replay_survives_the_source_it_destroyed() {
let (_dir, _url, pool) = fixture().await;
let spend = || {
req(
"act-gone",
"single",
SourceConsumption::DestroyInstance,
"player",
)
};
let first = consume_item(&pool, "prof-a", "club-a", &spend(), &BumpTraining)
.await
.expect("first");
assert!(first.applied);
let replay = consume_item(&pool, "prof-a", "club-a", &spend(), &BumpTraining)
.await
.expect("a retry must replay, not fail on the destroyed source");
assert!(!replay.applied);
assert!(replay.source_destroyed);
assert_eq!(replay.effect, first.effect);
assert_eq!(training(&pool, "player").await, 1, "effect applied once");
assert_eq!(
count(&pool, "SELECT COUNT(*) FROM consumable_applications").await,
1
);
}
#[tokio::test]
async fn inline_closure_effect_is_accepted() {
let (_dir, _url, pool) = fixture().await;
+1 -1
View File
@@ -283,7 +283,7 @@ pub async fn list_unopened_entitlements(pool: &Pool, club_id: &str) -> AppResult
/// DEFERRED `pool.begin()` upgrades to a write only at the first write, where
/// SQLite returns SQLITE_BUSY *immediately* (bypassing the busy handler to avoid
/// deadlock) — the fresh-DB multi-connection write failure.
async fn finish<T>(conn: &mut SqliteConnection, result: AppResult<T>) -> AppResult<T> {
pub(crate) async fn finish<T>(conn: &mut SqliteConnection, result: AppResult<T>) -> AppResult<T> {
match result {
Ok(v) => {
sqlx::query("COMMIT").execute(&mut *conn).await?;
+116
View File
@@ -19,6 +19,8 @@
//! 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};
@@ -371,3 +373,117 @@ pub async fn apply_profile_import(
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,
})
}
File diff suppressed because it is too large Load Diff
+67 -1
View File
@@ -8,7 +8,9 @@ use crate::{
objective::ObjectiveDefinition,
profile::{coins_for_level, level_for_xp, pack_for_level, LevelUpEvent},
},
services::{achievement, card_db::CardDb, objective, season as season_svc, statistics},
services::{
achievement, card_db::CardDb, objective, season as season_svc, statistics, training,
},
};
use rand::{seq::SliceRandom, Rng};
use sqlx::{Sqlite, Transaction};
@@ -350,6 +352,7 @@ async fn complete_match_inner(
let mut level_ups = Vec::new();
let mut achievements_unlocked = Vec::new();
let mut expired_loans = Vec::new();
let mut expired_training = Vec::new();
let mut season_end = None;
// A no-contest is recorded (history + idempotency) but has ZERO economic
@@ -454,6 +457,12 @@ async fn complete_match_inner(
season_end =
season_svc::record_match_tx(&mut tx, club_id, profile_id, outcome, &now).await?;
}
// 9. One-match training effects are consumed by the players who took the
// field. Inside the same transaction and the same `is_economic`
// guard as everything else, so a NoContest voids it exactly as it
// voids coins and statistics, and a rollback leaves the boosts intact.
expired_training =
training::expire_for_instances_tx(&mut tx, club_id, &req.participants).await?;
}
inject_fault(fault, FaultPoint::BeforeCommit)?;
@@ -475,6 +484,7 @@ async fn complete_match_inner(
level_ups,
achievements_unlocked,
expired_loans,
expired_training,
season_end,
match_record,
})
@@ -525,6 +535,7 @@ async fn already_completed(
objectives_updated: vec![],
level_ups: vec![],
expired_loans: vec![],
expired_training: vec![],
season_end: None,
achievements_unlocked: vec![],
match_record,
@@ -664,6 +675,7 @@ mod match_completion_tests {
goal_positions: None,
expire_loans: false,
advance_season: false,
participants: vec![],
}
}
@@ -950,6 +962,60 @@ mod match_completion_tests {
}
}
/// Training expiry must be atomic with the match, in BOTH directions.
///
/// `BeforeCommit` is the discriminating fault: it fires AFTER the training
/// delete has already run inside the transaction. If the boost were removed
/// outside the transaction — or the transaction did not actually cover it —
/// the row would be gone here while the match itself rolled back, which is
/// exactly the split-brain state (match rejected, training consumed) that
/// must not exist.
#[tokio::test]
async fn a_rolled_back_match_leaves_training_intact() {
let fx = new_fixture().await;
sqlx::query(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) \
VALUES ('inst', ?, 'card', 0, 't')",
)
.bind(CLUB)
.execute(&fx.pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO owned_card_training \
(owned_card_id, attribute_index, amount, source_card_id, applied_at) \
VALUES ('inst', 4, 15, 'fifa17_5003012', 't')",
)
.execute(&fx.pool)
.await
.unwrap();
let mut r = req("m", MatchResultKind::Win, 3, 1);
r.participants = vec!["inst".into()];
let failed = complete_match_inner(
&fx.pool,
PROFILE,
CLUB,
&r,
&[],
&[],
Some(FaultPoint::BeforeCommit),
)
.await;
assert!(failed.is_err(), "the injected fault must fail the match");
assert_eq!(
count(&fx.pool, "owned_card_training").await,
1,
"a rolled-back match must NOT consume the boost"
);
// And the clean retry consumes it exactly once.
let ok = complete(&fx.pool, &r).await.unwrap();
assert_eq!(ok.expired_training, vec!["inst".to_string()]);
assert_eq!(count(&fx.pool, "owned_card_training").await, 0);
}
fn obj(id: &str, metric: ObjectiveMetric, target: i64) -> ObjectiveDefinition {
ObjectiveDefinition {
id: id.into(),
+2
View File
@@ -9,6 +9,7 @@ pub mod event;
pub mod fut_champs;
pub mod game_ext;
pub mod import;
pub mod instance_effect;
pub mod inventory;
pub mod market;
pub mod match_service;
@@ -22,4 +23,5 @@ pub mod settings;
pub mod squad;
pub mod squad_rules;
pub mod statistics;
pub mod training;
pub mod upgrades;
+2
View File
@@ -817,6 +817,8 @@ mod tests {
physical: 60,
rarity: Rarity::Bronze,
image_path: None,
// A player's rating IS `overall`; no separate authored value.
source_rating: None,
}
}
+151
View File
@@ -345,6 +345,32 @@ async fn replace_squad_inner(
}
};
// A replacement carrying no slots would DELETE every assignment below and
// insert nothing, silently emptying the squad. No product flow does that:
// a full-replacement client sends its COMPLETE slot array, so an empty list
// means the caller's own model was destroyed, not that the user emptied
// their squad. Mirroring that damage into the authority is unrecoverable,
// so refuse it.
//
// Observed for real: a FIFA 17 client whose in-memory squad had been
// destroyed by a bad parse wrote its emptiness back twice, taking
// `squad_players` from 18 rows to 0 while the request logged 200/ok.
//
// Checked inside the transaction so a concurrent write cannot slip between
// the count and the delete. A newly created squad counts 0 and is unaffected.
if replacement.slots.is_empty() {
let existing =
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM squad_players WHERE squad_id = ?")
.bind(&squad_id)
.fetch_one(&mut *tx)
.await?;
if existing > 0 {
return Err(AppError::BadRequest(format!(
"refusing to empty a populated squad: replacement carried no slots, but squad '{squad_id}' holds {existing} assignments"
)));
}
}
sqlx::query("DELETE FROM squad_players WHERE squad_id = ?")
.bind(&squad_id)
.execute(&mut *tx)
@@ -560,6 +586,131 @@ pub async fn read_squad_with_ext(
Ok((squad, players, state))
}
/// Outcome of a role-only squad patch.
pub struct SquadRolesPatched {
pub squad: Squad,
/// Re-anchored fingerprint of the committed canonical state. The captain
/// flag is part of the fingerprint, so a captain change MUST re-anchor the
/// extension or every later read reports it stale.
pub canonical_fingerprint: String,
/// Whether the captain flag actually moved (false when it was already set).
pub captain_changed: bool,
}
/// Patch ONLY a squad's role assignments plus its opaque game extension, in one
/// transaction. Never inserts, deletes or reorders a single assignment row.
///
/// This exists because a full replacement and a role-only update are different
/// operations that the FIFA 17 client sends down the same wire path. Routing a
/// role-only update through [`replace_squad_with_extension`] means presenting it
/// as a replacement carrying zero slots, which the empty-replacement guard
/// correctly refuses — the client's captain/kick-taker change was being lost
/// with a 400. The fix is to stop mis-describing the operation, NOT to relax the
/// guard: that guard is load-bearing and stays exactly as strict.
///
/// Player assignments, the squad manager and club actives are untouched by
/// construction — this function issues no statement that can affect them.
///
/// `captain_owned_card_id` must already be assigned to this squad. Anything else
/// is refused before any write, so an invalid target leaves the whole patch
/// unapplied (captain AND extension), never half-applied.
pub async fn patch_squad_roles(
pool: &Pool,
game_id: &str,
club_id: &str,
captain_owned_card_id: Option<&str>,
ext: &OpaqueExtensionWrite,
) -> AppResult<SquadRolesPatched> {
let now = chrono::Utc::now().to_rfc3339();
let mut tx = pool.begin().await?;
// Resolve the club's active squad. A role patch NEVER creates a squad: with
// no squad there is nothing to assign a captain within, and inventing one
// here would let a stray patch materialise empty canonical state.
let squad = sqlx::query_as::<_, Squad>(
"SELECT id, club_id, name, formation, created_at, updated_at FROM squads \
WHERE club_id = ? ORDER BY updated_at DESC LIMIT 1",
)
.bind(club_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| AppError::NotFound("no squad found for this club".into()))?;
let assigned = sqlx::query_as::<_, (String, i64, bool, bool)>(
"SELECT owned_card_id, position_index, is_captain, is_on_bench \
FROM squad_players WHERE squad_id = ?",
)
.bind(&squad.id)
.fetch_all(&mut *tx)
.await?;
let mut captain_changed = false;
if let Some(captain) = captain_owned_card_id {
// Validate against THIS squad's assignments, not the whole collection:
// a captain the user does not field is not a captain, and accepting an
// arbitrary owned card here would let a patch reference any inventory
// item.
// Validated BEFORE any write, so an invalid target aborts the whole
// patch — captain and extension both — rather than half-applying it.
if !assigned.iter().any(|(owned, _, _, _)| owned == captain) {
return Err(AppError::BadRequest(format!(
"captain '{captain}' is not assigned to squad '{}'",
squad.id
)));
}
let already_captain = assigned
.iter()
.any(|(owned, _, cap, _)| owned == captain && *cap);
let someone_else_captain = assigned
.iter()
.any(|(owned, _, cap, _)| *cap && owned != captain);
captain_changed = !already_captain || someone_else_captain;
sqlx::query("UPDATE squad_players SET is_captain = (owned_card_id = ?) WHERE squad_id = ?")
.bind(captain)
.bind(&squad.id)
.execute(&mut *tx)
.await?;
}
// Re-anchor to the state as it now stands, applying the captain move to the
// in-memory view rather than re-reading: same transaction, same result, one
// fewer round trip.
let canonical_fingerprint = squad_fingerprint(
&squad.id,
&squad.formation,
assigned.iter().map(|(owned, slot, cap, bench)| {
let is_cap = match captain_owned_card_id {
Some(c) => owned.as_str() == c,
None => *cap,
};
(*slot, owned.as_str(), is_cap, *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(game_id)
.bind(&squad.id)
.bind(&ext.namespace)
.bind(ext.schema_version)
.bind(&canonical_fingerprint)
.bind(&ext.payload)
.bind(&now)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(SquadRolesPatched {
squad,
canonical_fingerprint,
captain_changed,
})
}
/// Compatibility wrapper over [`replace_squad`].
///
/// Kept so the existing Core REST route keeps working, but it no longer has its
+175
View File
@@ -0,0 +1,175 @@
//! Reading per-instance attribute training back out for projection.
//!
//! Writing is [`crate::services::instance_effect::InstanceEffect::ApplyTraining`],
//! inside the one apply transaction. This module is the read half: it loads the
//! effects a club's instances carry and folds them onto a definition's
//! attributes.
//!
//! The fold lives in Core rather than in each game host on purpose. The stored
//! effect names a SLOT in Core's own card model, so only Core knows which field
//! slot 4 is; a host that did the arithmetic itself would have to re-derive that
//! mapping and could disagree with the next host. Core answers with the finished
//! numbers and the raw effects, and the host chooses which it needs.
use std::collections::HashMap;
use serde::Serialize;
use sqlx::FromRow;
use crate::{db::Pool, error::AppResult, models::card::CardDefinition};
/// The upper bound of a FIFA-style attribute. Training is added to a value whose
/// domain is 1..=99, so the fold clamps there.
///
/// This is a DOMAIN invariant of the six-attribute card model, not a reversed
/// training rule: whether FIFA 17 itself refuses to train a 95-pace player past
/// 99, or clamps like this, or wraps, is UNKNOWN. Clamping is the only behaviour
/// that keeps the projected card inside the model it is drawn from.
pub const ATTRIBUTE_MAX: i64 = 99;
/// The one training effect an instance may carry.
///
/// At most one per instance: FIFA 17 allows "one attribute or all six" and a new
/// card replaces the old, so a second concurrent effect is not representable.
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct TrainingEffect {
/// Slot in Core's six-attribute model, `CardDefinition` declaration order,
/// or `None` for an effect that boosts ALL SIX slots.
pub attribute_index: Option<i64>,
pub amount: i64,
pub source_card_id: String,
}
/// Every training effect held by the given club's instances, keyed by instance.
///
/// One query for the whole club rather than one per item: the projection walks
/// up to a couple of thousand owned rows, and a per-item lookup there is the
/// classic N+1 that has bitten this projection before.
pub async fn load_for_club(
pool: &Pool,
club_id: &str,
) -> AppResult<HashMap<String, TrainingEffect>> {
let rows = sqlx::query_as::<_, (String, Option<i64>, i64, String)>(
"SELECT t.owned_card_id, t.attribute_index, t.amount, t.source_card_id \
FROM owned_card_training t \
JOIN owned_cards o ON o.id = t.owned_card_id \
WHERE o.club_id = ?",
)
.bind(club_id)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(owned_card_id, attribute_index, amount, source_card_id)| {
(
owned_card_id,
TrainingEffect {
attribute_index,
amount,
source_card_id,
},
)
})
.collect())
}
/// Consume the training effects of the instances that took the field, inside a
/// caller-supplied transaction. Returns the instances actually cleared.
///
/// FIFA 17 training is a ONE-MATCH effect: it "is reflected in the following
/// match and expires after this", and a card applied to someone who stays on the
/// bench or in the reserves "will continue to benefit from the training effect
/// until he plays" (DOCUMENTED — fifauteam's contemporaneous FIFA 17 guide).
/// So the trigger is the PLAYER PLAYING, not the match merely completing, and
/// the caller must pass the instances that played — never a whole club.
///
/// `club_id` is not redundant with the ids: it scopes the delete so a caller
/// cannot expire another club's effects by guessing an instance id.
///
/// Idempotent by construction. Deleting an already-absent row is a no-op, so a
/// replayed match cannot "expire twice"; combined with the caller's
/// `match_completions` uniqueness guard, the mutation happens exactly once and a
/// replay is a silent no-op rather than a second effect.
pub async fn expire_for_instances_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
club_id: &str,
instance_ids: &[String],
) -> AppResult<Vec<String>> {
let mut expired = Vec::new();
for id in instance_ids {
// DELETE .. RETURNING so the report is what the database actually
// removed, not what we hoped it would: an id that carried no training,
// or belongs to another club, simply does not appear.
let hit: Option<(String,)> = sqlx::query_as(
"DELETE FROM owned_card_training \
WHERE owned_card_id = ? \
AND owned_card_id IN (SELECT id FROM owned_cards WHERE club_id = ?) \
RETURNING owned_card_id",
)
.bind(id)
.bind(club_id)
.fetch_optional(&mut **tx)
.await?;
if let Some((got,)) = hit {
expired.push(got);
}
}
Ok(expired)
}
/// The definition's six attributes in canonical slot order.
///
/// THIS ORDER IS THE CONTRACT that `attribute_index` indexes. It is
/// `CardDefinition`'s own declaration order, and changing it would silently
/// re-point every stored effect at a different attribute.
pub fn base_attributes(def: &CardDefinition) -> [i64; 6] {
[
def.pace as i64,
def.shooting as i64,
def.passing as i64,
def.dribbling as i64,
def.defending as i64,
def.physical as i64,
]
}
/// Base attributes with any training folded in, clamped to the model's domain.
///
/// A `None` slot boosts ALL SIX attributes — FIFA 17's rare "all" training card.
/// An out-of-range slot is ignored rather than panicking: the schema already
/// refuses one, so reaching this would mean the row was written around Core, and
/// dropping it degrades one attribute instead of failing every projection.
pub fn effective_attributes(def: &CardDefinition, effect: Option<&TrainingEffect>) -> [i64; 6] {
let mut out = base_attributes(def);
let Some(e) = effect else { return out };
match e.attribute_index {
Some(slot) => {
if let Some(v) = out.get_mut(slot as usize) {
*v = (*v + e.amount).clamp(0, ATTRIBUTE_MAX);
}
}
None => {
for v in out.iter_mut() {
*v = (*v + e.amount).clamp(0, ATTRIBUTE_MAX);
}
}
}
out
}
/// The same six values as a named object, for the projection envelope.
pub fn effective_attributes_json(
def: &CardDefinition,
effect: Option<&TrainingEffect>,
) -> serde_json::Value {
let a = effective_attributes(def, effect);
serde_json::json!({
"pace": a[0],
"shooting": a[1],
"passing": a[2],
"dribbling": a[3],
"defending": a[4],
"physical": a[5],
})
}
+103
View File
@@ -102,3 +102,106 @@ async fn preflight_passes_when_owned_card_definition_is_loaded() {
.await
.expect("preflight passes when the owned card's definition is loaded");
}
/// The LOAD-BEARING backwards-compatibility property: `source_rating` was added
/// to `CardDefinition` long after packs shipped, and `CardDefinition` has no
/// `#[serde(default)]`. Every already-emitted pack omits the key, so a pack
/// without it MUST still parse — and land as `None`, never as a fabricated 0
/// that a tier rule would read as bronze.
#[test]
fn content_pack_without_source_rating_still_parses() {
let dir = tempfile::tempdir().unwrap();
let pack = dir.path().join("legacy-pack.json");
std::fs::write(
&pack,
r#"[{"id":"legacy_1","name":"Legacy Player","overall":84,"position":"ST",
"nation":"Nation","league":"League","club":"Club","pace":80,
"shooting":85,"passing":70,"dribbling":82,"defending":40,
"physical":75,"rarity":"gold","image_path":null}]"#,
)
.unwrap();
let mut db = CardDb {
cards: Default::default(),
};
assert_eq!(db.load_pack(&pack).expect("legacy pack must load"), 1);
let def = db.get("legacy_1").expect("definition merged");
assert_eq!(def.overall, 84);
assert!(
def.source_rating.is_none(),
"a missing key is None, not a substituted 0"
);
}
/// A pack that DOES carry `source_rating` must round-trip through `CardDb` and
/// surface on `/collection` as `card.source_rating` — that envelope field is the
/// only authoritative staff/manager tier source a game host has. `overall` stays
/// 0 for the non-player because it feeds pricing and squad projection.
#[tokio::test]
async fn collection_surfaces_source_rating_for_a_non_player() {
let dir = tempfile::tempdir().unwrap();
let pack = dir.path().join("staff-pack.json");
std::fs::write(
&pack,
r#"[{"id":"fifa17_3000083","name":"Manager","overall":0,"position":"",
"nation":"","league":"","club":"","pace":0,"shooting":0,"passing":0,
"dribbling":0,"defending":0,"physical":0,"rarity":"bronze",
"image_path":null,"source_rating":88}]"#,
)
.unwrap();
let pool = fresh_pool().await;
let cfg = openfut_core::config::Config {
listen_addr: "127.0.0.1:0".into(),
database_url: "sqlite::memory:".into(),
data_dir: "data".into(),
max_connections: 1,
dev_content_games: Vec::new(),
content_packs: vec![pack.clone()],
};
let app = openfut_core::app::build(pool.clone(), cfg.clone())
.await
.expect("app build with the staff pack");
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-manager", &club, "fifa17_3000083").await;
// Rebuild so preflight sees the owned row, then read the envelope.
let app = openfut_core::app::build(pool.clone(), cfg)
.await
.expect("preflight passes: the pack carries the definition");
let resp = app
.oneshot(
Request::builder()
.uri("/collection")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let coll: serde_json::Value = serde_json::from_slice(&body).unwrap();
let entry = coll["collection"]
.as_array()
.unwrap()
.iter()
.find(|c| c["owned_card_id"] == serde_json::json!("oc-manager"))
.expect("the owned manager must project");
assert_eq!(entry["card"]["source_rating"], serde_json::json!(88));
assert_eq!(
entry["card"]["overall"],
serde_json::json!(0),
"overall stays 0 for a non-player: it feeds pricing and projection"
);
assert_eq!(
entry["effective_overall"],
serde_json::json!(0),
"the tier source must NOT leak into the projected overall"
);
}
+190
View File
@@ -287,3 +287,193 @@ async fn empty_owned_fails() {
.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");
}
+757
View File
@@ -1115,6 +1115,118 @@ async fn test_quick_sell_owned_card() {
assert_eq!(coins_after, coins_before + coins_received);
}
/// `POST /consumables/apply` end to end, in the exact wire shape a game host
/// sends: destroy the consumable, move the target's contract counter, surface it
/// on `/collection`, and REPLAY (not re-apply) a retried request.
///
/// Built on its own pool so a consumable instance can be minted directly — the
/// starter packs only yield players, and Core has no route that creates one.
#[tokio::test]
async fn test_apply_contract_consumable_over_http() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.connect("sqlite::memory:")
.await
.expect("in-memory sqlite");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrations");
let app = openfut_core::build_app(pool.clone(), "data")
.await
.expect("app build");
auth(&app, "ContractApplier").await;
let (_, packs) = json_get(&app, "/packs").await;
let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap().to_string();
let (s, _) = json_post(
&app,
&format!("/packs/open/{pack_id}"),
serde_json::json!({}),
)
.await;
assert_eq!(s, StatusCode::OK);
let (_, coll) = json_get(&app, "/collection").await;
let target = coll["collection"][0].clone();
let target_id = target["owned_card_id"].as_str().unwrap().to_string();
let card_id = target["card"]["id"].as_str().unwrap().to_string();
assert!(
target["contract_matches"].is_null(),
"a pack-fresh instance must report NULL, not a substituted default"
);
// Mint the consumable into the target's own club, reusing a definition the
// content pack already loaded so `/collection` can still project it.
sqlx::query(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at, content_kind) \
SELECT 'contract-card', club_id, ?, 0, ?, 'consumable' FROM owned_cards WHERE id = ?",
)
.bind(&card_id)
.bind("2026-01-01T00:00:00Z")
.bind(&target_id)
.execute(&pool)
.await
.expect("mint a consumable");
let request = serde_json::json!({
"action_identity": format!("fifa17:apply:contract-card->{target_id}"),
"source_owned_card_id": "contract-card",
"target_owned_card_id": target_id,
"target_kind": "player",
"effect": {
"kind": "add_contract_matches",
"amount": 15,
"cap": 99,
"default_when_unset": 7,
},
});
let (s, applied) = json_post(&app, "/consumables/apply", request.clone()).await;
assert_eq!(s, StatusCode::OK, "{applied}");
assert_eq!(applied["applied"], serde_json::json!(true));
assert_eq!(applied["source_destroyed"], serde_json::json!(true));
assert!(applied["source_quantity_after"].is_null());
assert_eq!(
applied["target_owned_card_id"],
serde_json::json!(target_id)
);
assert_eq!(
applied["effect"],
serde_json::json!({
"kind": "add_contract_matches", "granted": 15, "before": 7, "after": 22
})
);
let (_, after) = json_get(&app, "/collection").await;
let items = after["collection"].as_array().unwrap();
let projected = items
.iter()
.find(|c| c["owned_card_id"] == serde_json::json!(target_id))
.expect("target still owned");
assert_eq!(projected["contract_matches"], serde_json::json!(22));
assert!(
!items
.iter()
.any(|c| c["owned_card_id"] == serde_json::json!("contract-card")),
"the consumable must be spent, not merely marked"
);
// A retried request replays: no second grant, and no resurrection of the
// source it already destroyed.
let (s, replay) = json_post(&app, "/consumables/apply", request).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(replay["applied"], serde_json::json!(false));
assert_eq!(replay["effect"], applied["effect"]);
let (_, twice) = json_get(&app, "/collection").await;
let projected = twice["collection"]
.as_array()
.unwrap()
.iter()
.find(|c| c["owned_card_id"] == serde_json::json!(target_id))
.expect("target still owned")
.clone();
assert_eq!(projected["contract_matches"], serde_json::json!(22));
}
#[tokio::test]
async fn test_objective_get_by_id() {
let app = build_test_app().await;
@@ -2904,6 +3016,412 @@ async fn test_squad_ext_replace_read_roundtrip_and_idempotency() {
assert_eq!(other["extension"]["state"], "missing");
}
/// A full replacement that carries no slots MUST NOT empty a populated squad.
///
/// Regression: a FIFA 17 client whose in-memory squad had been destroyed by a
/// bad parse wrote that emptiness back through `/squad/replace`, taking the
/// canonical squad from 18 assignments to 0 while the request logged 200/ok.
/// The squad is the authority's state, so mirroring a broken client's model is
/// unrecoverable data loss.
#[tokio::test]
async fn test_squad_replace_refuses_to_empty_a_populated_squad() {
let app = build_test_app().await;
auth(&app, "SquadWipeGuardUser").await;
let (_, packs) = json_get(&app, "/packs").await;
let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap().to_string();
json_post(
&app,
&format!("/packs/open/{pack_id}"),
serde_json::json!({}),
)
.await;
let (_, coll) = json_get(&app, "/collection").await;
let ids: Vec<String> = coll["collection"]
.as_array()
.unwrap()
.iter()
.take(2)
.map(|c| c["owned_card_id"].as_str().unwrap().to_string())
.collect();
let ext_write = serde_json::json!({
"namespace": "fifa17.squad", "schema_version": 1, "payload": "{\"custom\":\"[1]\"}"
});
let client_reported = serde_json::json!({
"client_reported_chemistry": 52,
"client_reported_rating": 90,
"client_reported_star_rating": 90
});
let populate = serde_json::json!({
"name": "OpenFUT",
"formation": "f442",
"slots": [
{"owned_card_id": ids[0], "slot": 0, "is_captain": true, "is_on_bench": false},
{"owned_card_id": ids[1], "slot": 1, "is_captain": false, "is_on_bench": false},
],
"client_reported": client_reported,
"extension": ext_write,
});
let (s, put) = json_put(&app, "/squad/replace", populate).await;
assert_eq!(s, StatusCode::OK, "{put}");
assert_eq!(put["slots_written"], 2);
// The destructive write: a well-formed replacement that simply carries no
// slots. It must be REFUSED, not applied — this is the exact shape that
// emptied a real squad.
let (s, err) = json_put(
&app,
"/squad/replace",
serde_json::json!({
"name": "OpenFUT",
"formation": "f442",
"slots": [],
"client_reported": client_reported,
"extension": ext_write,
}),
)
.await;
assert_eq!(
s,
StatusCode::BAD_REQUEST,
"an empty replacement must be refused, not applied: {err}"
);
// The squad is untouched — the refusal rolled back, it did not half-apply.
let (s, ext) = json_get(&app, "/squad/ext?namespace=fifa17.squad").await;
assert_eq!(s, StatusCode::OK);
assert_eq!(
ext["players"].as_array().unwrap().len(),
2,
"both assignments survive the refused replacement"
);
}
/// A role-only patch must move the captain and re-anchor the extension WITHOUT
/// disturbing a single assignment.
///
/// Regression: FIFA 17's captain/kick-taker screen sends a body with no
/// `players`, which the host presented to `/squad/replace` as a replacement
/// carrying zero slots. The empty-replacement guard correctly refused it, so
/// every captain change died with a 400 (surfaced to the client as 502). The
/// operation, not the guard, was wrong.
#[tokio::test]
async fn test_squad_roles_patch_moves_captain_without_touching_assignments() {
let app = build_test_app().await;
auth(&app, "RolePatchUser").await;
let (_, packs) = json_get(&app, "/packs").await;
let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap().to_string();
json_post(
&app,
&format!("/packs/open/{pack_id}"),
serde_json::json!({}),
)
.await;
let (_, coll) = json_get(&app, "/collection").await;
let ids: Vec<String> = coll["collection"]
.as_array()
.unwrap()
.iter()
.take(2)
.map(|c| c["owned_card_id"].as_str().unwrap().to_string())
.collect();
let client_reported = serde_json::json!({
"client_reported_chemistry": 52,
"client_reported_rating": 90,
"client_reported_star_rating": 90
});
let (s, put) = json_put(
&app,
"/squad/replace",
serde_json::json!({
"name": "OpenFUT",
"formation": "f442",
"slots": [
{"owned_card_id": ids[0], "slot": 0, "is_captain": true, "is_on_bench": false},
{"owned_card_id": ids[1], "slot": 1, "is_captain": false, "is_on_bench": false},
],
"client_reported": client_reported,
"extension": {"namespace": "fifa17.squad", "schema_version": 1,
"payload": "{\"custom\":\"[1]\",\"kit_numbers\":{\"a\":7}}"},
}),
)
.await;
assert_eq!(s, StatusCode::OK, "{put}");
let before_fp = put["canonical_fingerprint"].as_str().unwrap().to_string();
// Move the captain to the second player, carrying a new opaque payload.
let (s, patched) = json_put(
&app,
"/squad/roles",
serde_json::json!({
"captain_owned_card_id": ids[1],
"extension": {"namespace": "fifa17.squad", "schema_version": 1,
"payload": "{\"custom\":\"[0,8,16]\",\"kit_numbers\":{\"a\":7}}"},
}),
)
.await;
assert_eq!(s, StatusCode::OK, "{patched}");
assert_eq!(patched["captain_changed"], true);
assert_ne!(
patched["canonical_fingerprint"].as_str().unwrap(),
before_fp,
"the captain is part of the fingerprint, so a captain move MUST re-anchor it"
);
let (s, ext) = json_get(&app, "/squad/ext?namespace=fifa17.squad").await;
assert_eq!(s, StatusCode::OK);
let players = ext["players"].as_array().unwrap();
assert_eq!(players.len(), 2, "a role patch must not add or drop slots");
let captain_of = |owned: &str| -> bool {
players
.iter()
.find(|p| p["owned_card_id"] == owned)
.map(|p| p["is_captain"] == true)
.unwrap_or(false)
};
assert!(captain_of(&ids[1]), "the new captain is flagged");
assert!(!captain_of(&ids[0]), "the previous captain is cleared");
// Fresh, not stale: the patch re-anchored the extension it wrote.
assert_eq!(
ext["extension"]["payload"], "{\"custom\":\"[0,8,16]\",\"kit_numbers\":{\"a\":7}}",
"the patch's payload is the one stored"
);
}
/// A role patch naming a captain who is not in the squad must change NOTHING —
/// not the captain, not the extension. All-or-nothing, validated before any write.
#[tokio::test]
async fn test_squad_roles_patch_rejects_unfielded_captain_and_rolls_back() {
let app = build_test_app().await;
auth(&app, "RolePatchRollbackUser").await;
let (_, packs) = json_get(&app, "/packs").await;
let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap().to_string();
json_post(
&app,
&format!("/packs/open/{pack_id}"),
serde_json::json!({}),
)
.await;
let (_, coll) = json_get(&app, "/collection").await;
let ids: Vec<String> = coll["collection"]
.as_array()
.unwrap()
.iter()
.take(3)
.map(|c| c["owned_card_id"].as_str().unwrap().to_string())
.collect();
let original_payload = "{\"custom\":\"[1]\"}";
let (s, _) = json_put(
&app,
"/squad/replace",
serde_json::json!({
"name": "OpenFUT",
"formation": "f442",
"slots": [
{"owned_card_id": ids[0], "slot": 0, "is_captain": true, "is_on_bench": false},
{"owned_card_id": ids[1], "slot": 1, "is_captain": false, "is_on_bench": false},
],
"client_reported": serde_json::json!({}),
"extension": {"namespace": "fifa17.squad", "schema_version": 1,
"payload": original_payload},
}),
)
.await;
assert_eq!(s, StatusCode::OK);
// ids[2] is owned but NOT fielded — a patch must not accept it.
let (s, err) = json_put(
&app,
"/squad/roles",
serde_json::json!({
"captain_owned_card_id": ids[2],
"extension": {"namespace": "fifa17.squad", "schema_version": 1,
"payload": "{\"custom\":\"[9,9,9]\"}"},
}),
)
.await;
assert_eq!(
s,
StatusCode::BAD_REQUEST,
"a captain not assigned to the squad must be refused: {err}"
);
let (s, ext) = json_get(&app, "/squad/ext?namespace=fifa17.squad").await;
assert_eq!(s, StatusCode::OK);
let players = ext["players"].as_array().unwrap();
assert!(
players
.iter()
.any(|p| p["owned_card_id"] == ids[0].as_str() && p["is_captain"] == true),
"the original captain survives a refused patch"
);
assert_eq!(
ext["extension"]["payload"], original_payload,
"the extension must NOT be written when the captain is refused"
);
}
/// `PUT /club/manager` must keep three states apart: absent = say nothing,
/// explicit null = remove, id = assign.
///
/// Regression: `owned_card_id` was a plain `Option<String>`, so serde collapsed
/// "field absent" and "field null" into the same `None` and the route treated
/// both as a clear. A caller with nothing to say therefore DELETED the manager —
/// how a FIFA 17 client with a destroyed squad model wiped a real manager row
/// (WAL commit 468, squad_managers 1 -> 0).
#[tokio::test]
async fn test_manager_absent_field_leaves_assignment_untouched() {
let app = build_test_app().await;
auth(&app, "ManagerGuardUser").await;
let (_, packs) = json_get(&app, "/packs").await;
let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap().to_string();
json_post(
&app,
&format!("/packs/open/{pack_id}"),
serde_json::json!({}),
)
.await;
let (_, coll) = json_get(&app, "/collection").await;
let ids: Vec<String> = coll["collection"]
.as_array()
.unwrap()
.iter()
.take(3)
.map(|c| c["owned_card_id"].as_str().unwrap().to_string())
.collect();
// A squad must exist for a manager to attach to.
let (s, _) = json_put(
&app,
"/squad/replace",
serde_json::json!({
"name": "OpenFUT", "formation": "f442",
"slots": [{"owned_card_id": ids[0], "slot": 0, "is_captain": true, "is_on_bench": false}],
"client_reported": {"client_reported_chemistry": 50, "client_reported_rating": 80,
"client_reported_star_rating": 80},
"extension": {"namespace": "fifa17.squad", "schema_version": 1, "payload": "{}"},
}),
)
.await;
assert_eq!(s, StatusCode::OK);
// Assign.
let (s, body) = json_put(
&app,
"/club/manager",
serde_json::json!({"owned_card_id": ids[1]}),
)
.await;
assert_eq!(s, StatusCode::OK, "{body}");
assert_eq!(body["manager"]["id"], ids[1].as_str());
// ABSENT field: the destructive shape. Must change nothing.
let (s, body) = json_put(&app, "/club/manager", serde_json::json!({})).await;
assert_eq!(s, StatusCode::OK, "{body}");
assert_eq!(
body["manager"]["id"],
ids[1].as_str(),
"an absent owned_card_id must LEAVE the manager, never clear it"
);
// Reassign to a different owned card: authentic, still allowed.
let (s, body) = json_put(
&app,
"/club/manager",
serde_json::json!({"owned_card_id": ids[2]}),
)
.await;
assert_eq!(s, StatusCode::OK, "{body}");
assert_eq!(body["manager"]["id"], ids[2].as_str());
// Same manager again: idempotent no-op, still assigned.
let (s, body) = json_put(
&app,
"/club/manager",
serde_json::json!({"owned_card_id": ids[2]}),
)
.await;
assert_eq!(s, StatusCode::OK, "{body}");
assert_eq!(body["manager"]["id"], ids[2].as_str());
// A card this club does not own is refused.
let (s, _) = json_put(
&app,
"/club/manager",
serde_json::json!({"owned_card_id": "not-a-real-owned-card"}),
)
.await;
assert_eq!(
s,
StatusCode::NOT_FOUND,
"an unowned manager must be refused"
);
let (_, body) = json_get(&app, "/club/manager").await;
assert_eq!(
body["manager"]["id"],
ids[2].as_str(),
"a refused assignment must not disturb the current manager"
);
// EXPLICIT null: a deliberate removal is legitimate and still works.
let (s, body) = json_put(
&app,
"/club/manager",
serde_json::json!({"owned_card_id": null}),
)
.await;
assert_eq!(s, StatusCode::OK, "{body}");
assert!(
body["manager"].is_null(),
"an explicit null must still remove the manager: {body}"
);
// Absent against a squad with NO manager: not over-guarded, plain no-op.
let (s, body) = json_put(&app, "/club/manager", serde_json::json!({})).await;
assert_eq!(s, StatusCode::OK, "{body}");
assert!(body["manager"].is_null());
// The squad's player assignment survived every one of those manager writes.
let (_, ext) = json_get(&app, "/squad/ext?namespace=fifa17.squad").await;
assert_eq!(
ext["players"].as_array().unwrap().len(),
1,
"manager writes must never disturb player assignments"
);
}
/// A malformed manager body is a PARSER rejection, distinguishable from the
/// guard's behaviour: a wrong-typed field is refused outright rather than being
/// silently treated as "absent" and passed through as a no-op.
#[tokio::test]
async fn test_manager_malformed_body_is_rejected_not_treated_as_absent() {
let app = build_test_app().await;
auth(&app, "ManagerMalformedUser").await;
let resp = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/club/manager")
.header("content-type", "application/json")
.body(Body::from(r#"{"owned_card_id": 12345}"#))
.unwrap(),
)
.await
.unwrap();
let s = resp.status();
assert!(
s == StatusCode::UNPROCESSABLE_ENTITY || s == StatusCode::BAD_REQUEST,
"a non-string owned_card_id must be a parser rejection, got {s}"
);
}
// ─────────────────────────── economy HTTP boundary ──────────────────────────
#[tokio::test]
@@ -3563,3 +4081,242 @@ async fn test_collection_reports_owned_rows_it_cannot_project() {
.collect();
assert!(!ids.contains(&"ghost"));
}
// ─────────────────── One-match training expiry (lifecycle row 12) ────────────
//
// FIFA 17 training is a ONE-MATCH effect that is consumed by the player PLAYING,
// not by the match merely completing: a card on someone who stays on the bench
// "will continue to benefit from the training effect until he plays"
// (DOCUMENTED). Core therefore expires exactly the instances the caller says
// took the field, and nothing else.
/// Seed a club with two owned instances, both carrying a training effect.
/// Returns `(club_id, played_id, benched_id)`.
async fn seed_two_trained(
app: &axum::Router,
pool: &sqlx::SqlitePool,
who: &str,
) -> (String, String, String) {
auth(app, who).await;
let club_id: String = sqlx::query_scalar("SELECT id FROM clubs LIMIT 1")
.fetch_one(pool)
.await
.expect("club exists after auth");
for id in ["played", "benched"] {
sqlx::query(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) \
VALUES (?, ?, 'card_raregold_001', 0, '2026-01-01T00:00:00Z')",
)
.bind(id)
.bind(&club_id)
.execute(pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO owned_card_training \
(owned_card_id, attribute_index, amount, source_card_id, applied_at) \
VALUES (?, 4, 15, 'fifa17_5003012', '2026-01-01T00:00:00Z')",
)
.bind(id)
.execute(pool)
.await
.unwrap();
}
(club_id, "played".to_string(), "benched".to_string())
}
async fn training_rows(pool: &sqlx::SqlitePool) -> Vec<String> {
sqlx::query_scalar("SELECT owned_card_id FROM owned_card_training ORDER BY owned_card_id")
.fetch_all(pool)
.await
.unwrap()
}
/// The core of the documented rule: only the players who took the field lose
/// their boost. Expiring the whole squad — or the whole club — would clear the
/// benched player the rule explicitly protects.
#[tokio::test]
async fn a_match_expires_training_only_for_the_players_who_played() {
let (app, pool) = build_test_app_with_pool().await;
let (_club, played, benched) = seed_two_trained(&app, &pool, "ExpiryScope").await;
let (status, body) = json_post(
&app,
"/matches/complete",
serde_json::json!({
"match_identity": "expiry-scope-1", "result": "win",
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 1, "goals_against": 0, "mode": "squad_battles",
"participants": [played]
}),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["expired_training"], serde_json::json!(["played"]));
assert_eq!(
training_rows(&pool).await,
vec![benched],
"the benched player must keep his boost"
);
}
/// A caller that cannot identify participants must be INERT, never a club wipe.
#[tokio::test]
async fn a_match_with_no_participants_expires_nothing() {
let (app, pool) = build_test_app_with_pool().await;
seed_two_trained(&app, &pool, "ExpiryNone").await;
let (status, body) = json_post(
&app,
"/matches/complete",
serde_json::json!({
"match_identity": "expiry-none-1", "result": "win",
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
}),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["expired_training"], serde_json::json!([]));
assert_eq!(training_rows(&pool).await, vec!["benched", "played"]);
}
/// Replay safety. The economic guard already stops double rewards; the training
/// mutation must ride the SAME canonical identity so a resubmitted completion
/// cannot consume a second, freshly-applied boost.
#[tokio::test]
async fn a_replayed_completion_does_not_expire_training_twice() {
let (app, pool) = build_test_app_with_pool().await;
let (_club, played, _benched) = seed_two_trained(&app, &pool, "ExpiryReplay").await;
let submit = || {
json_post(
&app,
"/matches/complete",
serde_json::json!({
"match_identity": "expiry-replay-1", "result": "win",
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 1, "goals_against": 0, "mode": "squad_battles",
"participants": [played]
}),
)
};
let (_, first) = submit().await;
assert_eq!(first["applied"], true);
assert_eq!(first["expired_training"], serde_json::json!(["played"]));
// Re-apply a boost to the same instance, then replay the SAME match.
sqlx::query(
"INSERT INTO owned_card_training \
(owned_card_id, attribute_index, amount, source_card_id, applied_at) \
VALUES ('played', 4, 15, 'fifa17_5003012', '2026-01-02T00:00:00Z')",
)
.execute(&pool)
.await
.unwrap();
let (_, second) = submit().await;
assert_eq!(second["applied"], false, "replay must not re-apply");
assert_eq!(
second["expired_training"],
serde_json::json!([]),
"a replay reports no mutation"
);
assert!(
training_rows(&pool).await.contains(&"played".to_string()),
"the replay must NOT consume the newly applied boost"
);
}
/// `NoContest` is a voided match: it grants no coins, XP or statistics, so it
/// must not consume a one-match effect either. Core's `is_economic` guard is the
/// single place that decides this, and training now sits inside it.
#[tokio::test]
async fn a_no_contest_match_does_not_expire_training() {
let (app, pool) = build_test_app_with_pool().await;
let (_club, played, _benched) = seed_two_trained(&app, &pool, "ExpiryVoid").await;
let (status, body) = json_post(
&app,
"/matches/complete",
serde_json::json!({
"match_identity": "expiry-void-1", "result": "no_contest",
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 0, "goals_against": 0, "mode": "squad_battles",
"participants": [played]
}),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["expired_training"], serde_json::json!([]));
assert_eq!(
training_rows(&pool).await,
vec!["benched", "played"],
"a voided match consumes nothing"
);
}
/// An id belonging to somebody else's club must not be expirable by guessing it.
#[tokio::test]
async fn training_expiry_is_scoped_to_the_completing_club() {
let (app, pool) = build_test_app_with_pool().await;
seed_two_trained(&app, &pool, "ExpiryScoped").await;
// A genuinely separate club, built properly so the FKs hold — the point of
// the test is club scoping, not a dangling row.
//
// created_at is deliberately in the FUTURE: `get_active_profile` selects
// `WHERE game_id = ? ORDER BY created_at ASC LIMIT 1`, and `game_id`
// defaults to 'fifa23' (migration 0016), so a rival dated earlier than the
// authed profile would silently BECOME the active profile and this test
// would assert the opposite of what it means.
sqlx::query(
"INSERT INTO profiles (id, username, created_at, updated_at) \
VALUES ('other-profile', 'Rival', '2099-01-01T00:00:00Z', '2099-01-01T00:00:00Z')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO clubs (id, profile_id, name, created_at, updated_at) \
VALUES ('other-club', 'other-profile', 'Rival FC', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) \
VALUES ('foreign', 'other-club', 'card_raregold_001', 0, '2026-01-01T00:00:00Z')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO owned_card_training \
(owned_card_id, attribute_index, amount, source_card_id, applied_at) \
VALUES ('foreign', 4, 15, 'fifa17_5003012', '2026-01-01T00:00:00Z')",
)
.execute(&pool)
.await
.unwrap();
let (status, body) = json_post(
&app,
"/matches/complete",
serde_json::json!({
"match_identity": "expiry-scoped-1", "result": "win",
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 1, "goals_against": 0, "mode": "squad_battles",
"participants": ["foreign"]
}),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(
body["expired_training"],
serde_json::json!([]),
"another club's effect must not be reachable"
);
assert!(training_rows(&pool).await.contains(&"foreign".to_string()));
}
+31 -4
View File
@@ -1,12 +1,18 @@
//! Owned-content model migrations (0025 content_kind/quantity, 0026
//! club_active_items, 0027 consumable_applications).
//! 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 (the migration is a pure widening, not a rewrite);
//! 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
@@ -205,6 +211,25 @@ async fn kit_assignments_migrate_into_club_active_items() {
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'")
@@ -300,10 +325,11 @@ async fn migrations_apply_to_a_real_populated_snapshot() {
.await
.expect("migrations must apply to real populated data");
let (after, players, stacked) = sqlx::query_as::<_, (i64, i64, i64)>(
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 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)
@@ -312,6 +338,7 @@ async fn migrations_apply_to_a_real_populated_snapshot() {
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);