-- 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);