Compare commits
5 Commits
a45155e0c5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 20e281e0cf | |||
| 8819cc76a1 | |||
| 9bdc1633a0 | |||
| 1df03d4287 | |||
| 90210702c3 |
@@ -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;
|
||||||
@@ -241,6 +241,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
.route("/squad", post(routes::squad::post_squad))
|
.route("/squad", post(routes::squad::post_squad))
|
||||||
.route("/squad/ext", get(routes::squad::get_squad_ext))
|
.route("/squad/ext", get(routes::squad::get_squad_ext))
|
||||||
.route("/squad/replace", put(routes::squad::put_squad_replace))
|
.route("/squad/replace", put(routes::squad::put_squad_replace))
|
||||||
|
.route("/squad/roles", put(routes::squad::put_squad_roles))
|
||||||
.route("/squads", get(routes::squad::get_squads))
|
.route("/squads", get(routes::squad::get_squads))
|
||||||
.route("/squads/:squad_id", get(routes::squad::get_squad_by_id))
|
.route("/squads/:squad_id", get(routes::squad::get_squad_by_id))
|
||||||
.route("/squads/:squad_id", delete(routes::squad::delete_squad))
|
.route("/squads/:squad_id", delete(routes::squad::delete_squad))
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
+6
-9
@@ -147,15 +147,12 @@ 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
|
// Attribute training is per-instance state, so the finished attributes
|
||||||
// belong in the envelope beside the finished rating. The raw effects go
|
// belong in the envelope beside the finished rating. The raw effect goes
|
||||||
// out too: a caller that needs to show WHICH attribute was trained
|
// out too: a caller that needs to show WHICH attribute was trained
|
||||||
// cannot recover that by differencing against a definition it may not
|
// cannot recover that by differencing against a definition it may not
|
||||||
// have.
|
// have. At most ONE effect per instance -- FIFA 17 replaces rather than
|
||||||
const NO_TRAINING: &[training_svc::TrainingEffect] = &[];
|
// accumulates, so this is an Option, not a list.
|
||||||
let effects = training
|
let effect = training.get(&o.id);
|
||||||
.get(&o.id)
|
|
||||||
.map(Vec::as_slice)
|
|
||||||
.unwrap_or(NO_TRAINING);
|
|
||||||
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,
|
||||||
@@ -172,8 +169,8 @@ pub async fn get_collection(
|
|||||||
"contract_matches": o.contract_matches,
|
"contract_matches": o.contract_matches,
|
||||||
"effective_overall": effective_overall,
|
"effective_overall": effective_overall,
|
||||||
"effective_position": effective_position,
|
"effective_position": effective_position,
|
||||||
"effective_attributes": training_svc::effective_attributes_json(def, effects),
|
"effective_attributes": training_svc::effective_attributes_json(def, effect),
|
||||||
"training": effects,
|
"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 })))
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+147
-65
@@ -44,20 +44,29 @@ pub enum InstanceEffect {
|
|||||||
cap: i64,
|
cap: i64,
|
||||||
default_when_unset: i64,
|
default_when_unset: i64,
|
||||||
},
|
},
|
||||||
/// Attach an attribute training effect to the target.
|
/// Attach an attribute training effect to the target, REPLACING any the
|
||||||
|
/// instance already carries.
|
||||||
///
|
///
|
||||||
/// `attribute_index` is a slot in Core's own six-attribute card model, in
|
/// `attribute_index` is a slot in Core's own six-attribute card model, in
|
||||||
/// `CardDefinition` declaration order (0 pace .. 5 physical). Naming the
|
/// `CardDefinition` declaration order (0 pace .. 5 physical), or `None` for
|
||||||
/// slot rather than the game's attribute is what keeps this game-neutral:
|
/// an effect that boosts ALL SIX slots. Naming the slot rather than the
|
||||||
/// that FIFA 17's "GK speed" is slot 4 is the adapter's reversed knowledge,
|
/// game's attribute is what keeps this game-neutral: that FIFA 17's "GK
|
||||||
/// and it stays there.
|
/// speed" is slot 4 is the adapter's reversed knowledge, and it stays there.
|
||||||
///
|
///
|
||||||
/// `max_amount` is the caller's authored ceiling for its own family (FIFA 17
|
/// REPLACEMENT, NOT ACCUMULATION, and at most one effect per instance. Both
|
||||||
/// authors 5/10/15, so 15). Core cannot know it, but it can refuse anything
|
/// halves are the caller's game rule, but they are enforced here because the
|
||||||
/// above the number the caller itself declares, which is what stops a host
|
/// storage shape is Core's: FIFA 17's own documentation states "you can only
|
||||||
/// from describing a "+99 pace" that no card could grant.
|
/// boost one attribute or all six" and "when you apply a new training card
|
||||||
|
/// to a player, he loses the improved attributes of previous training cards.
|
||||||
|
/// It does not accumulate, it replaces."
|
||||||
|
///
|
||||||
|
/// `max_amount` is the caller's authored ceiling for the specific family
|
||||||
|
/// (FIFA 17: 15 single-attribute, 10 for the rare all-six card). Core cannot
|
||||||
|
/// know it, but it can refuse anything above the number the caller itself
|
||||||
|
/// declares, which is what stops a host describing a "+99 pace" no card
|
||||||
|
/// could grant.
|
||||||
ApplyTraining {
|
ApplyTraining {
|
||||||
attribute_index: i64,
|
attribute_index: Option<i64>,
|
||||||
amount: i64,
|
amount: i64,
|
||||||
max_amount: i64,
|
max_amount: i64,
|
||||||
},
|
},
|
||||||
@@ -192,16 +201,18 @@ const ATTRIBUTE_SLOTS: i64 = 6;
|
|||||||
async fn apply_training(
|
async fn apply_training(
|
||||||
tx: &mut SqliteConnection,
|
tx: &mut SqliteConnection,
|
||||||
ctx: &ConsumeContext,
|
ctx: &ConsumeContext,
|
||||||
attribute_index: i64,
|
attribute_index: Option<i64>,
|
||||||
amount: i64,
|
amount: i64,
|
||||||
max_amount: i64,
|
max_amount: i64,
|
||||||
) -> AppResult<Value> {
|
) -> AppResult<Value> {
|
||||||
let target = require_target(ctx, "apply_training")?;
|
let target = require_target(ctx, "apply_training")?;
|
||||||
|
|
||||||
if !(0..ATTRIBUTE_SLOTS).contains(&attribute_index) {
|
if let Some(slot) = attribute_index {
|
||||||
return Err(AppError::BadRequest(format!(
|
if !(0..ATTRIBUTE_SLOTS).contains(&slot) {
|
||||||
"apply_training attribute_index must be 0..{ATTRIBUTE_SLOTS}, got {attribute_index}"
|
return Err(AppError::BadRequest(format!(
|
||||||
)));
|
"apply_training attribute_index must be 0..{ATTRIBUTE_SLOTS} or absent, got {slot}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if amount < 1 {
|
if amount < 1 {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(AppError::BadRequest(format!(
|
||||||
@@ -223,7 +234,7 @@ async fn apply_training(
|
|||||||
}
|
}
|
||||||
// Same reasoning as contracts: a loan is borrowed for a fixed run of
|
// Same reasoning as contracts: a loan is borrowed for a fixed run of
|
||||||
// matches, so durably improving it would outlive the thing it is attached
|
// matches, so durably improving it would outlive the thing it is attached
|
||||||
// to. Conservative and consistent rather than reversed — no FIFA 17 source
|
// to. Conservative and consistent rather than reversed -- no FIFA 17 source
|
||||||
// speaks to training a loan item.
|
// speaks to training a loan item.
|
||||||
if target.is_loan {
|
if target.is_loan {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(AppError::BadRequest(format!(
|
||||||
@@ -232,18 +243,28 @@ async fn apply_training(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The INSERT is the check. `(owned_card_id, attribute_index)` is the primary
|
// REPLACE. One instance carries at most one training effect, and a new card
|
||||||
// key, so a second training on a slot that already carries one collides here
|
// supersedes whatever was there -- including an effect on a DIFFERENT slot,
|
||||||
// and the whole transaction rolls back — the source is NOT consumed. That is
|
// because FIFA 17 allows "one attribute or all six" and never a mixture.
|
||||||
// deliberate: replacement-vs-stacking is UNKNOWN (see migration 0029), and a
|
// Delete-then-insert inside the caller's transaction, so the old effect can
|
||||||
// refusal is the only answer that neither invents a rule nor silently eats a
|
// never survive a failed insert and the two can never coexist.
|
||||||
// card. Detected as a constraint violation rather than a SELECT-then-INSERT
|
let previous = sqlx::query_as::<_, (Option<i64>, i64, String)>(
|
||||||
// so it holds against a concurrent writer instead of racing it.
|
"SELECT attribute_index, amount, source_card_id FROM owned_card_training \
|
||||||
let inserted = sqlx::query(
|
WHERE owned_card_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&target.id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM owned_card_training WHERE owned_card_id = ?")
|
||||||
|
.bind(&target.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
"INSERT INTO owned_card_training \
|
"INSERT INTO owned_card_training \
|
||||||
(owned_card_id, attribute_index, amount, source_card_id, applied_at) \
|
(owned_card_id, attribute_index, amount, source_card_id, applied_at) \
|
||||||
VALUES (?, ?, ?, ?, ?) \
|
VALUES (?, ?, ?, ?, ?)",
|
||||||
ON CONFLICT(owned_card_id, attribute_index) DO NOTHING",
|
|
||||||
)
|
)
|
||||||
.bind(&target.id)
|
.bind(&target.id)
|
||||||
.bind(attribute_index)
|
.bind(attribute_index)
|
||||||
@@ -251,17 +272,7 @@ async fn apply_training(
|
|||||||
.bind(&ctx.source.card_id)
|
.bind(&ctx.source.card_id)
|
||||||
.bind(Utc::now().to_rfc3339())
|
.bind(Utc::now().to_rfc3339())
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?
|
.await?;
|
||||||
.rows_affected();
|
|
||||||
|
|
||||||
if inserted != 1 {
|
|
||||||
return Err(AppError::Conflict(format!(
|
|
||||||
"target item '{}' already carries training on attribute slot {attribute_index}; \
|
|
||||||
FIFA 17 replacement/stacking behaviour is unproven, so this is refused rather \
|
|
||||||
than guessed",
|
|
||||||
target.id
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"kind": "apply_training",
|
"kind": "apply_training",
|
||||||
@@ -270,12 +281,19 @@ async fn apply_training(
|
|||||||
// Named `granted` as well as `amount` so every effect's recorded outcome
|
// Named `granted` as well as `amount` so every effect's recorded outcome
|
||||||
// answers "what did this card award" under one key, whatever the family.
|
// answers "what did this card award" under one key, whatever the family.
|
||||||
"granted": amount,
|
"granted": amount,
|
||||||
// `before`/`after` describe the TRAINING held on this slot, not the
|
// `before`/`after` describe the training this instance holds, not the
|
||||||
// attribute's value: Core stores effects, and the attribute total is a
|
// attribute's value: Core stores effects, and the attribute total is a
|
||||||
// projection over a definition Core does not consult here. `before` is
|
// projection over a definition Core does not consult here. `before` is
|
||||||
// always 0 because a slot that already carried training refused above.
|
// the magnitude of the effect this one replaced, 0 when there was none.
|
||||||
"before": 0,
|
"before": previous.as_ref().map(|(_, a, _)| *a).unwrap_or(0),
|
||||||
"after": amount,
|
"after": amount,
|
||||||
|
// What was displaced, so the outcome records the replacement rather than
|
||||||
|
// silently overwriting history.
|
||||||
|
"replaced": previous.map(|(slot, amt, src)| json!({
|
||||||
|
"attribute_index": slot,
|
||||||
|
"amount": amt,
|
||||||
|
"source_card_id": src,
|
||||||
|
})),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,16 +387,25 @@ mod tests {
|
|||||||
|
|
||||||
fn train(attribute_index: i64, amount: i64) -> InstanceEffect {
|
fn train(attribute_index: i64, amount: i64) -> InstanceEffect {
|
||||||
InstanceEffect::ApplyTraining {
|
InstanceEffect::ApplyTraining {
|
||||||
attribute_index,
|
attribute_index: Some(attribute_index),
|
||||||
amount,
|
amount,
|
||||||
max_amount: 15,
|
max_amount: 15,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn training_of(pool: &db::Pool, id: &str) -> Vec<(i64, i64, String)> {
|
/// The rare card: every slot, ceiling 10.
|
||||||
sqlx::query_as::<_, (i64, i64, String)>(
|
fn train_all(amount: i64) -> InstanceEffect {
|
||||||
|
InstanceEffect::ApplyTraining {
|
||||||
|
attribute_index: None,
|
||||||
|
amount,
|
||||||
|
max_amount: 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn training_of(pool: &db::Pool, id: &str) -> Vec<(Option<i64>, i64, String)> {
|
||||||
|
sqlx::query_as::<_, (Option<i64>, i64, String)>(
|
||||||
"SELECT attribute_index, amount, source_card_id FROM owned_card_training \
|
"SELECT attribute_index, amount, source_card_id FROM owned_card_training \
|
||||||
WHERE owned_card_id = ? ORDER BY attribute_index",
|
WHERE owned_card_id = ?",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
@@ -421,20 +448,20 @@ mod tests {
|
|||||||
"granted": 10,
|
"granted": 10,
|
||||||
"before": 0,
|
"before": 0,
|
||||||
"after": 10,
|
"after": 10,
|
||||||
|
"replaced": null,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
training_of(&pool, "fresh").await,
|
training_of(&pool, "fresh").await,
|
||||||
vec![(4, 10, "def-card-1".to_string())]
|
vec![(Some(4), 10, "def-card-1".to_string())]
|
||||||
);
|
);
|
||||||
assert!(!source_exists(&pool, "card-1").await);
|
assert!(!source_exists(&pool, "card-1").await);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replacement-vs-stacking is UNKNOWN, so a second card on the SAME slot is
|
/// A second card on the SAME slot REPLACES the first and does not
|
||||||
/// refused — and, critically, the refusal rolls back the whole transaction,
|
/// accumulate: 10 then 15 leaves 15, never 25.
|
||||||
/// so the player keeps the card rather than paying for nothing.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_second_training_on_the_same_slot_is_refused_and_the_card_survives() {
|
async fn a_second_training_on_the_same_slot_replaces_rather_than_accumulating() {
|
||||||
let (_dir, pool) = fixture().await;
|
let (_dir, pool) = fixture().await;
|
||||||
consume_item(
|
consume_item(
|
||||||
&pool,
|
&pool,
|
||||||
@@ -446,7 +473,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("first apply");
|
.expect("first apply");
|
||||||
|
|
||||||
let err = consume_item(
|
let out = consume_item(
|
||||||
&pool,
|
&pool,
|
||||||
"prof",
|
"prof",
|
||||||
"club",
|
"club",
|
||||||
@@ -454,22 +481,24 @@ mod tests {
|
|||||||
&train(4, 15),
|
&train(4, 15),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("second apply on the same slot must be refused");
|
.expect("second apply replaces");
|
||||||
assert!(matches!(err, AppError::Conflict(_)), "got {err:?}");
|
|
||||||
|
|
||||||
// The original effect is untouched: not replaced, not stacked.
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
training_of(&pool, "fresh").await,
|
training_of(&pool, "fresh").await,
|
||||||
vec![(4, 10, "def-card-1".to_string())]
|
vec![(Some(4), 15, "def-card-2".to_string())],
|
||||||
|
"the newer effect must stand alone, not sum to 25"
|
||||||
);
|
);
|
||||||
// And the second card was NOT spent.
|
assert_eq!(out.effect["before"], json!(10));
|
||||||
assert!(source_exists(&pool, "card-2").await);
|
assert_eq!(out.effect["after"], json!(15));
|
||||||
|
assert_eq!(out.effect["replaced"]["amount"], json!(10));
|
||||||
|
// Both cards were legitimately spent.
|
||||||
|
assert!(!source_exists(&pool, "card-2").await);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Distinct slots are independent: each training card names exactly one
|
/// A card on a DIFFERENT slot also replaces: FIFA 17 permits "one attribute
|
||||||
/// attribute, and nothing in the shipped data couples them.
|
/// or all six", never a mixture, so two slots must never be boosted at once.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn distinct_slots_coexist_on_one_instance() {
|
async fn a_training_on_a_different_slot_still_replaces_the_previous_one() {
|
||||||
let (_dir, pool) = fixture().await;
|
let (_dir, pool) = fixture().await;
|
||||||
for (identity, source, slot, amount) in
|
for (identity, source, slot, amount) in
|
||||||
[("act-1", "card-1", 4, 10), ("act-2", "card-2", 1, 5)]
|
[("act-1", "card-1", 4, 10), ("act-2", "card-2", 1, 5)]
|
||||||
@@ -486,13 +515,66 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
training_of(&pool, "fresh").await,
|
training_of(&pool, "fresh").await,
|
||||||
vec![
|
vec![(Some(1), 5, "def-card-2".to_string())],
|
||||||
(1, 5, "def-card-2".to_string()),
|
"only the newest effect may remain"
|
||||||
(4, 10, "def-card-1".to_string())
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The rare card boosts every slot, and replaces a single-attribute effect
|
||||||
|
/// exactly like any other new card.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_all_six_card_stores_no_slot_and_replaces_a_single_attribute_effect() {
|
||||||
|
let (_dir, pool) = fixture().await;
|
||||||
|
consume_item(
|
||||||
|
&pool,
|
||||||
|
"prof",
|
||||||
|
"club",
|
||||||
|
&apply_request("act-1", "card-1", "fresh"),
|
||||||
|
&train(4, 15),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("single-attribute apply");
|
||||||
|
|
||||||
|
consume_item(
|
||||||
|
&pool,
|
||||||
|
"prof",
|
||||||
|
"club",
|
||||||
|
&apply_request("act-2", "card-2", "fresh"),
|
||||||
|
&train_all(10),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("all-six apply");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
training_of(&pool, "fresh").await,
|
||||||
|
vec![(None, 10, "def-card-2".to_string())],
|
||||||
|
"the all-six effect stores a NULL slot and stands alone"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The all-six card authors at most +10; a caller declaring that ceiling
|
||||||
|
/// cannot then push +15 through it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_all_six_ceiling_is_enforced_independently() {
|
||||||
|
let (_dir, pool) = fixture().await;
|
||||||
|
let err = consume_item(
|
||||||
|
&pool,
|
||||||
|
"prof",
|
||||||
|
"club",
|
||||||
|
&apply_request("act-1", "card-1", "fresh"),
|
||||||
|
&InstanceEffect::ApplyTraining {
|
||||||
|
attribute_index: None,
|
||||||
|
amount: 15,
|
||||||
|
max_amount: 10,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("an over-ceiling all-six boost must be refused");
|
||||||
|
assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}");
|
||||||
|
assert!(training_of(&pool, "fresh").await.is_empty());
|
||||||
|
assert!(source_exists(&pool, "card-1").await);
|
||||||
|
}
|
||||||
|
|
||||||
/// The caller declares its own family's ceiling and is held to it. This is
|
/// The caller declares its own family's ceiling and is held to it. This is
|
||||||
/// what stops a host describing a boost no card could grant.
|
/// what stops a host describing a boost no card could grant.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -516,7 +598,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_slot_outside_the_card_model_is_refused() {
|
async fn a_slot_outside_the_card_model_is_refused() {
|
||||||
let (_dir, pool) = fixture().await;
|
let (_dir, pool) = fixture().await;
|
||||||
for slot in [-1, 6, 99] {
|
for slot in [-1i64, 6, 99] {
|
||||||
let err = consume_item(
|
let err = consume_item(
|
||||||
&pool,
|
&pool,
|
||||||
"prof",
|
"prof",
|
||||||
@@ -582,7 +664,7 @@ mod tests {
|
|||||||
assert_eq!(replay.effect, first.effect);
|
assert_eq!(replay.effect, first.effect);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
training_of(&pool, "fresh").await,
|
training_of(&pool, "fresh").await,
|
||||||
vec![(2, 15, "def-card-1".to_string())]
|
vec![(Some(2), 15, "def-card-1".to_string())]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+81
-24
@@ -27,11 +27,15 @@ use crate::{db::Pool, error::AppResult, models::card::CardDefinition};
|
|||||||
/// that keeps the projected card inside the model it is drawn from.
|
/// that keeps the projected card inside the model it is drawn from.
|
||||||
pub const ATTRIBUTE_MAX: i64 = 99;
|
pub const ATTRIBUTE_MAX: i64 = 99;
|
||||||
|
|
||||||
/// One training effect attached to one instance.
|
/// 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)]
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
pub struct TrainingEffect {
|
pub struct TrainingEffect {
|
||||||
/// Slot in Core's six-attribute model, `CardDefinition` declaration order.
|
/// Slot in Core's six-attribute model, `CardDefinition` declaration order,
|
||||||
pub attribute_index: i64,
|
/// or `None` for an effect that boosts ALL SIX slots.
|
||||||
|
pub attribute_index: Option<i64>,
|
||||||
pub amount: i64,
|
pub amount: i64,
|
||||||
pub source_card_id: String,
|
pub source_card_id: String,
|
||||||
}
|
}
|
||||||
@@ -44,30 +48,74 @@ pub struct TrainingEffect {
|
|||||||
pub async fn load_for_club(
|
pub async fn load_for_club(
|
||||||
pool: &Pool,
|
pool: &Pool,
|
||||||
club_id: &str,
|
club_id: &str,
|
||||||
) -> AppResult<HashMap<String, Vec<TrainingEffect>>> {
|
) -> AppResult<HashMap<String, TrainingEffect>> {
|
||||||
let rows = sqlx::query_as::<_, (String, i64, i64, String)>(
|
let rows = sqlx::query_as::<_, (String, Option<i64>, i64, String)>(
|
||||||
"SELECT t.owned_card_id, t.attribute_index, t.amount, t.source_card_id \
|
"SELECT t.owned_card_id, t.attribute_index, t.amount, t.source_card_id \
|
||||||
FROM owned_card_training t \
|
FROM owned_card_training t \
|
||||||
JOIN owned_cards o ON o.id = t.owned_card_id \
|
JOIN owned_cards o ON o.id = t.owned_card_id \
|
||||||
WHERE o.club_id = ? \
|
WHERE o.club_id = ?",
|
||||||
ORDER BY t.owned_card_id, t.attribute_index",
|
|
||||||
)
|
)
|
||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut by_instance: HashMap<String, Vec<TrainingEffect>> = HashMap::new();
|
Ok(rows
|
||||||
for (owned_card_id, attribute_index, amount, source_card_id) in rows {
|
.into_iter()
|
||||||
by_instance
|
.map(|(owned_card_id, attribute_index, amount, source_card_id)| {
|
||||||
.entry(owned_card_id)
|
(
|
||||||
.or_default()
|
owned_card_id,
|
||||||
.push(TrainingEffect {
|
TrainingEffect {
|
||||||
attribute_index,
|
attribute_index,
|
||||||
amount,
|
amount,
|
||||||
source_card_id,
|
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(by_instance)
|
Ok(expired)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The definition's six attributes in canonical slot order.
|
/// The definition's six attributes in canonical slot order.
|
||||||
@@ -88,14 +136,23 @@ pub fn base_attributes(def: &CardDefinition) -> [i64; 6] {
|
|||||||
|
|
||||||
/// Base attributes with any training folded in, clamped to the model's domain.
|
/// 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
|
/// 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
|
/// refuses one, so reaching this would mean the row was written around Core, and
|
||||||
/// dropping it degrades one attribute instead of failing every projection.
|
/// dropping it degrades one attribute instead of failing every projection.
|
||||||
pub fn effective_attributes(def: &CardDefinition, effects: &[TrainingEffect]) -> [i64; 6] {
|
pub fn effective_attributes(def: &CardDefinition, effect: Option<&TrainingEffect>) -> [i64; 6] {
|
||||||
let mut out = base_attributes(def);
|
let mut out = base_attributes(def);
|
||||||
for e in effects {
|
let Some(e) = effect else { return out };
|
||||||
if let Some(slot) = out.get_mut(e.attribute_index as usize) {
|
match e.attribute_index {
|
||||||
*slot = (*slot + e.amount).clamp(0, ATTRIBUTE_MAX);
|
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
|
out
|
||||||
@@ -104,9 +161,9 @@ pub fn effective_attributes(def: &CardDefinition, effects: &[TrainingEffect]) ->
|
|||||||
/// The same six values as a named object, for the projection envelope.
|
/// The same six values as a named object, for the projection envelope.
|
||||||
pub fn effective_attributes_json(
|
pub fn effective_attributes_json(
|
||||||
def: &CardDefinition,
|
def: &CardDefinition,
|
||||||
effects: &[TrainingEffect],
|
effect: Option<&TrainingEffect>,
|
||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
let a = effective_attributes(def, effects);
|
let a = effective_attributes(def, effect);
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"pace": a[0],
|
"pace": a[0],
|
||||||
"shooting": a[1],
|
"shooting": a[1],
|
||||||
|
|||||||
@@ -3016,6 +3016,412 @@ async fn test_squad_ext_replace_read_roundtrip_and_idempotency() {
|
|||||||
assert_eq!(other["extension"]["state"], "missing");
|
assert_eq!(other["extension"]["state"], "missing");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A full replacement that carries no slots MUST NOT empty a populated squad.
|
||||||
|
///
|
||||||
|
/// Regression: a FIFA 17 client whose in-memory squad had been destroyed by a
|
||||||
|
/// bad parse wrote that emptiness back through `/squad/replace`, taking the
|
||||||
|
/// canonical squad from 18 assignments to 0 while the request logged 200/ok.
|
||||||
|
/// The squad is the authority's state, so mirroring a broken client's model is
|
||||||
|
/// unrecoverable data loss.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_squad_replace_refuses_to_empty_a_populated_squad() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "SquadWipeGuardUser").await;
|
||||||
|
|
||||||
|
let (_, packs) = json_get(&app, "/packs").await;
|
||||||
|
let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap().to_string();
|
||||||
|
json_post(
|
||||||
|
&app,
|
||||||
|
&format!("/packs/open/{pack_id}"),
|
||||||
|
serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (_, coll) = json_get(&app, "/collection").await;
|
||||||
|
let ids: Vec<String> = coll["collection"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.take(2)
|
||||||
|
.map(|c| c["owned_card_id"].as_str().unwrap().to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let ext_write = serde_json::json!({
|
||||||
|
"namespace": "fifa17.squad", "schema_version": 1, "payload": "{\"custom\":\"[1]\"}"
|
||||||
|
});
|
||||||
|
let client_reported = serde_json::json!({
|
||||||
|
"client_reported_chemistry": 52,
|
||||||
|
"client_reported_rating": 90,
|
||||||
|
"client_reported_star_rating": 90
|
||||||
|
});
|
||||||
|
let populate = serde_json::json!({
|
||||||
|
"name": "OpenFUT",
|
||||||
|
"formation": "f442",
|
||||||
|
"slots": [
|
||||||
|
{"owned_card_id": ids[0], "slot": 0, "is_captain": true, "is_on_bench": false},
|
||||||
|
{"owned_card_id": ids[1], "slot": 1, "is_captain": false, "is_on_bench": false},
|
||||||
|
],
|
||||||
|
"client_reported": client_reported,
|
||||||
|
"extension": ext_write,
|
||||||
|
});
|
||||||
|
let (s, put) = json_put(&app, "/squad/replace", populate).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{put}");
|
||||||
|
assert_eq!(put["slots_written"], 2);
|
||||||
|
|
||||||
|
// The destructive write: a well-formed replacement that simply carries no
|
||||||
|
// slots. It must be REFUSED, not applied — this is the exact shape that
|
||||||
|
// emptied a real squad.
|
||||||
|
let (s, err) = json_put(
|
||||||
|
&app,
|
||||||
|
"/squad/replace",
|
||||||
|
serde_json::json!({
|
||||||
|
"name": "OpenFUT",
|
||||||
|
"formation": "f442",
|
||||||
|
"slots": [],
|
||||||
|
"client_reported": client_reported,
|
||||||
|
"extension": ext_write,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
s,
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"an empty replacement must be refused, not applied: {err}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The squad is untouched — the refusal rolled back, it did not half-apply.
|
||||||
|
let (s, ext) = json_get(&app, "/squad/ext?namespace=fifa17.squad").await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
ext["players"].as_array().unwrap().len(),
|
||||||
|
2,
|
||||||
|
"both assignments survive the refused replacement"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A role-only patch must move the captain and re-anchor the extension WITHOUT
|
||||||
|
/// disturbing a single assignment.
|
||||||
|
///
|
||||||
|
/// Regression: FIFA 17's captain/kick-taker screen sends a body with no
|
||||||
|
/// `players`, which the host presented to `/squad/replace` as a replacement
|
||||||
|
/// carrying zero slots. The empty-replacement guard correctly refused it, so
|
||||||
|
/// every captain change died with a 400 (surfaced to the client as 502). The
|
||||||
|
/// operation, not the guard, was wrong.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_squad_roles_patch_moves_captain_without_touching_assignments() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "RolePatchUser").await;
|
||||||
|
|
||||||
|
let (_, packs) = json_get(&app, "/packs").await;
|
||||||
|
let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap().to_string();
|
||||||
|
json_post(
|
||||||
|
&app,
|
||||||
|
&format!("/packs/open/{pack_id}"),
|
||||||
|
serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (_, coll) = json_get(&app, "/collection").await;
|
||||||
|
let ids: Vec<String> = coll["collection"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.take(2)
|
||||||
|
.map(|c| c["owned_card_id"].as_str().unwrap().to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let client_reported = serde_json::json!({
|
||||||
|
"client_reported_chemistry": 52,
|
||||||
|
"client_reported_rating": 90,
|
||||||
|
"client_reported_star_rating": 90
|
||||||
|
});
|
||||||
|
let (s, put) = json_put(
|
||||||
|
&app,
|
||||||
|
"/squad/replace",
|
||||||
|
serde_json::json!({
|
||||||
|
"name": "OpenFUT",
|
||||||
|
"formation": "f442",
|
||||||
|
"slots": [
|
||||||
|
{"owned_card_id": ids[0], "slot": 0, "is_captain": true, "is_on_bench": false},
|
||||||
|
{"owned_card_id": ids[1], "slot": 1, "is_captain": false, "is_on_bench": false},
|
||||||
|
],
|
||||||
|
"client_reported": client_reported,
|
||||||
|
"extension": {"namespace": "fifa17.squad", "schema_version": 1,
|
||||||
|
"payload": "{\"custom\":\"[1]\",\"kit_numbers\":{\"a\":7}}"},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{put}");
|
||||||
|
let before_fp = put["canonical_fingerprint"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
// Move the captain to the second player, carrying a new opaque payload.
|
||||||
|
let (s, patched) = json_put(
|
||||||
|
&app,
|
||||||
|
"/squad/roles",
|
||||||
|
serde_json::json!({
|
||||||
|
"captain_owned_card_id": ids[1],
|
||||||
|
"extension": {"namespace": "fifa17.squad", "schema_version": 1,
|
||||||
|
"payload": "{\"custom\":\"[0,8,16]\",\"kit_numbers\":{\"a\":7}}"},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{patched}");
|
||||||
|
assert_eq!(patched["captain_changed"], true);
|
||||||
|
assert_ne!(
|
||||||
|
patched["canonical_fingerprint"].as_str().unwrap(),
|
||||||
|
before_fp,
|
||||||
|
"the captain is part of the fingerprint, so a captain move MUST re-anchor it"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (s, ext) = json_get(&app, "/squad/ext?namespace=fifa17.squad").await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
let players = ext["players"].as_array().unwrap();
|
||||||
|
assert_eq!(players.len(), 2, "a role patch must not add or drop slots");
|
||||||
|
let captain_of = |owned: &str| -> bool {
|
||||||
|
players
|
||||||
|
.iter()
|
||||||
|
.find(|p| p["owned_card_id"] == owned)
|
||||||
|
.map(|p| p["is_captain"] == true)
|
||||||
|
.unwrap_or(false)
|
||||||
|
};
|
||||||
|
assert!(captain_of(&ids[1]), "the new captain is flagged");
|
||||||
|
assert!(!captain_of(&ids[0]), "the previous captain is cleared");
|
||||||
|
// Fresh, not stale: the patch re-anchored the extension it wrote.
|
||||||
|
assert_eq!(
|
||||||
|
ext["extension"]["payload"], "{\"custom\":\"[0,8,16]\",\"kit_numbers\":{\"a\":7}}",
|
||||||
|
"the patch's payload is the one stored"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A role patch naming a captain who is not in the squad must change NOTHING —
|
||||||
|
/// not the captain, not the extension. All-or-nothing, validated before any write.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_squad_roles_patch_rejects_unfielded_captain_and_rolls_back() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "RolePatchRollbackUser").await;
|
||||||
|
|
||||||
|
let (_, packs) = json_get(&app, "/packs").await;
|
||||||
|
let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap().to_string();
|
||||||
|
json_post(
|
||||||
|
&app,
|
||||||
|
&format!("/packs/open/{pack_id}"),
|
||||||
|
serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (_, coll) = json_get(&app, "/collection").await;
|
||||||
|
let ids: Vec<String> = coll["collection"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.take(3)
|
||||||
|
.map(|c| c["owned_card_id"].as_str().unwrap().to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let original_payload = "{\"custom\":\"[1]\"}";
|
||||||
|
let (s, _) = json_put(
|
||||||
|
&app,
|
||||||
|
"/squad/replace",
|
||||||
|
serde_json::json!({
|
||||||
|
"name": "OpenFUT",
|
||||||
|
"formation": "f442",
|
||||||
|
"slots": [
|
||||||
|
{"owned_card_id": ids[0], "slot": 0, "is_captain": true, "is_on_bench": false},
|
||||||
|
{"owned_card_id": ids[1], "slot": 1, "is_captain": false, "is_on_bench": false},
|
||||||
|
],
|
||||||
|
"client_reported": serde_json::json!({}),
|
||||||
|
"extension": {"namespace": "fifa17.squad", "schema_version": 1,
|
||||||
|
"payload": original_payload},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
|
||||||
|
// ids[2] is owned but NOT fielded — a patch must not accept it.
|
||||||
|
let (s, err) = json_put(
|
||||||
|
&app,
|
||||||
|
"/squad/roles",
|
||||||
|
serde_json::json!({
|
||||||
|
"captain_owned_card_id": ids[2],
|
||||||
|
"extension": {"namespace": "fifa17.squad", "schema_version": 1,
|
||||||
|
"payload": "{\"custom\":\"[9,9,9]\"}"},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
s,
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"a captain not assigned to the squad must be refused: {err}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (s, ext) = json_get(&app, "/squad/ext?namespace=fifa17.squad").await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
let players = ext["players"].as_array().unwrap();
|
||||||
|
assert!(
|
||||||
|
players
|
||||||
|
.iter()
|
||||||
|
.any(|p| p["owned_card_id"] == ids[0].as_str() && p["is_captain"] == true),
|
||||||
|
"the original captain survives a refused patch"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ext["extension"]["payload"], original_payload,
|
||||||
|
"the extension must NOT be written when the captain is refused"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PUT /club/manager` must keep three states apart: absent = say nothing,
|
||||||
|
/// explicit null = remove, id = assign.
|
||||||
|
///
|
||||||
|
/// Regression: `owned_card_id` was a plain `Option<String>`, so serde collapsed
|
||||||
|
/// "field absent" and "field null" into the same `None` and the route treated
|
||||||
|
/// both as a clear. A caller with nothing to say therefore DELETED the manager —
|
||||||
|
/// how a FIFA 17 client with a destroyed squad model wiped a real manager row
|
||||||
|
/// (WAL commit 468, squad_managers 1 -> 0).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_manager_absent_field_leaves_assignment_untouched() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "ManagerGuardUser").await;
|
||||||
|
|
||||||
|
let (_, packs) = json_get(&app, "/packs").await;
|
||||||
|
let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap().to_string();
|
||||||
|
json_post(
|
||||||
|
&app,
|
||||||
|
&format!("/packs/open/{pack_id}"),
|
||||||
|
serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (_, coll) = json_get(&app, "/collection").await;
|
||||||
|
let ids: Vec<String> = coll["collection"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.take(3)
|
||||||
|
.map(|c| c["owned_card_id"].as_str().unwrap().to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// A squad must exist for a manager to attach to.
|
||||||
|
let (s, _) = json_put(
|
||||||
|
&app,
|
||||||
|
"/squad/replace",
|
||||||
|
serde_json::json!({
|
||||||
|
"name": "OpenFUT", "formation": "f442",
|
||||||
|
"slots": [{"owned_card_id": ids[0], "slot": 0, "is_captain": true, "is_on_bench": false}],
|
||||||
|
"client_reported": {"client_reported_chemistry": 50, "client_reported_rating": 80,
|
||||||
|
"client_reported_star_rating": 80},
|
||||||
|
"extension": {"namespace": "fifa17.squad", "schema_version": 1, "payload": "{}"},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
|
||||||
|
// Assign.
|
||||||
|
let (s, body) = json_put(
|
||||||
|
&app,
|
||||||
|
"/club/manager",
|
||||||
|
serde_json::json!({"owned_card_id": ids[1]}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{body}");
|
||||||
|
assert_eq!(body["manager"]["id"], ids[1].as_str());
|
||||||
|
|
||||||
|
// ABSENT field: the destructive shape. Must change nothing.
|
||||||
|
let (s, body) = json_put(&app, "/club/manager", serde_json::json!({})).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{body}");
|
||||||
|
assert_eq!(
|
||||||
|
body["manager"]["id"],
|
||||||
|
ids[1].as_str(),
|
||||||
|
"an absent owned_card_id must LEAVE the manager, never clear it"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reassign to a different owned card: authentic, still allowed.
|
||||||
|
let (s, body) = json_put(
|
||||||
|
&app,
|
||||||
|
"/club/manager",
|
||||||
|
serde_json::json!({"owned_card_id": ids[2]}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{body}");
|
||||||
|
assert_eq!(body["manager"]["id"], ids[2].as_str());
|
||||||
|
|
||||||
|
// Same manager again: idempotent no-op, still assigned.
|
||||||
|
let (s, body) = json_put(
|
||||||
|
&app,
|
||||||
|
"/club/manager",
|
||||||
|
serde_json::json!({"owned_card_id": ids[2]}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{body}");
|
||||||
|
assert_eq!(body["manager"]["id"], ids[2].as_str());
|
||||||
|
|
||||||
|
// A card this club does not own is refused.
|
||||||
|
let (s, _) = json_put(
|
||||||
|
&app,
|
||||||
|
"/club/manager",
|
||||||
|
serde_json::json!({"owned_card_id": "not-a-real-owned-card"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
s,
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"an unowned manager must be refused"
|
||||||
|
);
|
||||||
|
let (_, body) = json_get(&app, "/club/manager").await;
|
||||||
|
assert_eq!(
|
||||||
|
body["manager"]["id"],
|
||||||
|
ids[2].as_str(),
|
||||||
|
"a refused assignment must not disturb the current manager"
|
||||||
|
);
|
||||||
|
|
||||||
|
// EXPLICIT null: a deliberate removal is legitimate and still works.
|
||||||
|
let (s, body) = json_put(
|
||||||
|
&app,
|
||||||
|
"/club/manager",
|
||||||
|
serde_json::json!({"owned_card_id": null}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{body}");
|
||||||
|
assert!(
|
||||||
|
body["manager"].is_null(),
|
||||||
|
"an explicit null must still remove the manager: {body}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Absent against a squad with NO manager: not over-guarded, plain no-op.
|
||||||
|
let (s, body) = json_put(&app, "/club/manager", serde_json::json!({})).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{body}");
|
||||||
|
assert!(body["manager"].is_null());
|
||||||
|
|
||||||
|
// The squad's player assignment survived every one of those manager writes.
|
||||||
|
let (_, ext) = json_get(&app, "/squad/ext?namespace=fifa17.squad").await;
|
||||||
|
assert_eq!(
|
||||||
|
ext["players"].as_array().unwrap().len(),
|
||||||
|
1,
|
||||||
|
"manager writes must never disturb player assignments"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A malformed manager body is a PARSER rejection, distinguishable from the
|
||||||
|
/// guard's behaviour: a wrong-typed field is refused outright rather than being
|
||||||
|
/// silently treated as "absent" and passed through as a no-op.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_manager_malformed_body_is_rejected_not_treated_as_absent() {
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "ManagerMalformedUser").await;
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("PUT")
|
||||||
|
.uri("/club/manager")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(r#"{"owned_card_id": 12345}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let s = resp.status();
|
||||||
|
assert!(
|
||||||
|
s == StatusCode::UNPROCESSABLE_ENTITY || s == StatusCode::BAD_REQUEST,
|
||||||
|
"a non-string owned_card_id must be a parser rejection, got {s}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────── economy HTTP boundary ──────────────────────────
|
// ─────────────────────────── economy HTTP boundary ──────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -3675,3 +4081,242 @@ async fn test_collection_reports_owned_rows_it_cannot_project() {
|
|||||||
.collect();
|
.collect();
|
||||||
assert!(!ids.contains(&"ghost"));
|
assert!(!ids.contains(&"ghost"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────── One-match training expiry (lifecycle row 12) ────────────
|
||||||
|
//
|
||||||
|
// FIFA 17 training is a ONE-MATCH effect that is consumed by the player PLAYING,
|
||||||
|
// not by the match merely completing: a card on someone who stays on the bench
|
||||||
|
// "will continue to benefit from the training effect until he plays"
|
||||||
|
// (DOCUMENTED). Core therefore expires exactly the instances the caller says
|
||||||
|
// took the field, and nothing else.
|
||||||
|
|
||||||
|
/// Seed a club with two owned instances, both carrying a training effect.
|
||||||
|
/// Returns `(club_id, played_id, benched_id)`.
|
||||||
|
async fn seed_two_trained(
|
||||||
|
app: &axum::Router,
|
||||||
|
pool: &sqlx::SqlitePool,
|
||||||
|
who: &str,
|
||||||
|
) -> (String, String, String) {
|
||||||
|
auth(app, who).await;
|
||||||
|
let club_id: String = sqlx::query_scalar("SELECT id FROM clubs LIMIT 1")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.expect("club exists after auth");
|
||||||
|
for id in ["played", "benched"] {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) \
|
||||||
|
VALUES (?, ?, 'card_raregold_001', 0, '2026-01-01T00:00:00Z')",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(&club_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO owned_card_training \
|
||||||
|
(owned_card_id, attribute_index, amount, source_card_id, applied_at) \
|
||||||
|
VALUES (?, 4, 15, 'fifa17_5003012', '2026-01-01T00:00:00Z')",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
(club_id, "played".to_string(), "benched".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn training_rows(pool: &sqlx::SqlitePool) -> Vec<String> {
|
||||||
|
sqlx::query_scalar("SELECT owned_card_id FROM owned_card_training ORDER BY owned_card_id")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The core of the documented rule: only the players who took the field lose
|
||||||
|
/// their boost. Expiring the whole squad — or the whole club — would clear the
|
||||||
|
/// benched player the rule explicitly protects.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_match_expires_training_only_for_the_players_who_played() {
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
let (_club, played, benched) = seed_two_trained(&app, &pool, "ExpiryScope").await;
|
||||||
|
|
||||||
|
let (status, body) = json_post(
|
||||||
|
&app,
|
||||||
|
"/matches/complete",
|
||||||
|
serde_json::json!({
|
||||||
|
"match_identity": "expiry-scope-1", "result": "win",
|
||||||
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
|
"goals_for": 1, "goals_against": 0, "mode": "squad_battles",
|
||||||
|
"participants": [played]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{body}");
|
||||||
|
assert_eq!(body["expired_training"], serde_json::json!(["played"]));
|
||||||
|
assert_eq!(
|
||||||
|
training_rows(&pool).await,
|
||||||
|
vec![benched],
|
||||||
|
"the benched player must keep his boost"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A caller that cannot identify participants must be INERT, never a club wipe.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_match_with_no_participants_expires_nothing() {
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
seed_two_trained(&app, &pool, "ExpiryNone").await;
|
||||||
|
|
||||||
|
let (status, body) = json_post(
|
||||||
|
&app,
|
||||||
|
"/matches/complete",
|
||||||
|
serde_json::json!({
|
||||||
|
"match_identity": "expiry-none-1", "result": "win",
|
||||||
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
|
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{body}");
|
||||||
|
assert_eq!(body["expired_training"], serde_json::json!([]));
|
||||||
|
assert_eq!(training_rows(&pool).await, vec!["benched", "played"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replay safety. The economic guard already stops double rewards; the training
|
||||||
|
/// mutation must ride the SAME canonical identity so a resubmitted completion
|
||||||
|
/// cannot consume a second, freshly-applied boost.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_replayed_completion_does_not_expire_training_twice() {
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
let (_club, played, _benched) = seed_two_trained(&app, &pool, "ExpiryReplay").await;
|
||||||
|
|
||||||
|
let submit = || {
|
||||||
|
json_post(
|
||||||
|
&app,
|
||||||
|
"/matches/complete",
|
||||||
|
serde_json::json!({
|
||||||
|
"match_identity": "expiry-replay-1", "result": "win",
|
||||||
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
|
"goals_for": 1, "goals_against": 0, "mode": "squad_battles",
|
||||||
|
"participants": [played]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (_, first) = submit().await;
|
||||||
|
assert_eq!(first["applied"], true);
|
||||||
|
assert_eq!(first["expired_training"], serde_json::json!(["played"]));
|
||||||
|
|
||||||
|
// Re-apply a boost to the same instance, then replay the SAME match.
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO owned_card_training \
|
||||||
|
(owned_card_id, attribute_index, amount, source_card_id, applied_at) \
|
||||||
|
VALUES ('played', 4, 15, 'fifa17_5003012', '2026-01-02T00:00:00Z')",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (_, second) = submit().await;
|
||||||
|
assert_eq!(second["applied"], false, "replay must not re-apply");
|
||||||
|
assert_eq!(
|
||||||
|
second["expired_training"],
|
||||||
|
serde_json::json!([]),
|
||||||
|
"a replay reports no mutation"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
training_rows(&pool).await.contains(&"played".to_string()),
|
||||||
|
"the replay must NOT consume the newly applied boost"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NoContest` is a voided match: it grants no coins, XP or statistics, so it
|
||||||
|
/// must not consume a one-match effect either. Core's `is_economic` guard is the
|
||||||
|
/// single place that decides this, and training now sits inside it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_no_contest_match_does_not_expire_training() {
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
let (_club, played, _benched) = seed_two_trained(&app, &pool, "ExpiryVoid").await;
|
||||||
|
|
||||||
|
let (status, body) = json_post(
|
||||||
|
&app,
|
||||||
|
"/matches/complete",
|
||||||
|
serde_json::json!({
|
||||||
|
"match_identity": "expiry-void-1", "result": "no_contest",
|
||||||
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
|
"goals_for": 0, "goals_against": 0, "mode": "squad_battles",
|
||||||
|
"participants": [played]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{body}");
|
||||||
|
assert_eq!(body["expired_training"], serde_json::json!([]));
|
||||||
|
assert_eq!(
|
||||||
|
training_rows(&pool).await,
|
||||||
|
vec!["benched", "played"],
|
||||||
|
"a voided match consumes nothing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An id belonging to somebody else's club must not be expirable by guessing it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn training_expiry_is_scoped_to_the_completing_club() {
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
seed_two_trained(&app, &pool, "ExpiryScoped").await;
|
||||||
|
|
||||||
|
// A genuinely separate club, built properly so the FKs hold — the point of
|
||||||
|
// the test is club scoping, not a dangling row.
|
||||||
|
//
|
||||||
|
// created_at is deliberately in the FUTURE: `get_active_profile` selects
|
||||||
|
// `WHERE game_id = ? ORDER BY created_at ASC LIMIT 1`, and `game_id`
|
||||||
|
// defaults to 'fifa23' (migration 0016), so a rival dated earlier than the
|
||||||
|
// authed profile would silently BECOME the active profile and this test
|
||||||
|
// would assert the opposite of what it means.
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO profiles (id, username, created_at, updated_at) \
|
||||||
|
VALUES ('other-profile', 'Rival', '2099-01-01T00:00:00Z', '2099-01-01T00:00:00Z')",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO clubs (id, profile_id, name, created_at, updated_at) \
|
||||||
|
VALUES ('other-club', 'other-profile', 'Rival FC', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) \
|
||||||
|
VALUES ('foreign', 'other-club', 'card_raregold_001', 0, '2026-01-01T00:00:00Z')",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO owned_card_training \
|
||||||
|
(owned_card_id, attribute_index, amount, source_card_id, applied_at) \
|
||||||
|
VALUES ('foreign', 4, 15, 'fifa17_5003012', '2026-01-01T00:00:00Z')",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (status, body) = json_post(
|
||||||
|
&app,
|
||||||
|
"/matches/complete",
|
||||||
|
serde_json::json!({
|
||||||
|
"match_identity": "expiry-scoped-1", "result": "win",
|
||||||
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
|
"goals_for": 1, "goals_against": 0, "mode": "squad_battles",
|
||||||
|
"participants": ["foreign"]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{body}");
|
||||||
|
assert_eq!(
|
||||||
|
body["expired_training"],
|
||||||
|
serde_json::json!([]),
|
||||||
|
"another club's effect must not be reachable"
|
||||||
|
);
|
||||||
|
assert!(training_rows(&pool).await.contains(&"foreign".to_string()));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user