feat(core): per-instance attribute training as a closed effect
CI / Build, lint & test (push) Successful in 3m4s

FIFA 17 training cards boost ONE attribute of ONE owned player. Core gains
the state to hold that and the vocabulary to be asked for it, without
learning any FIFA rule.

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

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

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

Tests cover the happy path, same-slot refusal leaving the card intact,
distinct slots coexisting, over-ceiling and out-of-range refusals, loan
refusal, replay, FK cascade, and two 12-round races: apply vs quick-sell on
one card, and two concurrent applies of one card. Both prove exactly one
winner, one effect, one audit row.
This commit is contained in:
funman300
2026-08-22 22:59:24 +00:00
parent 82c3d2c85a
commit a45155e0c5
5 changed files with 673 additions and 1 deletions
+18 -1
View File
@@ -13,7 +13,7 @@ use crate::{
services::{
club as club_svc, economy as economy_svc,
inventory::{self, OwnedItemQuery, OwnedItemView},
profile as profile_svc,
profile as profile_svc, training as training_svc,
},
};
@@ -119,6 +119,11 @@ pub async fn get_collection(
.fetch_all(&state.pool)
.await?;
// One query for the whole club, not one per item: this projection walks
// every owned row, and a per-item lookup here is the N+1 it has suffered
// before.
let training = training_svc::load_for_club(&state.pool, &club.id).await?;
// An owned row whose definition is absent from the loaded content CANNOT be
// projected (there is nothing to project), but it must never vanish in
// silence: that silent `filter_map` drop is how a real club once served
@@ -141,6 +146,16 @@ pub async fn get_collection(
};
let effective_overall = def.overall as i64 + o.training_bonus;
let effective_position = o.position_override.as_deref().unwrap_or(&def.position);
// Attribute training is per-instance state, so the finished attributes
// belong in the envelope beside the finished rating. The raw effects go
// out too: a caller that needs to show WHICH attribute was trained
// cannot recover that by differencing against a definition it may not
// have.
const NO_TRAINING: &[training_svc::TrainingEffect] = &[];
let effects = training
.get(&o.id)
.map(Vec::as_slice)
.unwrap_or(NO_TRAINING);
let body = json!({
"owned_card_id": o.id,
"content_kind": o.content_kind,
@@ -157,6 +172,8 @@ pub async fn get_collection(
"contract_matches": o.contract_matches,
"effective_overall": effective_overall,
"effective_position": effective_position,
"effective_attributes": training_svc::effective_attributes_json(def, effects),
"training": effects,
"card": def,
});
views.push(OwnedItemView {