Compare commits
8 Commits
233df1d99d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 20e281e0cf | |||
| 8819cc76a1 | |||
| 9bdc1633a0 | |||
| 1df03d4287 | |||
| 90210702c3 | |||
| a45155e0c5 | |||
| 82c3d2c85a | |||
| e8be289660 |
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -192,6 +192,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
post(routes::economy::post_redeem_entitlement),
|
post(routes::economy::post_redeem_entitlement),
|
||||||
)
|
)
|
||||||
.route("/economy/sell-item", post(routes::economy::post_sell_item))
|
.route("/economy/sell-item", post(routes::economy::post_sell_item))
|
||||||
|
.route(
|
||||||
|
"/consumables/apply",
|
||||||
|
post(routes::consumables::post_apply_consumable),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/economy/grant-reward",
|
"/economy/grant-reward",
|
||||||
post(routes::economy::post_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", 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))
|
||||||
|
|||||||
+19
-1
@@ -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).
|
||||||
@@ -252,6 +266,10 @@ pub struct OwnedCard {
|
|||||||
pub training_bonus: i64,
|
pub training_bonus: i64,
|
||||||
pub content_kind: ContentKind,
|
pub content_kind: ContentKind,
|
||||||
pub quantity: Option<i64>,
|
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`].
|
/// 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.
|
/// never leave a stale SELECT behind; append `WHERE …` to it.
|
||||||
pub const OWNED_CARD_SELECT: &str = "SELECT id, club_id, card_id, is_loan, \
|
pub const OWNED_CARD_SELECT: &str = "SELECT id, club_id, card_id, is_loan, \
|
||||||
loan_matches_remaining, acquired_at, chemistry_style, position_override, \
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
+19
-1
@@ -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,
|
||||||
@@ -151,8 +163,14 @@ pub async fn get_collection(
|
|||||||
"chemistry_style": o.chemistry_style,
|
"chemistry_style": o.chemistry_style,
|
||||||
"position_override": o.position_override,
|
"position_override": o.position_override,
|
||||||
"training_bonus": o.training_bonus,
|
"training_bonus": o.training_bonus,
|
||||||
|
// Core's stored value verbatim: `null` means Core tracks no contract
|
||||||
|
// for this instance, which is NOT zero. Substituting a default here
|
||||||
|
// would bake one game's pack-fresh number into every game's envelope.
|
||||||
|
"contract_matches": o.contract_matches,
|
||||||
"effective_overall": effective_overall,
|
"effective_overall": effective_overall,
|
||||||
"effective_position": effective_position,
|
"effective_position": effective_position,
|
||||||
|
"effective_attributes": training_svc::effective_attributes_json(def, effect),
|
||||||
|
"training": effect,
|
||||||
"card": def,
|
"card": def,
|
||||||
});
|
});
|
||||||
views.push(OwnedItemView {
|
views.push(OwnedItemView {
|
||||||
|
|||||||
+31
-7
@@ -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 })))
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ pub mod achievements;
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod cards;
|
pub mod cards;
|
||||||
pub mod club;
|
pub mod club;
|
||||||
|
pub mod consumables;
|
||||||
pub mod division;
|
pub mod division;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
pub mod economy;
|
pub mod economy;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+139
-43
@@ -2,17 +2,24 @@
|
|||||||
//!
|
//!
|
||||||
//! ONE Core transaction that does, in this order and nothing else:
|
//! 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;
|
//! `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;
|
//! 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
|
//! `consumable_applications`, so a duplicate is refused BEFORE anything is
|
||||||
//! mutated or consumed (same discipline as `match_completions`);
|
//! mutated or consumed (same discipline as `match_completions`). Step 1 is
|
||||||
//! 4. apply the caller's mutation to the target;
|
//! a courtesy; THIS is the guarantee, and it holds against a writer on any
|
||||||
//! 5. consume the source EXACTLY ONCE — destroy the instance, or decrement its
|
//! 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;
|
//! stack and destroy it at zero;
|
||||||
//! 6. commit.
|
//! 7. commit.
|
||||||
//!
|
//!
|
||||||
//! Anything failing at any step rolls the whole thing back: the source is never
|
//! 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
|
//! 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,
|
//! Core deliberately supplies **no per-category formula**. What a fitness card,
|
||||||
//! a contract, a chemistry style or a position modifier actually DOES to its
|
//! 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
|
//! target is the calling game adapter's reversed behaviour, passed in as
|
||||||
//! [`ItemMutation`]; an unreversed behaviour must not be invented here, and the
|
//! [`ItemMutation`] — either in-process, or described over the wire through the
|
||||||
//! honest stopping point for one is ownership + projection, i.e. not calling
|
//! closed, Core-validated vocabulary in [`crate::services::instance_effect`]. An
|
||||||
//! this function at all.
|
//! 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::future::Future;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
@@ -38,6 +46,7 @@ use crate::{
|
|||||||
db::Pool,
|
db::Pool,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::card::{ContentKind, OwnedCard, OWNED_CARD_SELECT},
|
models::card::{ContentKind, OwnedCard, OWNED_CARD_SELECT},
|
||||||
|
services::economy,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// What the transaction does to the source instance once the effect is applied.
|
/// 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(())
|
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.
|
/// Apply one consumable to one target, exactly once. See the module docs.
|
||||||
pub async fn consume_item<M: ItemMutation>(
|
pub async fn consume_item<M: ItemMutation>(
|
||||||
pool: &Pool,
|
pool: &Pool,
|
||||||
@@ -197,20 +216,40 @@ 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 result = async {
|
||||||
let source = fetch_owned(&mut tx, req.source_owned_card_id, club_id).await?;
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")?;
|
require_kind(&source, req.expected_source_kind, "source item")?;
|
||||||
|
|
||||||
// A source that is fielded in a squad cannot be consumed: `squad_players`
|
// 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.
|
// holds a FK onto `owned_cards(id)`, so the DELETE below would fail anyway.
|
||||||
// Refuse explicitly instead of surfacing SQLITE_CONSTRAINT, and never
|
// Refuse explicitly instead of surfacing SQLITE_CONSTRAINT, and never
|
||||||
// silently evict a lineup as a side effect of spending an item.
|
// silently evict a lineup as a side effect of spending an item.
|
||||||
let fielded =
|
let fielded = sqlx::query_scalar::<_, i64>(
|
||||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM squad_players WHERE owned_card_id = ?")
|
"SELECT COUNT(*) FROM squad_players WHERE owned_card_id = ?",
|
||||||
|
)
|
||||||
.bind(req.source_owned_card_id)
|
.bind(req.source_owned_card_id)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *conn)
|
||||||
.await?;
|
.await?;
|
||||||
if fielded > 0 {
|
if fielded > 0 {
|
||||||
return Err(AppError::Conflict(format!(
|
return Err(AppError::Conflict(format!(
|
||||||
@@ -219,22 +258,24 @@ pub async fn consume_item<M: ItemMutation>(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. target.
|
// 3. target.
|
||||||
let target = match req.target {
|
let target = match req.target {
|
||||||
ConsumeTarget::OwnedCard {
|
ConsumeTarget::OwnedCard {
|
||||||
owned_card_id,
|
owned_card_id,
|
||||||
expected_kind,
|
expected_kind,
|
||||||
} => {
|
} => {
|
||||||
let card = fetch_owned(&mut tx, owned_card_id, club_id).await?;
|
let card = fetch_owned(&mut conn, owned_card_id, club_id).await?;
|
||||||
require_kind(&card, expected_kind, "target item")?;
|
require_kind(&card, expected_kind, "target item")?;
|
||||||
Some(card)
|
Some(card)
|
||||||
}
|
}
|
||||||
ConsumeTarget::Club => None,
|
ConsumeTarget::Club => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3. replay guard FIRST — before the mutation and before the consumption, so
|
// 4. replay guard — the durable one. It precedes the mutation and the
|
||||||
// a duplicate cannot apply a second effect or spend a second charge. The
|
// consumption, so a duplicate that got past step 1 on another
|
||||||
// recorded `effect` is filled in below, once the mutation has produced it.
|
// 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 application_id = Uuid::new_v4().to_string();
|
||||||
let now = Utc::now().to_rfc3339();
|
let now = Utc::now().to_rfc3339();
|
||||||
let guard = sqlx::query(
|
let guard = sqlx::query(
|
||||||
@@ -252,30 +293,26 @@ pub async fn consume_item<M: ItemMutation>(
|
|||||||
.bind(source.content_kind.as_str())
|
.bind(source.content_kind.as_str())
|
||||||
.bind(target.as_ref().map(|t| t.id.as_str()))
|
.bind(target.as_ref().map(|t| t.id.as_str()))
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *conn)
|
||||||
.await;
|
.await;
|
||||||
match guard {
|
match guard {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
// A collision writes nothing, so this transaction has nothing to undo
|
||||||
tx.rollback().await?;
|
// and simply ends; the recorded outcome is read back afterwards.
|
||||||
return already_applied(pool, profile_id, req.action_identity).await;
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => return Ok(Applied::Replay),
|
||||||
}
|
Err(e) => return Err(e.into()),
|
||||||
Err(e) => {
|
|
||||||
tx.rollback().await?;
|
|
||||||
return Err(e.into());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. the caller's effect on the target, inside this transaction.
|
// 5. the caller's effect on the target, inside this transaction.
|
||||||
let ctx = ConsumeContext {
|
let ctx = ConsumeContext {
|
||||||
profile_id: profile_id.to_string(),
|
profile_id: profile_id.to_string(),
|
||||||
club_id: club_id.to_string(),
|
club_id: club_id.to_string(),
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
};
|
};
|
||||||
let effect = mutation.apply(&mut tx, &ctx).await?;
|
let effect = mutation.apply(&mut conn, &ctx).await?;
|
||||||
|
|
||||||
// 5. consume the source exactly once. Both paths assert rows_affected == 1,
|
// 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
|
// 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
|
// lock and now sees the row gone / already decremented) fails instead of
|
||||||
// granting a second effect.
|
// granting a second effect.
|
||||||
@@ -284,7 +321,7 @@ pub async fn consume_item<M: ItemMutation>(
|
|||||||
let deleted = sqlx::query("DELETE FROM owned_cards WHERE id = ? AND club_id = ?")
|
let deleted = sqlx::query("DELETE FROM owned_cards WHERE id = ? AND club_id = ?")
|
||||||
.bind(&ctx.source.id)
|
.bind(&ctx.source.id)
|
||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *conn)
|
||||||
.await?
|
.await?
|
||||||
.rows_affected();
|
.rows_affected();
|
||||||
if deleted != 1 {
|
if deleted != 1 {
|
||||||
@@ -316,7 +353,7 @@ pub async fn consume_item<M: ItemMutation>(
|
|||||||
.bind(&ctx.source.id)
|
.bind(&ctx.source.id)
|
||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
.bind(have)
|
.bind(have)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *conn)
|
||||||
.await?
|
.await?
|
||||||
.rows_affected();
|
.rows_affected();
|
||||||
if deleted != 1 {
|
if deleted != 1 {
|
||||||
@@ -335,7 +372,7 @@ pub async fn consume_item<M: ItemMutation>(
|
|||||||
.bind(&ctx.source.id)
|
.bind(&ctx.source.id)
|
||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
.bind(have)
|
.bind(have)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *conn)
|
||||||
.await?
|
.await?
|
||||||
.rows_affected();
|
.rows_affected();
|
||||||
if updated != 1 {
|
if updated != 1 {
|
||||||
@@ -358,12 +395,10 @@ pub async fn consume_item<M: ItemMutation>(
|
|||||||
.bind(i64::from(source_destroyed))
|
.bind(i64::from(source_destroyed))
|
||||||
.bind(source_quantity_after)
|
.bind(source_quantity_after)
|
||||||
.bind(&application_id)
|
.bind(&application_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *conn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
tx.commit().await?;
|
Ok(Applied::Fresh(ConsumeOutcome {
|
||||||
|
|
||||||
Ok(ConsumeOutcome {
|
|
||||||
applied: true,
|
applied: true,
|
||||||
action_identity: req.action_identity.to_string(),
|
action_identity: req.action_identity.to_string(),
|
||||||
source_owned_card_id: ctx.source.id.clone(),
|
source_owned_card_id: ctx.source.id.clone(),
|
||||||
@@ -371,13 +406,41 @@ pub async fn consume_item<M: ItemMutation>(
|
|||||||
source_quantity_after,
|
source_quantity_after,
|
||||||
target_owned_card_id: ctx.target.as_ref().map(|t| t.id.clone()),
|
target_owned_card_id: ctx.target.as_ref().map(|t| t.id.clone()),
|
||||||
effect,
|
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(profile_id)
|
||||||
|
.bind(action_identity)
|
||||||
|
.fetch_one(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
Ok(hits > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Echo the recorded outcome of an application that already happened. Mutates
|
/// Echo the recorded outcome of an application that already happened. Mutates
|
||||||
/// nothing and reports `applied = false`.
|
/// nothing and reports `applied = false`.
|
||||||
async fn already_applied(
|
async fn already_applied(
|
||||||
pool: &Pool,
|
conn: &mut SqliteConnection,
|
||||||
profile_id: &str,
|
profile_id: &str,
|
||||||
action_identity: &str,
|
action_identity: &str,
|
||||||
) -> AppResult<ConsumeOutcome> {
|
) -> AppResult<ConsumeOutcome> {
|
||||||
@@ -388,7 +451,7 @@ async fn already_applied(
|
|||||||
)
|
)
|
||||||
.bind(profile_id)
|
.bind(profile_id)
|
||||||
.bind(action_identity)
|
.bind(action_identity)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(&mut *conn)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
AppError::Internal(anyhow::anyhow!(
|
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]
|
#[tokio::test]
|
||||||
async fn inline_closure_effect_is_accepted() {
|
async fn inline_closure_effect_is_accepted() {
|
||||||
let (_dir, _url, pool) = fixture().await;
|
let (_dir, _url, pool) = fixture().await;
|
||||||
|
|||||||
@@ -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
|
/// DEFERRED `pool.begin()` upgrades to a write only at the first write, where
|
||||||
/// SQLite returns SQLITE_BUSY *immediately* (bypassing the busy handler to avoid
|
/// SQLite returns SQLITE_BUSY *immediately* (bypassing the busy handler to avoid
|
||||||
/// deadlock) — the fresh-DB multi-connection write failure.
|
/// 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 {
|
match result {
|
||||||
Ok(v) => {
|
Ok(v) => {
|
||||||
sqlx::query("COMMIT").execute(&mut *conn).await?;
|
sqlx::query("COMMIT").execute(&mut *conn).await?;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod event;
|
|||||||
pub mod fut_champs;
|
pub mod fut_champs;
|
||||||
pub mod game_ext;
|
pub mod game_ext;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
|
pub mod instance_effect;
|
||||||
pub mod inventory;
|
pub mod inventory;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
pub mod match_service;
|
pub mod match_service;
|
||||||
@@ -22,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;
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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],
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1115,6 +1115,118 @@ async fn test_quick_sell_owned_card() {
|
|||||||
assert_eq!(coins_after, coins_before + coins_received);
|
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]
|
#[tokio::test]
|
||||||
async fn test_objective_get_by_id() {
|
async fn test_objective_get_by_id() {
|
||||||
let app = build_test_app().await;
|
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");
|
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]
|
||||||
@@ -3563,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()));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
//! Owned-content model migrations (0025 content_kind/quantity, 0026
|
//! 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:
|
//! 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
|
//! * 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
|
//! * every existing kit designation lands in `club_active_items` under its
|
||||||
//! generalised slot token, and the old table + trigger are gone.
|
//! 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
|
//! 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
|
//! 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
|
//! 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!(kind, ContentKind::Player);
|
||||||
assert_eq!(quantity, None);
|
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.
|
// And the new column constraints are real, not documentation.
|
||||||
assert!(
|
assert!(
|
||||||
sqlx::query("UPDATE owned_cards SET content_kind = 'coach' WHERE id = 'spare'")
|
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
|
.await
|
||||||
.expect("migrations must apply to real populated data");
|
.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(*), \
|
"SELECT COUNT(*), \
|
||||||
SUM(CASE WHEN content_kind = 'player' THEN 1 ELSE 0 END), \
|
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",
|
FROM owned_cards",
|
||||||
)
|
)
|
||||||
.fetch_one(&pool)
|
.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!(after, before, "no owned row may be lost or duplicated");
|
||||||
assert_eq!(players, before, "every backfilled row is a player");
|
assert_eq!(players, before, "every backfilled row is a player");
|
||||||
assert_eq!(stacked, 0, "no pre-existing row gains a stack size");
|
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_active_items").await);
|
||||||
assert!(!table_exists(&pool, "club_kit_assignments").await);
|
assert!(!table_exists(&pool, "club_kit_assignments").await);
|
||||||
|
|||||||
Reference in New Issue
Block a user