Commit Graph

56 Commits

Author SHA1 Message Date
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 82c3d2c85a feat(core): own the EA-authored non-player definition rating
CI / Build, lint & test (push) Successful in 3m23s
Adds `CardDefinition.source_rating: Option<u8>` -- the authoritative rating a
NON-PLAYER definition carries in EA's own tables (a staff card's `value` from
managercards/*coachcards/physiocards, a consumable's rating).

WHY NOT `overall`. `overall` feeds quick-sell pricing and squad projection, and
it is deliberately 0 for every non-player. Reusing it would silently revalue
staff, which is out of scope for the manager-contract milestone. `source_rating`
is a separate number read only by tier rules, so both pricing paths stay
byte-identical: Core's `quick_sell_coins(card.overall)` and the host's
`legacy_discard_value(item.rating)` see exactly what they saw before, and
`effective_overall` is unchanged.

MUST stay Option: CardDefinition has no `#[serde(default)]`, so a required field
would reject every already-shipped content pack, whereas a missing Option
deserializes to None. A test pins that backwards compatibility, because it is
the property that lets Core and the emitter be deployed independently.

No migration: Core does not persist definitions at all -- they are JSON content
packs parsed at startup into an immutable in-memory CardDb. `/collection`
embeds the serialized definition wholesale, so `card.source_rating` reaches the
host with no projection change.
2026-08-22 20:08:09 +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
funman300 233df1d99d feat(core): dry-run mode for reclassify, and a per-kind tally
CI / Build, lint & test (push) Successful in 3m7s
Production is frozen and sits on schema 19, so the content_kind correction it
needs cannot be applied yet. `--dry-run` runs the real UPDATEs and rolls the
transaction back, so an operator can see exactly what a run would touch before
touching a database they cannot easily restore — the counts are measured, not
predicted.

`updated_by_kind` reports the SHAPE of the change ("consumable 17, staff 3"),
which is the thing worth sanity-checking: a different shape means the source
profile moved and the mapping needs regenerating.

Measured against a copy of prod-core.db: 20 rows would change (17 consumable,
3 staff), 1966 already correct, 0 unmatched, and the copy was verified unchanged
afterwards.
2026-08-21 21:23:43 +00:00
funman300 c896545cf0 feat(core): reclassify the content_kind of already-imported owned rows
CI / Build, lint & test (push) Failing after 1m57s
A profile import is once-only — the same fingerprint no-ops and a different one
is refused — so a taxonomy correction cannot arrive by re-importing. Every club
imported before content_kind existed still records its coaches, kits and
consumables as players, because Core defaults an unstated row to `player`.

Core stays generic: the caller supplies card_id -> kind, since only the game
adapter can map its own taxonomy. One transaction, idempotent, scoped to a
single game so a shared database cannot be reclassified across games, and an
assignment nobody owns is reported rather than invented.
2026-08-21 19:55:39 +00:00
funman300 36bc594924 docs(core): quantity is not a consumable's wire amount
CI / Build, lint & test (push) Successful in 4m32s
FIFA 17's consumable `amount` is the definition's effect magnitude (a "+15
training" card), measured: across every owned consumable in the real profile
the observed `amount` equals its `fcc_*` table row, and each copy is its own
instance carrying the same value. Recording it as `quantity` would claim the
club owns fifteen of them.
2026-08-21 19:49:40 +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
funman300 bae0a2bdaa fix(matches): close the second, unguarded match-economy authority
CI / Build, lint & test (push) Successful in 3m15s
`POST /matches/result` granted coins, XP, level-ups, statistics, four objective
metrics, loan expiry, season progression and achievements across a dozen
SEPARATE writes with no transaction and no idempotency key. Every call
re-credited the same match, and any mid-way failure half-applied it. It sat
beside `/matches/complete`, so nothing stopped one match being paid twice
through two different doors.

It cannot be made exactly-once in place: that needs a caller-supplied match
identity, and this request shape has none. Deriving one from the body would
collapse two legitimate matches with the same scoreline into one — the
under-credit trap already documented for the `fp:` fallback. So the route fails
closed: it rejects with a message naming `/matches/complete`, rather than 404,
so a caller learns why.

The behaviour it uniquely drove is kept, not deleted. `process_match` was the
ONLY caller of loan expiry and Core's season model, so both move into
`complete_match`'s transaction behind opt-in `expire_loans` / `advance_season`
flags. Both default OFF, which keeps the FIFA 17 retail path byte-identical:
FIFA 17 has its own loan and Seasons models, and Core's season END GRANTS coins
and a pack — invisible economy on a path that never asked for it. Their pooled
implementations are replaced by `expire_loans_tx` and
`season::record_match_tx`, so a loan that expires or a season that ends commits
with the match that caused it.

Notifications (level-up / objective / loan / season) were pooled side effects of
the removed path. They now emit from the route AFTER the commit — never inside
the transaction, since a failed notification must not roll back a completed
match — and only when `applied`, so a replay no longer re-notifies. The pooled
path had no replay concept and notified every time.

Also fixes a real bug this surfaced: `/auth/reset` never deleted
`match_completions`, which carries un-cascaded foreign keys to BOTH `matches`
and `profiles`. Any profile that completed a match through the authoritative
route — i.e. every FIFA 17 profile after a retail match — failed to reset with a
database error. It is now deleted first, and ordering is documented.

Tests: the 20 integration call sites move to the authoritative route through one
helper that mints a per-call identity (each call IS a distinct match). New
coverage for the closed path: it rejects without moving the balance or writing
history; Core progression stays off unless opted into; a replay does not
duplicate notifications; and a profile that completed matches can still be
reset.
2026-08-21 04:47:57 +00:00
funman300 f0550e2ae1 feat(club): persist active home/away kit assignments
CI / Build, lint & test (push) Successful in 2m50s
Kits are ownership-backed club items: the owned instance stays in the
generic owned_cards inventory and only the two active roles get their own
table. This mirrors the squad_managers precedent and keeps every
FIFA-specific resourceId/wire concern in the game adapter.

* migration 0024: club_kit_assignments(club_id, slot, owned_card_id) with a
  UNIQUE owned_card_id (one instance cannot hold both roles) and
  ON DELETE CASCADE from owned_cards so a quick-sell clears the role.
* a BEFORE UPDATE OF club_id trigger clears the designation on a market
  transfer, which moves ownership by UPDATE and so is not covered by the
  cascade.
* set_active_club_kits replaces BOTH slots in one transaction, rejects
  home == away, and validates each instance against current club ownership,
  so a half-applied or dangling designation is not representable.
* get_active_club_kits revalidates ownership on read, so a stale row can
  never surface another club's item.
* GET/PUT /club/kits expose the pair.

Tests cover restart persistence, replace/clear without duplicates, atomic
rejection of invalid references, and clearing via delete and transfer.
2026-08-21 03:17:40 +00:00
funman300 2fb835200f style(core): cargo fmt match/club agent additions
CI / Build, lint & test (push) Successful in 4m1s
2026-08-20 17:59:02 +00:00
funman300 5f9f556af8 feat(app): register GET/PUT /club/manager routes 2026-08-20 16:43:51 +00:00
funman300 9036f5f411 feat(club): ownership-backed squad manager assignment
Add a generic, durable squad->manager assignment (migration 0023
squad_managers) so a manager persists across squad save, reload, and
server restart, backed by authoritative Core owned_cards.

- squad_managers(squad_id PK, owned_card_id, updated_at) with ON DELETE
  CASCADE on both FKs: quick-selling the manager auto-clears the
  assignment (no resurrection); one manager per squad (no duplicates).
- club::{get,set,clear}_squad_manager validate club ownership of both the
  squad and the card, and re-check ownership on read (defends against a
  stale row left by a market transfer).
- GET/PUT /club/manager routes expose the assignment; FIFA wire meaning
  stays in the adapter.

Tests: persistence across reload+restart (headline), reassignment
replace/no-duplicate, clear/no-resurrection, cascade on quick-sell,
foreign-card rejection.
2026-08-20 16:43:28 +00:00
funman300 b0306a9b1d feat(match): atomic exactly-once match-completion transaction
Add complete_match: one BEGIN/COMMIT that validates identity + result,
enforces a durable (profile_id, match_identity) uniqueness guard
(migration 0022 match_completions), persists match history, and grants
coins + XP/level-ups + W/D/L/DNF statistics + objectives + achievements
exactly once. Any failure rolls the whole match back (no compensating
cleanup). Handles sequential/restart/concurrent replay, conflicting
re-report (first result canonical), DNF (loss economics, own stat
bucket) and no-contest (zero economic effect).

Adds tx-scoped variants: statistics::record_match_tx/
record_position_goals_tx, objective::increment_metric_tx,
achievement::check_and_unlock_tx. New MatchResultKind/CompleteMatchRequest/
MatchCompletionResult models + POST /matches/complete route.
2026-08-20 16:38:16 +00:00
funman300 a034e74c16 style(core): apply cargo fmt across routes, services, models, tests
CI / Build, lint & test (push) Successful in 2m19s
Pure rustfmt reflow (import grouping, array/match-arm/call-arg wrapping,
alphabetized module decls, comment realignment). No semantic change:
full-diff and `git diff -w` both confirm logic byte-identical to 271c363;
workspace builds and all 74 core tests pass. Retained pre-existing WIP
brought forward after verification.
2026-08-20 16:05:41 +00:00
funman300 271c3639ed feat(sbc): make submissions atomic and durable
CI / Build, lint & test (push) Failing after 52s
2026-08-18 18:26:23 +00:00
funman300 637a21eac1 fix(economy): quick-selling a squadded card failed with a FOREIGN KEY error
Found while implementing sale settlement, and reproduced before fixing:

  sell_item(pool, "club", "item-x", 250)
    -> Database(SqliteError { code: 787, message: "FOREIGN KEY constraint failed" })

squad_players.owned_card_id is a FK onto owned_cards(id) and db.rs enables
foreign_keys, so DELETEing an item that sits in any lineup is refused outright.
services::economy::sell_item never cleared the lineup, and this is the LIVE FIFA 17
quick-sell path (host economy_store -> econ.sell_item -> POST /economy/sell-item).
Selling a card that happens to be in your squad is ordinary, not an edge case.

sell_item now evicts the item from every lineup first, inside its existing
transaction, so a wrong-owner sale rolls the eviction back with everything else
(asserted, not assumed).

Also folds routes/cards.rs::delete_owned_card into the same authority. It
hand-rolled DELETE + club::add_coins directly on the pool, which had BOTH defects:
no transaction, so a failed credit destroyed the card for nothing, and the same
missing squad eviction. Its response shape is unchanged and the pre-existing route
test still passes.

Tests: selling_a_squadded_item_succeeds_and_frees_the_slot (the repro) and
a_rejected_quick_sell_leaves_the_lineup_intact. Core 55 lib + 123 integration pass,
clippy clean.

Not deployed — production is mid live-test.
2026-08-18 01:01:13 +00:00
funman300 31ab4a683e feat(economy): atomic market sale settlement — transfer ownership, credit seller, destroy fee
Core had no way to settle a real sale. Every "buy" MINTED a new owned_cards row
(services/economy.rs::purchase_item), so a sold card existed twice; the seller was
never credited; no fee arithmetic existed anywhere in the project; and no
/economy/* route could even name a counterparty, since all eight resolve one club
from the X-OpenFUT-Game active profile.

Adds settle_sale() beside the existing tx-scoped primitives, so it inherits the
module's proven atomicity (pool.acquire + BEGIN IMMEDIATE + finish) rather than
re-deriving it: debit buyer gross -> evict from squads -> transfer the EXISTING row
-> credit seller gross-fee. The fee is simply never credited anywhere, which is
what destroys it. Exposed as POST /economy/settle-sale, the one economy route that
names clubs explicitly because a sale has two sides; an omitted buyer means a
counterparty OUTSIDE the modelled economy, never a silent fallback to the active
club (which would settle a club against itself).

Ownership moves by UPDATE ... WHERE id = ? AND club_id = ?, an ownership CAS. No
INSERT and no DELETE on the two-party path, so duplication is ruled out
structurally, not by an assertion.

Two bugs found by writing the tests rather than by reading the code:

1. Deriving the seller from CURRENT ownership let two racing buyers BOTH succeed —
   after the first sale the item belonged to B, so the second call read B as the
   seller and chain-sold it B -> C. Core has no listing concept and could not
   notice the replay. The seller is now the caller's EXPECTED owner and every
   ownership statement is predicated on it, which makes the CAS authoritative about
   "already sold" independently of caller-side listing state.

2. Debiting before transferring made a replay fail as "insufficient balance" (the
   buyer had spent the coins on the sale that succeeded), so the ownership guard was
   shadowed and settling_the_same_sale_twice_pays_once passed WITHOUT exercising
   the guard it named. Ownership is now judged first; the test asserts NotFound
   specifically and adds a cheap affordable replay that only ownership can refuse.

Squad eviction is mandatory, not cosmetic: squad_players.owned_card_id is a FK and
the pool enables foreign_keys, so an Outside sale of a squadded card would fail
outright, and a transfer preserves the row id so a stale lineup row would leave the
PREVIOUS owner fielding a card they no longer own.

Tests: 21 service (canonical 15,000/750/14,250 fixture, conservation, squad
eviction, Outside retirement, 8 invalid paths, zero-price, replay, two-buyer race)
+ 6 route-level over HTTP with two parties. Core 194 pass.

Deliberately NOT done: no wire/route output change, no deployment, and nothing yet
decides that a player's listing has sold — the seller-facing sold wire state needs
live client evidence and must not be guessed.
2026-08-18 00:51:20 +00:00
funman300 68d10658c7 fix(economy): close SBC dup-card exploit + non-atomic economy races
Correctness fixes from docs/CORE_CORRECTNESS_ISSUES.md:

- Issue 3 (HIGH, exploit): submit_sbc dedups owned_card_ids (HashSet) and
  bounds the list (MAX_SBC_CARDS=30) before resolution. A repeated id resolved
  the same card N times, passed validation, and granted the reward while only
  one card was consumed -> any SBC satisfiable with one duplicated card = free
  reward. Now rejected with BadRequest. Regression test added.
- Issue 2 (HIGH): TOCTOU economy mutations closed with single-statement
  compare-and-swap (no transaction plumbing): club::spend_coins conditional
  debit (WHERE coins >= ?) + rows_affected, also rejects negative amounts;
  pack::open_pack claims the pack before minting; market::buy_listing claims
  the listing before charging and releases on debit failure; market::sell_card
  guards the DELETE with owner + rows_affected; checkin::claim uses a
  conditional INSERT ... WHERE NOT EXISTS (today) before paying out.
- Issue 4 (LOW): season.rs .expect() on missing rows -> graceful AppError;
  checkin index (streak-1) % 7 -> .rem_euclid(7) (guards negative index panic).
- Issue 1 (LOW): migration 0019 adds sbc_submissions.club_id + backfill;
  submit_sbc binds it so the MY CLUB milestone query stops silently reading 0.

Core suite 179 green + clippy clean.
2026-08-17 16:00:48 +00:00
OpenFUT Agent fbb54eac95 fix(economy): BEGIN IMMEDIATE for write transactions (concurrency-safe)
Root cause of the fresh-DB multi-connection write failure: economy writes ran in
a DEFERRED transaction (pool.begin() = BEGIN) that read then wrote; SQLite returns
SQLITE_BUSY (code 5, 'database is locked') *immediately* when a deferred tx
upgrades to a write while another holds the write lock, bypassing busy_timeout to
avoid deadlock. Fix: open each write op with BEGIN IMMEDIATE on a dedicated pooled
connection (finish() commits/rolls back), taking the write lock up front so
busy_timeout serializes writers. Reproduction test: 100 fresh DBs x 8 concurrent
grant_reward — was 644/800 failures, now 800/800 succeed with correct final
balance (no lost update). Reads unchanged.
2026-08-13 20:20:14 +00:00
OpenFUT Agent 75b183077f fix(db): serialize SQLite WAL establishment before pooling
Switching a brand-new DB file to WAL is a one-time file-level change; letting
multiple pooled connections perform it concurrently during warm-up races the
switch and can surface a spurious lock (observed as intermittent 500s under the
integration harness). Open ONE connection to establish WAL before the pool
opens, so every pooled connection thereafter only re-asserts an already-WAL
file. Keeps per-connection foreign_keys + busy_timeout.
2026-08-13 20:03:40 +00:00
OpenFUT Agent 0360135322 fix(db): per-connection sqlite pragmas + busy_timeout
Set journal_mode=WAL, foreign_keys, and a 5s busy_timeout on the connection
options so EVERY pooled connection gets them. Previously WAL/foreign_keys were
set by a one-off PRAGMA on the pool (configuring only whichever connection
served that query), and no busy_timeout was set — so under concurrent access a
transient SQLITE_BUSY failed the transaction (surfaced as a 500 database error)
instead of waiting. This makes economy transactions robust under concurrency.
2026-08-13 19:55:13 +00:00
OpenFUT Agent bcc4f5104a feat(economy): purchase_items primitive + entitlement import seeding
purchase_items: generic atomic debit + mint of several items (fail-closed) for
open-on-buy Store packs (Store BUY returns items immediately) + POST
/economy/purchase-items route. Import: add optional entitlements[] to
ProfileImportRequest, seeding unconsumed packs rows in the same transaction (so a
source's unopened packs become Core entitlements); idempotency via the existing
import fingerprint. Tests: 2 purchase_items unit + endpoint + entitlement-seed
import. Core matrix 45 lib + 116 integration + 9 import green; clippy clean.
2026-08-13 19:27:06 +00:00
OpenFUT Agent d32dc6e3ae feat(economy): expose transactional economy service over HTTP
Add generic /economy/* routes (balance, entitlements, purchase-entitlement,
redeem-entitlement, sell-item, grant-reward, purchase-item) resolving the club
server-side via the same game-scoped active-profile mechanism as /collection —
callers never supply a club id, so there is no cross-club access. Add
list_unopened_entitlements + Entitlement for the reader side. Game-neutral: no
currency names or wire semantics. 4 endpoint integration tests (balance+reward,
purchase+redeem, purchase-item+sell fail-closed, insufficient-funds fail-closed);
full matrix 43 lib + 115 integration green.
2026-08-13 19:09:46 +00:00
OpenFUT Agent c8269d0df7 feat(economy): add generic purchase_item (atomic debit + mint)
The FIFA17 economy audit proved the transfer market is synthetic-seller:
buy-now mints a new owned item and debits the buyer; no real counterparty,
no sale-credit/expiry/fee. The generic primitive that models this is an
atomic debit + inventory add (purchase_item), NOT a two-party
transfer_item_with_payment (which would be unused). Fail-closed: an
unaffordable purchase debits nothing and mints nothing. 2 unit tests.
2026-08-13 18:58:36 +00:00
OpenFUT Agent ee2caa0bb0 feat(economy): generic atomic profile-economy authority
Add a game-agnostic economy service that exposes atomic, fail-closed
operations over Core's existing durable tables rather than forking a
parallel persistence stack:

  * currency ledger -> clubs.coins
  * owned inventory -> owned_cards
  * entitlements    -> packs (opaque definition_id, consume-once `opened`)

Compound operations run inside a single SQLite transaction, closing the
atomicity gap in the pool-scoped club::{spend,add}_coins helpers whose
read/modify/write spans multiple round-trips. Public ops:

  balance, purchase_entitlement (debit+grant), redeem_entitlement
  (consume-once + add items, all-or-nothing), sell_item (remove+credit),
  grant_reward (credit).

Deliberately game-neutral: currency names, entitlement/pack ids, and
per-save item-id sequences stay in the per-game adapter that drives these
primitives. 9 unit tests cover debit/credit fail-closed rollback,
consume-once, partial-redeem rollback, and non-negative guards.
2026-08-13 18:44:51 +00:00
funman300 66c88fb48e fix(cli): route tracing diagnostics to stderr
Machine output (the import/seed-dev subcommands' JSON result) now owns stdout;
tracing logs go to stderr. Lets a caller parse the import outcome without
log-line contamination on stdout. No behavioral change to the server beyond
where its logs are written.
2026-08-12 20:36:12 +00:00
funman300 9f3c545c46 feat(import): generic transactional profile-import service + CLI
Core performs a GAME-AGNOSTIC transactional profile import; all FIFA17 semantics
(manifest parse, wire ids, resourceId/nextItemId, squad extension v1,
CardDefinitionId/OwnedItemId choice) stay in openfut-import-fifa17. Core sees
only opaque ids and opaque extension bytes.

services::import::apply_profile_import(pool, card_db, ProfileImportRequest):
- ONE SQLite transaction installs profile + club + all owned cards + canonical
  squad + one opaque game extension; commits together or not at all.
- Definition preflight (pre-tx): every owned card_id MUST resolve in loaded
  production content, so ownership never points at absent content.
- Squad all-or-nothing (pre-tx): every active-squad OwnedItemId MUST be in the
  imported ownership set.
- OwnedItemId uniqueness + generic extension bounds enforced pre-tx.
- Core computes the canonical squad fingerprint itself (never adapter-supplied)
  and persists the extension atomically, exactly as the live squad-write path.

Rerun identity via profiles.import_fingerprint (migration 0018, nullable):
- identical source_fingerprint on an already-imported game -> idempotent no-op;
- differing token -> fail (needs explicit update mode);
- pre-existing non-imported profile -> never clobbered.
So a crash after identity-seeding re-runs cleanly with no cleanup/reminting.

CLI: 'openfut-core import <request.json>' loads production content packs, parses
a generic request, applies. squad_fingerprint made pub(crate) for reuse.

8 import-service tests (happy path, idempotent rerun, fingerprint mismatch,
missing-definition no-write, squad-not-owned, dup OwnedItemId, non-imported
clobber guard, empty-owned). clippy -D warnings clean; full suite 159 green.
2026-08-12 20:01:27 +00:00
funman300 352ad11bc4 feat(content): production content-pack loader + referenced-definition preflight
Production real-profile content is loaded via an explicit path, NOT the dev-only
OPENFUT_DEV_CONTENT_GAMES gate:
- Config.content_packs from env OPENFUT_CONTENT_PACKS (comma-sep file paths).
- CardDb::load_pack(path): merge an explicit CardDefinition[] production pack.
- app::build loads dev packs then production packs.

Preflight (app::build, always on): every owned_cards.card_id MUST resolve to a
loaded CardDefinition. A real profile with owned players but even ONE missing
definition fails LOUDLY instead of silently serving an empty /collection; an
empty owned_cards table (fresh DB / tests) passes.

2 preflight integration tests (missing def fails, loaded def passes). clippy
-D warnings clean; full suite 151 tests green.
2026-08-12 19:49:14 +00:00
funman300 3084a46dcc style(squad): rustfmt the squad-ext transport routes
Formatting-only follow-up to the squad-ext routes; no behavior change.
Pre-existing Core fmt drift elsewhere (e.g. app.rs route chain) predates
this branch (615c5fd is already not fmt-clean) and is intentionally left
untouched — not reformatting frozen Core beyond the squad-ext change.
2026-08-12 03:58:34 +00:00
funman300 9b2c6b82f2 feat(squad): expose extension-aware squad read/write over HTTP
Add two thin transport routes wrapping the existing extension services
(no new domain logic; Core still owns validation, ownership, the atomic
canonical+extension transaction, the server fingerprint, and staleness):

  GET /squad/ext?namespace=<ns>  -> read_squad_with_ext
      returns {squad, players, extension:{state: fresh|stale|missing,
      schema_version, payload, stored_fingerprint, current_fingerprint}}
  PUT /squad/replace             -> replace_squad_with_extension
      body {name, formation, slots[], client_reported, extension};
      resolves the active squad in place (creates if none); returns
      {squad_id, canonical_fingerprint, slots_written}

A game host needs these to read/persist the FIFA squad extension atomically
over HTTP; the service functions existed but were unreachable. Adds an
integration test (replace -> read Fresh, verbatim payload, idempotent PUT
converges to the same fingerprint, missing-namespace -> Missing) and clears
a pre-existing len_zero lint so the crate is clippy-clean.
2026-08-12 02:47:15 +00:00
funman300 615c5fd7a5 feat(squad): generic game-scoped opaque extension + server fingerprint, atomic in replace_squad tx 2026-08-12 01:39:04 +00:00
funman300 36abd4b6fb feat(seed): curated FIFA17 dev content pack + opt-in game-scoped ownership seed 2026-08-11 22:55:02 +00:00
funman300 6acae54f80 feat(club): semantic owned-item query + FIFA17 filter/pagination fix
Game-independent owned-inventory query (services::inventory::{OwnedItemQuery,
apply_query} + a Quality tier) that filters (AND) -> orders deterministically
(effective_overall desc, owned_card_id asc) -> paginates, wired into
GET /collection. Fixes the FIFA17 My Squad search: the Python oracle applied
only league+team and ignored level/rare/position/nation/start/count (proven by
response sha256 identity across pages -> the request-amplification bug); Core
now applies all proven filters and paginates. rare=SP left UNKNOWN.

Tests: +9 inventory unit, +14 /collection integration (full matrix incl. the
repeated-first-page regression); 9 mutations killed.

Isolated from an unrelated dirty working tree via a clean worktree at eab522a;
touches only the 5 slice files, no unrelated reformatting.
2026-08-11 21:38:16 +00:00
funman300 aecbff0de8 squad: transactional replace_squad + a game-rules boundary for evaluation
Driven by a retail FIFA 17 capture: the client sends the WHOLE squad on
every save (~2KB, every slot/item/kit number), and a user swapping two
players produced nine changed slots across two saves. Slot deltas
therefore do not describe intent, so the only honest semantic operation
is "this is the squad now".

replace_squad, and why save_squad no longer has its own write path
------------------------------------------------------------------
save_squad UPDATEd the squad, DELETEd every squad_players row, then
INSERTed the new ones one at a time -- all outside a transaction. A
failure part-way through left a squad with some old players deleted and
only some new ones written: a state nobody asked for and no client can
detect. It also never checked that the cards being placed belonged to the
club, and accepted the same card in two slots.

replace_squad validates BEFORE any write (so a rejection leaves the
stored squad untouched) and performs every write in one transaction:

  - card must exist AND belong to this club
  - a card may occupy at most one slot
  - a slot may hold at most one card
  - slot indices must not be negative
  - the squad being replaced must belong to this club

save_squad is now a thin wrapper over it. That deliberately tightens the
existing Core REST route -- it now validates ownership and rejects
duplicates. Those were bugs, and two write paths with different
guarantees is how the stricter one gets bypassed.

A cross-club card is reported as NotFound, not Forbidden: whether a card
exists in someone else's club is not the caller's business.

Game-rules boundary
-------------------
Core contained calculate_chemistry -- a full FUT-style link-scoring
formula. Chemistry is game-specific and changed between FIFA
generations, so a formula compiled into generic Core quietly makes Core a
FIFA-something server.

It now sits behind SquadRules, with the existing implementation preserved
byte-for-byte in behaviour as DefaultSquadRules ("openfut-default-v2").
Rules take a resolved SquadSnapshot of pure data rather than a pool and a
card database, so they are synchronous, testable without fixtures, and
cannot reach Core's storage. Fifa17SquadRules is deliberately NOT
written: the algorithm is unproven and inventing one is worse than having
none.

Client-reported values
----------------------
FIFA sends its own chemistry/rating/starRating. ClientReportedEvaluation
is a DIFFERENT TYPE from SquadEvaluation, so assigning one where the
other belongs does not compile. Disagreement is reported through
EvaluationComparison and never reconciled in either direction -- the
server's value stands and the mismatch is surfaced for investigation
against the exact squad that produced it.

Evidence
--------
17 unit tests, 113 in the crate, 7/7 mutations killed including
"ownership check removed", "replacement becomes a merge" and
"client-reported chemistry becomes the server value".

Scope note: `cargo fmt` without -p reformatted ~27 unrelated files; those
were reverted so this commit touches only the squad path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:33:05 +00:00
funman300 8c8a4116bf wip: checkpoint multi-game core work 2026-08-07 12:03:21 -07:00
funman300 11a811db6a Fix SQLite create_if_missing so the database is auto-created on first run
CI / Build, lint & test (push) Failing after 1m20s
Previously the binary failed with "unable to open database file" (SQLite
code 14) when no openfut.db existed yet. Using SqliteConnectOptions with
create_if_missing(true) tells SQLite to create the file if absent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 19:24:02 -07:00
funman300 e438b58d88 Phase 25: division leaderboard, market trade history
CI / Build, lint & test (push) Failing after 2m10s
- Market: record buy/sell history in market_history table; expose via
  GET /market/trade-history (last 30 events, newest first)
- Division: GET /division/leaderboard returns 10-club table with 9 seeded
  NPC entries + player row, sorted by pts; stable within a season
- rand feature small_rng enabled in Cargo.toml for SmallRng use
- 3 new integration tests (leaderboard count, sort order, empty trade history)
- Core: 96 tests passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 19:13:10 -07:00
funman300 956bfe7a73 Phase 24: daily check-in system + club milestones endpoint
CI / Build, lint & test (push) Failing after 28s
- migrations/0012_daily_checkin.sql: daily_checkins table tracking streak,
  coins awarded, pack granted, timestamp per profile
- services/checkin.rs: get_status() (available, streak_day, next reward),
  claim() (idempotent same-day guard, streak logic: continue if yesterday
  or today, else reset; 7-day cycle with STREAK_COINS array, day-7 pack)
- routes/club.rs: GET /club/checkin, POST /club/checkin, GET /club/milestones
  (computed from statistics, season_history, owned_cards, sbc_submissions,
  daily_checkins tables; no new DB tables needed)
- 4 new integration tests: checkin available initially, claim awards coins,
  idempotent same-day, milestones endpoint structure (93 → 93+4=97 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 19:05:52 -07:00
funman300 28d7490555 Phase 23: season history + division zone data + new route
CI / Build, lint & test (push) Failing after 1m19s
- migrations/0011_season_history.sql: persist one row per completed season
- models/season.rs: SeasonHistoryEntry struct; public SEASON_LENGTH /
  PROMOTION_PTS / RELEGATION_PTS consts; pts_above_safe, can_be_relegated,
  promotion_achievable helpers
- services/season.rs: write history entry on season rollover; get_history()
  returns last 20 seasons newest-first
- routes/division.rs: GET /division now includes promotion_pts, relegation_pts,
  season_length, pts_above_safe, promotion_achievable, can_be_relegated;
  new GET /division/history endpoint
- 3 new integration tests: history empty, history records after promotion,
  division response has zone fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 18:38:02 -07:00
funman300 c458b8cdbd Phase 21: auth status + full game reset
CI / Build, lint & test (push) Failing after 53s
GET /auth/status returns { has_profile: true/false } without erroring,
so the dashboard can check on load whether an onboarding flow is needed.

POST /auth/reset wipes every user-data table (profiles, clubs, owned_cards,
packs, squads, matches, statistics, achievements, notifications, seasons,
market_listings, sbc_submissions, draft_sessions, fut_champs_sessions,
objective_progress, position_goals, events, settings) in reverse-dependency
order, leaving the schema intact. A fresh POST /auth/local creates a new
club on the clean slate.

4 new tests: status before/after profile creation, reset clears profile
and allows a new one. Core now at 86 tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 18:07:36 -07:00
funman300 679f147c6a Phase 20: achievement system
CI / Build, lint & test (push) Failing after 1m19s
18 data-driven achievements (achievements.json) across 8 trigger categories:
matches_played, matches_won, goals_scored, packs_opened, sbcs_completed,
cards_owned, level, objectives_completed, drafts_completed. Rarities span
common → epic. Coin rewards range from 500 (first_match) to 6000 (win_50).

check_and_unlock() queries the relevant metric from existing tables, skips
already-earned achievements via INSERT OR IGNORE, grants coin rewards, and
fires a persistent notification per unlock. Trigger values are cached per
call to avoid redundant DB round-trips for same-trigger achievements.

Checks run automatically after every match result (all triggers), every
pack open (packs_opened), and every successful SBC submission (sbcs_completed).

GET /achievements returns all definitions annotated with unlocked/unlocked_at,
plus earned and total counts. POST /matches/result response gains an
achievements_unlocked array (empty when nothing new unlocked).

AppState gains achievement_defs (Arc<Vec<AchievementDefinition>>) loaded
from data/achievements/**/*.json at startup — same pattern as obj_defs.

5 new tests: list endpoint, first_match unlock, first_win unlock, no-dup
guard, coin reward verification. Core now at 82 tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 18:03:54 -07:00
funman300 f0dbabc409 Phase 19: persistent notifications system
CI / Build, lint & test (push) Failing after 58s
New notifications table (migration 0009) stores event-driven alerts
alongside the existing dynamic state notifications (unclaimed objectives,
expiring loans, season ending soon).

Persistent notifications are created automatically during match
processing: one per level gained, one per expired loan card, one per
objective newly completed, and one when a season ends (with
promotion/relegation result and rewards in the body).

GET /notifications now returns a merged list — persistent entries
(newest-first, limit 50) followed by dynamic entries — plus an
unread_count for the badge. Each item carries type, title, body,
is_read, and (for persistent) id and created_at.

PATCH /notifications/:id/read marks a single persistent notification
read. POST /notifications/read-all marks all persistent ones read.

Four new tests: unread_count field, level-up notification creation,
mark-all-read, single-read PATCH. Core now at 77 tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:57:24 -07:00
funman300 9e481ec072 Phase 18: data expansion and market pricing fix
CI / Build, lint & test (push) Failing after 1m15s
Four new card files (Serie A, Ligue 1, Primeira Liga, Eredivisie) add 45
cards across Italian, French, Portuguese, and Dutch football, bringing the
total card pool from 115 to 160. Each file uses fictional club/player
names and null image_path, keeping the project free of copyrighted assets.

Ten new SBC challenges (league_sbcs.json) targeting specific leagues and
nations increase the total from 7 to 17 challenges. Includes league purity
SBCs (Premier League, Bundesliga, Serie A, Ligue 1, Eredivisie), nation
combo SBCs (Iberian Derby, South American Fire), and utility SBCs (elite
strikers mini-submission, bronze-to-silver recycler, world tour).

NPC market refresh now shuffles the card pool before picking 24 listings
(was: take first 20 unshuffled), and price variance tightened from ±100%
to ±25% of the OVR-tier base price for more realistic NPC competition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:49:38 -07:00
funman300 d97695d414 Phase 17: profile level-up system
CI / Build, lint & test (push) Failing after 57s
XP thresholds (500→1200→2000→…→11000→+2500/level) drive automatic level
increases. add_xp_with_levelup() replaces bare add_xp() in match
processing: for each level gained it grants level×500 coins and milestone
packs (bronze@5, silver@10, gold@15, rare_gold@20, gold every 5 after).

GET /profile now returns computed level (recalculated from XP so it
stays consistent), xp_to_next_level, and xp_for_next_level so the
dashboard can render a progress bar without a second call.

POST /matches/result response gains level_ups array (empty when no
level-up occurred) with new_level, coins_granted, pack_granted per event.

Four new tests: profile level fields, level_for_xp boundary checks,
level-up event in match result, milestone pack unit test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:41:13 -07:00
funman300 3d50da3589 Phase 15: add GET /packs/store endpoint
CI / Build, lint & test (push) Failing after 52s
Returns all pack definitions with id, name, description, cost_coins, and
total_cards so the dashboard can render a purchasable pack store without
needing to know the definitions at compile time.

Two new tests: store listing shape and buy-then-open round-trip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:25:37 -07:00
funman300 26b8e9efef Phase 11: FUT Champions mode and Division Rivals weekly rewards
CI / Build, lint & test (push) Failing after 1m33s
FUT Champions (Weekend League):
- POST /fut-champs/start — open a new 30-match week (one active session at a time)
- GET /fut-champs — current session status with matches_remaining
- POST /fut-champs/:id/result — record a match; auto-completes and computes tier at 30
- POST /fut-champs/:id/claim — claim coins + pack reward (idempotent guard)
- GET /fut-champs/history — past sessions newest-first
- 11 reward tiers: Elite (27+ wins, 50k coins + icon pack) down to Bronze 1 (0 wins, 250 coins)

Division Rivals:
- POST /rivals/claim-weekly — claim weekly reward scaled by current division
- Week counter is monotonic (offline-safe, no real-time calendar dependency)
- Division 1-3 get a bonus pack alongside coins

Migration 0008 adds fut_champs_sessions table and two columns to seasons.
12 new integration tests — all 67 pass, clippy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:06:18 -07:00
funman300 19c7b9989d Phase 10: card upgrade system (chemistry styles, position change, training)
CI / Build, lint & test (push) Failing after 1m37s
- GET /chemistry-styles — lists 18 FUT-style chemistry styles with stat boost breakdowns
- POST /collection/:id/chemistry-style — apply a style (Shadow, Hunter, Anchor, etc.)
- POST /collection/:id/position — override a player's position for 500 coins
- POST /collection/:id/training — add +1/+2/+3 OVR training bonus (max +3 total)
- GET /collection now includes chemistry_style, position_override, training_bonus,
  effective_overall and effective_position fields per owned card
- Migration 0007 adds three columns to owned_cards with safe defaults
- 10 new integration tests — all 55 pass, clippy clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:02:44 -07:00
funman300 a749fba93c Phase 9: division/season tracking, club customization, pack history, notifications
CI / Build, lint & test (push) Failing after 1m42s
- GET /division returns live season stats (points, record, promotion threshold)
- PUT /club allows updating club name and manager_name
- GET /packs/history returns opened packs with full card definitions
- GET /notifications dynamically surfaces completed objectives, expiring loans, season end
- Club model gains manager_name column (migration 0006 already added it)
- Pack model gains opened_cards and opened_at; pack SELECT queries updated
- 9 new integration tests — all 45 pass, clippy clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:50:35 -07:00
funman300 bfd6de6896 Phase 8 (Core): stateful draft, quick-sell, objectives by ID, market listings
CI / Build, lint & test (push) Failing after 1m21s
Draft v2 — stateful FUT-style pick sessions:
  - POST /draft/start?difficulty=<> — creates session, returns 5
    candidates for GK slot (position order: GK RB CB CB LB CDM CM CAM RW ST LW)
  - POST /draft/sessions/:id/pick { card_id } — validates candidate,
    advances to next position; on last pick grants coins+pack reward
    (avg OVR ≥84 → gold pack + 2000 coins, ≥78 → silver + 1000, else 400)
  - GET /draft/sessions/:id — session state with per-pick cards
  - POST /draft/sessions/:id/abandon — cancel without reward
  - Migration 0005_draft_sessions.sql

Quick-sell:
  - DELETE /collection/:owned_card_id — removes card, credits coins based
    on overall (85+ → 1500, 80-84 → 900, 75-79 → 600, 65-74 → 300, <65 → 150)

Objectives:
  - GET /objectives/:id — single objective with progress
  - POST /objectives/:id/claim — claim reward by URL param (complement to
    existing POST /objectives/claim body-param endpoint)

Market:
  - GET /market/my-listings — active listings posted by current club
  - DELETE /market/listings/:id — cancel a listing, returns card to collection

Tests: 9 new integration tests (37 total, all passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:41:55 -07:00
funman300 8fa125cfd6 Phase 7 (Core): chemistry v2, expanded card pool & search filters
CI / Build, lint & test (push) Failing after 1m46s
- Chemistry v2: FUT-style link scoring with per-player breakdown
  (club +3/link cap 6, league +1/link cap 4, nation +1/link cap 3;
   player cap 10, team cap 100); chemistry response now includes
   per-player breakdown with club/league/nation link counts and pts
- Card pool: 34 new cards across Premier League, La Liga, Bundesliga
  with overlapping clubs/nations for meaningful chemistry testing
- Card search: extended GET /cards with nation, league, club,
  min_overall, max_overall, limit query params; results sorted by
  overall descending
- Match opponent: added "ultimate" difficulty band (85+ OVR);
  random formation selection from 6 tactical formations; falls back
  to lower OVR pool when not enough cards at the requested band
- Tests: 9 new integration tests (28 total, all passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:31:56 -07:00