feat(core): one instance-based ownership model for every kind of owned content
CI / Build, lint & test (push) Successful in 3m21s
CI / Build, lint & test (push) Successful in 3m21s
Core could only own players. Everything else a FUT club holds — managers, staff, consumables, kits, badges, balls, stadiums — had no representation, so the only way to show one to a client was to synthesise it on read. That is the failure mode this commit exists to make impossible: read authority, write authority and persistent ownership authority are now the same rows. MODEL. There is deliberately NO parallel items table. A manager, a consumable, a kit and a player are all rows in `owned_cards`, differing only by a new game-INDEPENDENT `content_kind` (player|manager|staff|consumable|kit|badge|ball| stadium|misc). A game adapter translates its own taxonomy — FIFA 17's `cardsubtypeid` and resource ranges — into one of those tokens before ownership reaches Core; no game's numerics land here. Ownership stays INSTANCE-based: `card_id` is the definition, `id` is the instance, and two copies of one definition remain two rows. `quantity` is a nullable per-instance attribute, not a replacement for the instance. The real profile settles this: its 17 consumables are instance-based and only SOME carry a wire `amount` (observed 1,2,4,5,10,15), while two copies of definition 5003068 exist as two distinct instances. So NULL means "not a stack" and a positive integer is the stack size; collapsing instances into counts is forbidden by the model. ACTIVE DESIGNATIONS. Migration 0024's two-slot kit table becomes `club_active_items` over the five slots that correspond exactly to the client's recovered equipped-state vocabulary (activeBadge 100, activeHomeKit 101, activeAwayKit 102, activeBall 103, activeStadium 104). There is no activeLeagueLogo or activeMisc token, so those kinds correctly get no slot. The invariants are schema-enforced rather than conventional: PK(club_id, slot) allows at most one item per role, `owned_card_id UNIQUE` makes "the same card is both home and away kit" unstorable, and ON DELETE CASCADE means a quick-sold or consumed item cannot be projected back as active. 0024's trigger is preserved in semantics — and dropped EXPLICITLY before its table, because it lives ON `owned_cards`, so DROP TABLE would have orphaned it and broken every later ownership transfer. It still exists because the market moves ownership by UPDATE, which no foreign key can observe. CONSUMABLE ACTIONS. `services/consume.rs` is one transaction primitive — validate source ownership and kind, validate target, mutate, consume the source exactly once, commit — guarded by `UNIQUE(profile_id, action_identity)` in migration 0027, the same discipline as `match_completions`. It supports both deleting the row and decrementing a stack, chosen by the caller, inside the one transaction and the one replay guard. It deliberately contains NO category formulas: an unreversed effect must not be invented, so callers supply the mutation and category validation stays explicit. `/club/kits` is replaced by slot-generic `/club/active-items`. `get_collection` now carries `content_kind` and `quantity`, accepts a `content_kind` filter, and — importantly — stops dropping an owned card with a missing definition silently: the envelope reports `owned_rows`, `unresolved_items` and the offending definition ids. That silent `filter_map` is the documented cause of a club that looks empty while the rows are all present. Verified against a REAL populated club, not a fixture: the production snapshot (migration 19) is copied to a tempdir, migrated to 0024, given two kit designations on real owned instances, then migrated to head. 1986 owned rows survive as content_kind='player', both designations land in `club_active_items`, no row gains a quantity, and the old table is gone. 258 tests pass, clippy clean.
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
-- Generic owned-content classification on the EXISTING ownership table.
|
||||
--
|
||||
-- Core owns ONE instance-based ownership model for every kind of owned content.
|
||||
-- There is deliberately no parallel "items" table: a manager, a consumable, a
|
||||
-- kit and a player are all rows in `owned_cards`, differing only by
|
||||
-- `content_kind`. Two copies of one definition remain TWO rows (instance-based
|
||||
-- ownership: `card_id` is the definition, `id` is the instance).
|
||||
--
|
||||
-- `content_kind` is a game-INDEPENDENT vocabulary. Game adapters translate their
|
||||
-- own taxonomy (e.g. FIFA 17 `cardsubtypeid` / resource ranges) into one of these
|
||||
-- tokens before ownership reaches Core; a game's numeric ids NEVER land here.
|
||||
--
|
||||
-- BACKFILL: none needed — every pre-existing row is a player card, which is
|
||||
-- exactly the column DEFAULT, so the ALTER backfills all existing ownership as
|
||||
-- 'player' in place. (Verified against a real populated club snapshot: 1986
|
||||
-- owned rows, all players.)
|
||||
ALTER TABLE owned_cards ADD COLUMN content_kind TEXT NOT NULL DEFAULT 'player'
|
||||
CHECK (content_kind IN (
|
||||
'player', 'manager', 'staff', 'consumable',
|
||||
'kit', 'badge', 'ball', 'stadium', 'misc'
|
||||
));
|
||||
|
||||
-- Optional stack count for content that is owned as an instance CARRYING a
|
||||
-- count rather than as a bare instance.
|
||||
--
|
||||
-- Evidence (real profile, 1995 owned items): consumables are instance-based with
|
||||
-- an OPTIONAL count — some carry a wire `amount` (observed 1,2,4,5,10,15), some
|
||||
-- omit the key entirely, and two copies of one definition exist as two distinct
|
||||
-- instances. So a count is a per-instance ATTRIBUTE, never a replacement for the
|
||||
-- instance: NULL means "not a stack", a positive integer is the stack size.
|
||||
-- Collapsing instances into counts is forbidden by the ownership model above.
|
||||
ALTER TABLE owned_cards ADD COLUMN quantity INTEGER
|
||||
CHECK (quantity IS NULL OR quantity >= 1);
|
||||
|
||||
-- Every club projection reads one kind at a time (players for the squad, kits
|
||||
-- for the club room, consumables for the item list), so the club+kind pair is
|
||||
-- the hot access path.
|
||||
CREATE INDEX IF NOT EXISTS idx_owned_cards_club_kind
|
||||
ON owned_cards(club_id, content_kind);
|
||||
@@ -0,0 +1,55 @@
|
||||
-- Generalise the two-slot kit designation (migration 0024) into the full set of
|
||||
-- active club designations.
|
||||
--
|
||||
-- Ownership still lives ONLY in `owned_cards`; this table records which owned
|
||||
-- INSTANCE currently occupies each club-scoped role. A row here is a pointer,
|
||||
-- never a second ownership authority.
|
||||
--
|
||||
-- Lifecycle invariants enforced by the schema, not by convention:
|
||||
-- * `PRIMARY KEY (club_id, slot)` — at most one active item per role.
|
||||
-- * `owned_card_id ... UNIQUE` — one owned instance can occupy at most ONE
|
||||
-- slot, so "the same card is both the home and the away kit" is unstorable.
|
||||
-- * `REFERENCES owned_cards(id) ON DELETE CASCADE` — quick-selling/consuming
|
||||
-- the item removes the designation, so a sold item can never be projected
|
||||
-- back to the client as active.
|
||||
-- * the BEFORE UPDATE trigger below — a market transfer moves ownership by
|
||||
-- UPDATE (the row id survives), which no FK action can see, so the
|
||||
-- designation is dropped explicitly before the owner changes.
|
||||
--
|
||||
-- `squad_managers` (migration 0023) is squad-scoped, not club-scoped, and is
|
||||
-- deliberately NOT folded in here.
|
||||
CREATE TABLE IF NOT EXISTS club_active_items (
|
||||
club_id TEXT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||
slot TEXT NOT NULL CHECK (slot IN (
|
||||
'home_kit', 'away_kit', 'badge', 'ball', 'stadium'
|
||||
)),
|
||||
owned_card_id TEXT NOT NULL UNIQUE REFERENCES owned_cards(id) ON DELETE CASCADE,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (club_id, slot)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_active_items_owned
|
||||
ON club_active_items(owned_card_id);
|
||||
|
||||
-- Carry every existing kit designation over: 'home' -> 'home_kit',
|
||||
-- 'away' -> 'away_kit'. No designation is lost and none is invented.
|
||||
INSERT INTO club_active_items (club_id, slot, owned_card_id, updated_at)
|
||||
SELECT club_id,
|
||||
CASE slot WHEN 'home' THEN 'home_kit' ELSE 'away_kit' END,
|
||||
owned_card_id,
|
||||
updated_at
|
||||
FROM club_kit_assignments
|
||||
WHERE slot IN ('home', 'away');
|
||||
|
||||
-- 0024's trigger lives ON owned_cards, so DROP TABLE would NOT remove it and
|
||||
-- every subsequent ownership transfer would fail on a missing table. Drop it
|
||||
-- explicitly first, then replace it with the generalised one.
|
||||
DROP TRIGGER IF EXISTS clear_club_kit_assignment_before_transfer;
|
||||
DROP TABLE club_kit_assignments;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS clear_club_active_item_before_transfer
|
||||
BEFORE UPDATE OF club_id ON owned_cards
|
||||
WHEN OLD.club_id <> NEW.club_id
|
||||
BEGIN
|
||||
DELETE FROM club_active_items WHERE owned_card_id = OLD.id;
|
||||
END;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Durable idempotency for applying a consumable to a target.
|
||||
--
|
||||
-- Same discipline as `match_completions` (migration 0022): ONE effect per
|
||||
-- (profile_id, action_identity). A sequential replay, a restart replay, a
|
||||
-- concurrent duplicate, or a retried HTTP request all collide on this UNIQUE and
|
||||
-- are refused BEFORE the target is mutated and BEFORE the source is consumed —
|
||||
-- so a consumable can never be spent twice, and its effect can never be applied
|
||||
-- twice from one spend.
|
||||
--
|
||||
-- `action_identity` is opaque to Core: the game adapter/host derives a stable
|
||||
-- per-application token from its own wire request. Core never parses it.
|
||||
--
|
||||
-- `source_owned_card_id` / `target_owned_card_id` are deliberately NOT foreign
|
||||
-- keys: the source row is DELETEd (or decremented to zero and deleted) by the
|
||||
-- very transaction that writes this record, and the target may later be sold.
|
||||
-- This table is an audit + replay record, not an ownership reference.
|
||||
--
|
||||
-- `effect` is the caller-supplied outcome summary stored verbatim as JSON text.
|
||||
-- Core defines NO per-category formula: what a given consumable does to its
|
||||
-- target is the calling game adapter's reversed behaviour, and an unreversed
|
||||
-- behaviour must not be invented here.
|
||||
CREATE TABLE consumable_applications (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
profile_id TEXT NOT NULL REFERENCES profiles(id),
|
||||
action_identity TEXT NOT NULL,
|
||||
source_owned_card_id TEXT NOT NULL,
|
||||
source_card_id TEXT NOT NULL,
|
||||
source_content_kind TEXT NOT NULL,
|
||||
-- 1 = the source instance was destroyed; 0 = a stack was decremented.
|
||||
source_consumed INTEGER NOT NULL,
|
||||
-- Remaining stack size after a decrement, NULL when the instance was destroyed.
|
||||
source_quantity_after INTEGER,
|
||||
target_owned_card_id TEXT,
|
||||
effect TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL,
|
||||
UNIQUE(profile_id, action_identity)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_consumable_applications_profile
|
||||
ON consumable_applications(profile_id);
|
||||
Reference in New Issue
Block a user