a45155e0c5
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.
50 lines
2.9 KiB
SQL
50 lines
2.9 KiB
SQL
-- 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);
|