7 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
17 changed files with 1961 additions and 12 deletions
+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;
+1
View File
@@ -241,6 +241,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/squad", post(routes::squad::post_squad)) .route("/squad", post(routes::squad::post_squad))
.route("/squad/ext", get(routes::squad::get_squad_ext)) .route("/squad/ext", get(routes::squad::get_squad_ext))
.route("/squad/replace", put(routes::squad::put_squad_replace)) .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", get(routes::squad::get_squads))
.route("/squads/:squad_id", get(routes::squad::get_squad_by_id)) .route("/squads/:squad_id", get(routes::squad::get_squad_by_id))
.route("/squads/:squad_id", delete(routes::squad::delete_squad)) .route("/squads/:squad_id", delete(routes::squad::delete_squad))
+14
View File
@@ -231,6 +231,20 @@ pub struct CardDefinition {
pub physical: u8, pub physical: u8,
pub rarity: Rarity, pub rarity: Rarity,
pub image_path: Option<String>, 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). /// One owned content INSTANCE (stored in DB).
+21
View File
@@ -133,6 +133,23 @@ pub struct CompleteMatchRequest {
/// its own wire). Only a caller using Core's season model opts in. /// its own wire). Only a caller using Core's season model opts in.
#[serde(default)] #[serde(default)]
pub advance_season: bool, 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`]. /// 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 /// Owned card ids removed because their loan expired on this match. Empty
/// unless the caller set `expire_loans`, and empty on a replay. /// unless the caller set `expire_loans`, and empty on a replay.
pub expired_loans: Vec<String>, 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 /// Present when this match ended a Core season. `None` unless the caller set
/// `advance_season`, and `None` on a replay. /// `advance_season`, and `None` on a replay.
pub season_end: Option<crate::models::season::SeasonEndSummary>, pub season_end: Option<crate::models::season::SeasonEndSummary>,
+15 -1
View File
@@ -13,7 +13,7 @@ use crate::{
services::{ services::{
club as club_svc, economy as economy_svc, club as club_svc, economy as economy_svc,
inventory::{self, OwnedItemQuery, OwnedItemView}, 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) .fetch_all(&state.pool)
.await?; .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 // 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 // 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 // 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_overall = def.overall as i64 + o.training_bonus;
let effective_position = o.position_override.as_deref().unwrap_or(&def.position); 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!({ let body = json!({
"owned_card_id": o.id, "owned_card_id": o.id,
"content_kind": o.content_kind, "content_kind": o.content_kind,
@@ -157,6 +169,8 @@ pub async fn get_collection(
"contract_matches": o.contract_matches, "contract_matches": o.contract_matches,
"effective_overall": effective_overall, "effective_overall": effective_overall,
"effective_position": effective_position, "effective_position": effective_position,
"effective_attributes": training_svc::effective_attributes_json(def, effect),
"training": effect,
"card": def, "card": def,
}); });
views.push(OwnedItemView { views.push(OwnedItemView {
+31 -7
View File
@@ -139,15 +139,36 @@ pub async fn get_squad_manager(
Ok(Json(json!({ "manager": 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)] #[derive(Deserialize)]
pub struct SetManagerRequest { pub struct SetManagerRequest {
/// The owned card to assign as manager, or `null`/absent to clear it. #[serde(default, deserialize_with = "deserialize_present_option")]
pub owned_card_id: Option<String>, pub owned_card_id: Option<Option<String>>,
} }
/// Assign (or, with a null/absent `owned_card_id`, clear) the active squad's /// Deserialize a field that is present-but-null into `Some(None)`, leaving an
/// manager. Fail-closed: the card must be owned by this club and the club must /// absent field as `None` (supplied by `#[serde(default)]`).
/// have a squad. Returns the resulting assignment. 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( pub async fn put_squad_manager(
State(state): State<AppState>, State(state): State<AppState>,
game: GameId, 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 profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?; let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
match req.owned_card_id { 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? 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?; let manager = club_svc::get_squad_manager(&state.pool, &club.id).await?;
Ok(Json(json!({ "manager": manager }))) Ok(Json(json!({ "manager": manager })))
+42
View File
@@ -235,3 +235,45 @@ pub async fn put_squad_replace(
"slots_written": out.slots_written, "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, squad_id: &str,
owned_card_id: &str, owned_card_id: &str,
) -> AppResult<()> { ) -> 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 = let squad_ok =
sqlx::query_scalar::<_, String>("SELECT id FROM squads WHERE id = ? AND club_id = ?") sqlx::query_scalar::<_, String>("SELECT id FROM squads WHERE id = ? AND club_id = ?")
.bind(squad_id) .bind(squad_id)
.bind(club_id) .bind(club_id)
.fetch_optional(pool) .fetch_optional(&mut *tx)
.await?; .await?;
if squad_ok.is_none() { if squad_ok.is_none() {
return Err(AppError::NotFound(format!("squad '{squad_id}' not found"))); 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 = ?") sqlx::query_scalar::<_, String>("SELECT id FROM owned_cards WHERE id = ? AND club_id = ?")
.bind(owned_card_id) .bind(owned_card_id)
.bind(club_id) .bind(club_id)
.fetch_optional(pool) .fetch_optional(&mut *tx)
.await?; .await?;
if card_ok.is_none() { if card_ok.is_none() {
return Err(AppError::NotFound(format!( return Err(AppError::NotFound(format!(
@@ -223,8 +228,9 @@ pub async fn set_squad_manager_for_squad(
.bind(squad_id) .bind(squad_id)
.bind(owned_card_id) .bind(owned_card_id)
.bind(&now) .bind(&now)
.execute(pool) .execute(&mut *tx)
.await?; .await?;
tx.commit().await?;
Ok(()) Ok(())
} }
+569
View File
@@ -18,6 +18,7 @@
//! present and future invariant unenforceable; a new effect is a new variant, //! present and future invariant unenforceable; a new effect is a new variant,
//! validated here, reviewed here. //! validated here, reviewed here.
use chrono::Utc;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use sqlx::SqliteConnection; use sqlx::SqliteConnection;
@@ -43,6 +44,32 @@ pub enum InstanceEffect {
cap: i64, cap: i64,
default_when_unset: i64, default_when_unset: i64,
}, },
/// Attach an attribute training effect to the target, REPLACING any the
/// instance already carries.
///
/// `attribute_index` is a slot in Core's own six-attribute card model, in
/// `CardDefinition` declaration order (0 pace .. 5 physical), or `None` for
/// an effect that boosts ALL SIX slots. Naming the slot rather than the
/// game's attribute is what keeps this game-neutral: that FIFA 17's "GK
/// speed" is slot 4 is the adapter's reversed knowledge, and it stays there.
///
/// REPLACEMENT, NOT ACCUMULATION, and at most one effect per instance. Both
/// halves are the caller's game rule, but they are enforced here because the
/// storage shape is Core's: FIFA 17's own documentation states "you can only
/// boost one attribute or all six" and "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."
///
/// `max_amount` is the caller's authored ceiling for the specific family
/// (FIFA 17: 15 single-attribute, 10 for the rare all-six card). Core cannot
/// know it, but it can refuse anything above the number the caller itself
/// declares, which is what stops a host describing a "+99 pace" no card
/// could grant.
ApplyTraining {
attribute_index: Option<i64>,
amount: i64,
max_amount: i64,
},
} }
impl ItemMutation for InstanceEffect { impl ItemMutation for InstanceEffect {
@@ -63,6 +90,17 @@ impl ItemMutation for InstanceEffect {
*cap, *cap,
*default_when_unset, *default_when_unset,
)), )),
InstanceEffect::ApplyTraining {
attribute_index,
amount,
max_amount,
} => Box::pin(apply_training(
tx,
ctx,
*attribute_index,
*amount,
*max_amount,
)),
} }
} }
} }
@@ -156,6 +194,109 @@ async fn add_contract_matches(
})) }))
} }
/// Core's own six-attribute card model is this wide. A slot outside it cannot
/// name anything Core can project.
const ATTRIBUTE_SLOTS: i64 = 6;
async fn apply_training(
tx: &mut SqliteConnection,
ctx: &ConsumeContext,
attribute_index: Option<i64>,
amount: i64,
max_amount: i64,
) -> AppResult<Value> {
let target = require_target(ctx, "apply_training")?;
if let Some(slot) = attribute_index {
if !(0..ATTRIBUTE_SLOTS).contains(&slot) {
return Err(AppError::BadRequest(format!(
"apply_training attribute_index must be 0..{ATTRIBUTE_SLOTS} or absent, got {slot}"
)));
}
}
if amount < 1 {
return Err(AppError::BadRequest(format!(
"apply_training amount must be >= 1, got {amount}"
)));
}
// The caller declares its own family's authored ceiling and is then held to
// it. Without this a host could describe an arbitrary boost through a
// vocabulary that exists precisely to prevent that.
if !(1..=99).contains(&max_amount) {
return Err(AppError::BadRequest(format!(
"apply_training max_amount must be 1..=99, got {max_amount}"
)));
}
if amount > max_amount {
return Err(AppError::BadRequest(format!(
"apply_training amount {amount} exceeds the caller's declared maximum {max_amount}"
)));
}
// Same reasoning as contracts: a loan is borrowed for a fixed run of
// matches, so durably improving it would outlive the thing it is attached
// to. Conservative and consistent rather than reversed -- no FIFA 17 source
// speaks to training a loan item.
if target.is_loan {
return Err(AppError::BadRequest(format!(
"training cannot be applied to a loan item ('{}')",
target.id
)));
}
// REPLACE. One instance carries at most one training effect, and a new card
// supersedes whatever was there -- including an effect on a DIFFERENT slot,
// because FIFA 17 allows "one attribute or all six" and never a mixture.
// Delete-then-insert inside the caller's transaction, so the old effect can
// never survive a failed insert and the two can never coexist.
let previous = sqlx::query_as::<_, (Option<i64>, i64, String)>(
"SELECT attribute_index, amount, source_card_id FROM owned_card_training \
WHERE owned_card_id = ?",
)
.bind(&target.id)
.fetch_optional(&mut *tx)
.await?;
sqlx::query("DELETE FROM owned_card_training WHERE owned_card_id = ?")
.bind(&target.id)
.execute(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO owned_card_training \
(owned_card_id, attribute_index, amount, source_card_id, applied_at) \
VALUES (?, ?, ?, ?, ?)",
)
.bind(&target.id)
.bind(attribute_index)
.bind(amount)
.bind(&ctx.source.card_id)
.bind(Utc::now().to_rfc3339())
.execute(&mut *tx)
.await?;
Ok(json!({
"kind": "apply_training",
"attribute_index": attribute_index,
"amount": amount,
// Named `granted` as well as `amount` so every effect's recorded outcome
// answers "what did this card award" under one key, whatever the family.
"granted": amount,
// `before`/`after` describe the training this instance holds, not the
// attribute's value: Core stores effects, and the attribute total is a
// projection over a definition Core does not consult here. `before` is
// the magnitude of the effect this one replaced, 0 when there was none.
"before": previous.as_ref().map(|(_, a, _)| *a).unwrap_or(0),
"after": amount,
// What was displaced, so the outcome records the replacement rather than
// silently overwriting history.
"replaced": previous.map(|(slot, amt, src)| json!({
"attribute_index": slot,
"amount": amt,
"source_card_id": src,
})),
}))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -244,6 +385,434 @@ mod tests {
} }
} }
fn train(attribute_index: i64, amount: i64) -> InstanceEffect {
InstanceEffect::ApplyTraining {
attribute_index: Some(attribute_index),
amount,
max_amount: 15,
}
}
/// The rare card: every slot, ceiling 10.
fn train_all(amount: i64) -> InstanceEffect {
InstanceEffect::ApplyTraining {
attribute_index: None,
amount,
max_amount: 10,
}
}
async fn training_of(pool: &db::Pool, id: &str) -> Vec<(Option<i64>, i64, String)> {
sqlx::query_as::<_, (Option<i64>, i64, String)>(
"SELECT attribute_index, amount, source_card_id FROM owned_card_training \
WHERE owned_card_id = ?",
)
.bind(id)
.fetch_all(pool)
.await
.expect("read training")
}
async fn source_exists(pool: &db::Pool, id: &str) -> bool {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards WHERE id = ?")
.bind(id)
.fetch_one(pool)
.await
.expect("count source")
== 1
}
/// The whole point: one card, one slot, one consumed source, recorded
/// against the definition that granted it.
#[tokio::test]
async fn training_attaches_to_the_slot_and_spends_the_card() {
let (_dir, pool) = fixture().await;
let out = consume_item(
&pool,
"prof",
"club",
&apply_request("act-1", "card-1", "fresh"),
&train(4, 10),
)
.await
.expect("apply");
assert!(out.applied);
assert!(out.source_destroyed);
assert_eq!(
out.effect,
json!({
"kind": "apply_training",
"attribute_index": 4,
"amount": 10,
"granted": 10,
"before": 0,
"after": 10,
"replaced": null,
})
);
assert_eq!(
training_of(&pool, "fresh").await,
vec![(Some(4), 10, "def-card-1".to_string())]
);
assert!(!source_exists(&pool, "card-1").await);
}
/// A second card on the SAME slot REPLACES the first and does not
/// accumulate: 10 then 15 leaves 15, never 25.
#[tokio::test]
async fn a_second_training_on_the_same_slot_replaces_rather_than_accumulating() {
let (_dir, pool) = fixture().await;
consume_item(
&pool,
"prof",
"club",
&apply_request("act-1", "card-1", "fresh"),
&train(4, 10),
)
.await
.expect("first apply");
let out = consume_item(
&pool,
"prof",
"club",
&apply_request("act-2", "card-2", "fresh"),
&train(4, 15),
)
.await
.expect("second apply replaces");
assert_eq!(
training_of(&pool, "fresh").await,
vec![(Some(4), 15, "def-card-2".to_string())],
"the newer effect must stand alone, not sum to 25"
);
assert_eq!(out.effect["before"], json!(10));
assert_eq!(out.effect["after"], json!(15));
assert_eq!(out.effect["replaced"]["amount"], json!(10));
// Both cards were legitimately spent.
assert!(!source_exists(&pool, "card-2").await);
}
/// A card on a DIFFERENT slot also replaces: FIFA 17 permits "one attribute
/// or all six", never a mixture, so two slots must never be boosted at once.
#[tokio::test]
async fn a_training_on_a_different_slot_still_replaces_the_previous_one() {
let (_dir, pool) = fixture().await;
for (identity, source, slot, amount) in
[("act-1", "card-1", 4, 10), ("act-2", "card-2", 1, 5)]
{
consume_item(
&pool,
"prof",
"club",
&apply_request(identity, source, "fresh"),
&train(slot, amount),
)
.await
.expect("apply");
}
assert_eq!(
training_of(&pool, "fresh").await,
vec![(Some(1), 5, "def-card-2".to_string())],
"only the newest effect may remain"
);
}
/// The rare card boosts every slot, and replaces a single-attribute effect
/// exactly like any other new card.
#[tokio::test]
async fn the_all_six_card_stores_no_slot_and_replaces_a_single_attribute_effect() {
let (_dir, pool) = fixture().await;
consume_item(
&pool,
"prof",
"club",
&apply_request("act-1", "card-1", "fresh"),
&train(4, 15),
)
.await
.expect("single-attribute apply");
consume_item(
&pool,
"prof",
"club",
&apply_request("act-2", "card-2", "fresh"),
&train_all(10),
)
.await
.expect("all-six apply");
assert_eq!(
training_of(&pool, "fresh").await,
vec![(None, 10, "def-card-2".to_string())],
"the all-six effect stores a NULL slot and stands alone"
);
}
/// The all-six card authors at most +10; a caller declaring that ceiling
/// cannot then push +15 through it.
#[tokio::test]
async fn the_all_six_ceiling_is_enforced_independently() {
let (_dir, pool) = fixture().await;
let err = consume_item(
&pool,
"prof",
"club",
&apply_request("act-1", "card-1", "fresh"),
&InstanceEffect::ApplyTraining {
attribute_index: None,
amount: 15,
max_amount: 10,
},
)
.await
.expect_err("an over-ceiling all-six boost must be refused");
assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}");
assert!(training_of(&pool, "fresh").await.is_empty());
assert!(source_exists(&pool, "card-1").await);
}
/// The caller declares its own family's ceiling and is held to it. This is
/// what stops a host describing a boost no card could grant.
#[tokio::test]
async fn an_amount_above_the_callers_declared_maximum_is_refused() {
let (_dir, pool) = fixture().await;
let err = consume_item(
&pool,
"prof",
"club",
&apply_request("act-1", "card-1", "fresh"),
&train(0, 99),
)
.await
.expect_err("over-max must be refused");
assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}");
assert!(training_of(&pool, "fresh").await.is_empty());
assert!(source_exists(&pool, "card-1").await);
}
/// A slot outside Core's six-attribute model names nothing projectable.
#[tokio::test]
async fn a_slot_outside_the_card_model_is_refused() {
let (_dir, pool) = fixture().await;
for slot in [-1i64, 6, 99] {
let err = consume_item(
&pool,
"prof",
"club",
&apply_request("act-x", "card-1", "fresh"),
&train(slot, 5),
)
.await
.expect_err("out-of-range slot must be refused");
assert!(
matches!(err, AppError::BadRequest(_)),
"slot {slot}: {err:?}"
);
}
assert!(source_exists(&pool, "card-1").await);
}
/// Same reasoning as contracts: a loan outlives neither its match budget nor
/// the improvement, so training it is refused rather than quietly wasted.
#[tokio::test]
async fn training_a_loan_item_is_refused_and_the_source_survives() {
let (_dir, pool) = fixture().await;
let err = consume_item(
&pool,
"prof",
"club",
&apply_request("act-1", "card-1", "loaned"),
&train(0, 5),
)
.await
.expect_err("loan target must be refused");
assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}");
assert!(training_of(&pool, "loaned").await.is_empty());
assert!(source_exists(&pool, "card-1").await);
}
/// A transport retry of the SAME action must not train twice or spend two
/// cards — the guard is the same one contracts rely on.
#[tokio::test]
async fn a_training_replay_neither_trains_nor_charges_twice() {
let (_dir, pool) = fixture().await;
let first = consume_item(
&pool,
"prof",
"club",
&apply_request("act-1", "card-1", "fresh"),
&train(2, 15),
)
.await
.expect("first");
assert!(first.applied);
let replay = consume_item(
&pool,
"prof",
"club",
&apply_request("act-1", "card-1", "fresh"),
&train(2, 15),
)
.await
.expect("replay");
assert!(!replay.applied, "a replay must not report a fresh apply");
assert_eq!(replay.effect, first.effect);
assert_eq!(
training_of(&pool, "fresh").await,
vec![(Some(2), 15, "def-card-1".to_string())]
);
}
/// Destroying a trained instance must not leave its training behind — the
/// FK cascade is what guarantees a quick-sold card cannot haunt the table.
#[tokio::test]
async fn training_dies_with_the_instance_it_is_attached_to() {
let (_dir, pool) = fixture().await;
consume_item(
&pool,
"prof",
"club",
&apply_request("act-1", "card-1", "fresh"),
&train(3, 5),
)
.await
.expect("apply");
assert_eq!(training_of(&pool, "fresh").await.len(), 1);
sqlx::query("DELETE FROM owned_cards WHERE id = 'fresh'")
.execute(&pool)
.await
.expect("delete instance");
assert!(training_of(&pool, "fresh").await.is_empty());
}
/// APPLY vs QUICK-SELL on the LAST copy of a source. Exactly one may win:
/// the card is either spent on the target or sold for coins, never both.
///
/// Both paths are single Core transactions over the same row, so the loser
/// must fail rather than operate on an already-gone source. This is the race
/// a real player creates by hammering Enter on the consumables screen while a
/// quick-sell is in flight.
#[tokio::test]
async fn apply_and_quick_sell_cannot_both_spend_one_card() {
use crate::services::economy::{self, SaleBuyer, SaleTerms};
// Repeated because a race that only sometimes interleaves would pass by
// luck on a single attempt.
for round in 0..12 {
let (_dir, pool) = fixture().await;
let apply_pool = pool.clone();
let sell_pool = pool.clone();
let applied = tokio::spawn(async move {
consume_item(
&apply_pool,
"prof",
"club",
&apply_request("act-race", "card-1", "fresh"),
&train(0, 5),
)
.await
});
let sold = tokio::spawn(async move {
economy::settle_sale(
&sell_pool,
"card-1",
"club",
SaleBuyer::Outside,
SaleTerms { gross: 100, fee: 0 },
)
.await
});
let applied = applied.await.expect("apply task");
let sold = sold.await.expect("sell task");
let trained = !training_of(&pool, "fresh").await.is_empty();
let coins = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id='club'")
.fetch_one(&pool)
.await
.expect("coins");
match (applied.is_ok(), sold.is_ok()) {
(true, false) => {
assert!(trained, "round {round}: apply won but left no training");
assert_eq!(coins, 0, "round {round}: apply won but coins moved");
}
(false, true) => {
assert!(!trained, "round {round}: sale won but training was written");
assert_eq!(coins, 100, "round {round}: sale won but paid nothing");
}
(a, s) => panic!("round {round}: exactly one must win, got apply={a} sale={s}"),
}
// Either way the card is gone exactly once.
assert!(
!source_exists(&pool, "card-1").await,
"round {round}: the source survived a winner"
);
}
}
/// Two CONCURRENT applies of the same last copy, under DIFFERENT identities
/// (so the replay guard is not what separates them) and onto different
/// slots. One must win outright: one training written, one card spent, one
/// audit row.
#[tokio::test]
async fn two_concurrent_applies_of_one_card_produce_exactly_one_effect() {
for round in 0..12 {
let (_dir, pool) = fixture().await;
let a_pool = pool.clone();
let b_pool = pool.clone();
let a = tokio::spawn(async move {
consume_item(
&a_pool,
"prof",
"club",
&apply_request("act-a", "card-1", "fresh"),
&train(0, 5),
)
.await
});
let b = tokio::spawn(async move {
consume_item(
&b_pool,
"prof",
"club",
&apply_request("act-b", "card-1", "fresh"),
&train(1, 5),
)
.await
});
let (a, b) = (a.await.expect("a"), b.await.expect("b"));
assert!(
a.is_ok() ^ b.is_ok(),
"round {round}: exactly one apply must win, got a={:?} b={:?}",
a.is_ok(),
b.is_ok()
);
assert_eq!(
training_of(&pool, "fresh").await.len(),
1,
"round {round}: exactly one training effect must exist"
);
let audits = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM consumable_applications WHERE profile_id = 'prof'",
)
.fetch_one(&pool)
.await
.expect("audit count");
assert_eq!(audits, 1, "round {round}: exactly one audit row");
assert!(!source_exists(&pool, "card-1").await);
}
}
async fn contract_of(pool: &db::Pool, id: &str) -> Option<i64> { async fn contract_of(pool: &db::Pool, id: &str) -> Option<i64> {
sqlx::query_scalar::<_, Option<i64>>( sqlx::query_scalar::<_, Option<i64>>(
"SELECT contract_matches FROM owned_cards WHERE id = ?", "SELECT contract_matches FROM owned_cards WHERE id = ?",
+67 -1
View File
@@ -8,7 +8,9 @@ use crate::{
objective::ObjectiveDefinition, objective::ObjectiveDefinition,
profile::{coins_for_level, level_for_xp, pack_for_level, LevelUpEvent}, 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 rand::{seq::SliceRandom, Rng};
use sqlx::{Sqlite, Transaction}; use sqlx::{Sqlite, Transaction};
@@ -350,6 +352,7 @@ async fn complete_match_inner(
let mut level_ups = Vec::new(); let mut level_ups = Vec::new();
let mut achievements_unlocked = Vec::new(); let mut achievements_unlocked = Vec::new();
let mut expired_loans = Vec::new(); let mut expired_loans = Vec::new();
let mut expired_training = Vec::new();
let mut season_end = None; let mut season_end = None;
// A no-contest is recorded (history + idempotency) but has ZERO economic // A no-contest is recorded (history + idempotency) but has ZERO economic
@@ -454,6 +457,12 @@ async fn complete_match_inner(
season_end = season_end =
season_svc::record_match_tx(&mut tx, club_id, profile_id, outcome, &now).await?; 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)?; inject_fault(fault, FaultPoint::BeforeCommit)?;
@@ -475,6 +484,7 @@ async fn complete_match_inner(
level_ups, level_ups,
achievements_unlocked, achievements_unlocked,
expired_loans, expired_loans,
expired_training,
season_end, season_end,
match_record, match_record,
}) })
@@ -525,6 +535,7 @@ async fn already_completed(
objectives_updated: vec![], objectives_updated: vec![],
level_ups: vec![], level_ups: vec![],
expired_loans: vec![], expired_loans: vec![],
expired_training: vec![],
season_end: None, season_end: None,
achievements_unlocked: vec![], achievements_unlocked: vec![],
match_record, match_record,
@@ -664,6 +675,7 @@ mod match_completion_tests {
goal_positions: None, goal_positions: None,
expire_loans: false, expire_loans: false,
advance_season: 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 { fn obj(id: &str, metric: ObjectiveMetric, target: i64) -> ObjectiveDefinition {
ObjectiveDefinition { ObjectiveDefinition {
id: id.into(), id: id.into(),
+1
View File
@@ -23,4 +23,5 @@ pub mod settings;
pub mod squad; pub mod squad;
pub mod squad_rules; pub mod squad_rules;
pub mod statistics; pub mod statistics;
pub mod training;
pub mod upgrades; pub mod upgrades;
+2
View File
@@ -817,6 +817,8 @@ mod tests {
physical: 60, physical: 60,
rarity: Rarity::Bronze, rarity: Rarity::Bronze,
image_path: None, 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 = ?") sqlx::query("DELETE FROM squad_players WHERE squad_id = ?")
.bind(&squad_id) .bind(&squad_id)
.execute(&mut *tx) .execute(&mut *tx)
@@ -560,6 +586,131 @@ pub async fn read_squad_with_ext(
Ok((squad, players, state)) 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`]. /// Compatibility wrapper over [`replace_squad`].
/// ///
/// Kept so the existing Core REST route keeps working, but it no longer has its /// 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 .await
.expect("preflight passes when the owned card's definition is loaded"); .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"
);
}
+645
View File
@@ -3016,6 +3016,412 @@ async fn test_squad_ext_replace_read_roundtrip_and_idempotency() {
assert_eq!(other["extension"]["state"], "missing"); 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 ────────────────────────── // ─────────────────────────── economy HTTP boundary ──────────────────────────
#[tokio::test] #[tokio::test]
@@ -3675,3 +4081,242 @@ async fn test_collection_reports_owned_rows_it_cannot_project() {
.collect(); .collect();
assert!(!ids.contains(&"ghost")); 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()));
}