Commit Graph

3 Commits

Author SHA1 Message Date
funman300 90210702c3 feat(core): training replaces, and the rare card boosts all six
CI / Build, lint & test (push) Successful in 3m25s
Corrects two decisions the previous revision got wrong, both now settled by
the published FIFA 17 training guide -- the same source class and publisher
this project already accepted for the contract matrix, and which
cross-validates 6/6 against fcc_trainingcards on rows we had misclassified.

"You can only boost one attribute or all six... When you apply a new training
card to a player, he loses the improved attributes of previous training
cards. It does not accumulate, it replaces."

So a second card REPLACES rather than being refused, and two slots must never
be boosted at once -- our old composite key permitted exactly that, and
staging demonstrated it by holding a slot-4 and a slot-1 effect together.

Migration 0030 reshapes the table to one row per instance keyed on
owned_card_id alone, with attribute_index nullable for the rare all-six card,
and carries forward the most recent effect where several existed -- the
replaces rule applied retroactively. 0029 is left intact rather than
rewritten: it is already applied to supervised staging, where migration
history matters.

Apply is now delete-then-insert inside the one transaction, so the old effect
cannot survive a failed insert and the two cannot coexist. The outcome
records what it displaced instead of overwriting history silently.

The previous refusal was OUR policy standing in for an unknown, and it was
never evidence about FIFA 17. It is retired now that the behaviour is
recovered, not because the 409 was inconvenient.
2026-08-22 23:33:43 +00:00
funman300 a45155e0c5 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.
2026-08-22 22:59:24 +00:00
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