Commit Graph

2 Commits

Author SHA1 Message Date
funman300 e8be289660 feat(consume): durable per-instance contract state + HTTP apply route
CI / Build, lint & test (push) Successful in 3m16s
`consume_item` was a complete, tested, atomic apply transaction with zero
production callers and no route -- it could not be reached over HTTP because
its effect is an in-process `ItemMutation` trait object and the host is a
separate process on a synchronous JSON boundary.

Closes that gap with a CLOSED, Core-validated effect vocabulary rather than a
pass-through: `InstanceEffect::AddContractMatches { amount, cap,
default_when_unset }`. A generic "apply this field/value" escape hatch would
hand economic authority back to the caller and break the architecture.

The read-modify-write runs INSIDE the caller's transaction
(`min(cap, COALESCE(contract_matches, default) + amount)`) so two concurrent
applies cannot lose an update, and the reported `granted` stays the requested
amount even when the cap clamps the total.

Migration 0028 adds `owned_cards.contract_matches` NULLABLE: NULL means "Core
tracks no contract here", which keeps the pack-fresh default (a FIFA-specific
7) out of Core and leaves every existing row unchanged in meaning. ADD COLUMN,
not a rebuild -- a rebuild would drop 0026's transfer trigger.

Two ordering fixes forced by putting this on the live path:
* consume_item moves from DEFERRED `pool.begin()` to `BEGIN IMMEDIATE`, the
  discipline economy.rs documents: three reads precede the first write, which
  is exactly the shape that returns SQLITE_BUSY past the busy handler.
* the replay answer now precedes source validation. With DestroyInstance the
  first apply deletes the source, so the old order answered a retry with 404
  instead of the recorded outcome -- replay semantics were unreachable.
2026-08-22 18:23:05 +00:00
funman300 8b1081019f feat(core): one instance-based ownership model for every kind of owned content
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.
2026-08-21 19:10:54 +00:00