Commit Graph

32 Commits

Author SHA1 Message Date
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 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 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 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 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 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 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
funman300 1ef2c436ab feat: Phase 5 — events system complete
CI / Build, lint & test (push) Successful in 2m5s
Adds a data-driven event system (#47 schema, #48 activation, #49 TOTW):

- EventDefinition JSON schema (event_type, effects, date range, is_manual)
- data/events/totw_week1.json — TOTW event active by date range (2024–2030),
  injects 5 TOTW cards into the market as [EVENT] listings
- data/events/seasonal_events.json — Spring Festival and Icon Weekend,
  manual-activation-only example events
- migrations/0004_events.sql — events override table (NULL/1/0 per event)
- services/event — load_event_definitions, compute_is_active (date range +
  manual override), get_active_events, get_all_with_status, activate/deactivate
- routes/events — GET /events, GET /events/:id, POST .../activate, .../deactivate
- services/market::refresh_npc_listings now accepts event_defs, injects bonus
  market cards from active events at 2× premium price
- 5 new integration tests (events list, single, activate/deactivate cycle,
  404 on unknown, market injection verification); 19/19 passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:02:45 -07:00
funman300 4ae1081aa9 feat: Phase 3 — polish & settings complete
Card pool:
- TOTW cards (5): overall 88-92, rarity "totw"
- Hero cards (5): overall 85-88, rarity "hero"
- Icon cards (5): overall 93-95, rarity "icon" (permanent, non-loan)

Packs:
- TOTW Pack (30,000 coins): 5 TOTW cards guaranteed
- Icon Pack (50,000 coins): 3 icon cards guaranteed
- Hero Pack (20,000 coins): 5 hero cards guaranteed

Objectives:
- Milestone objectives (6): win 10/50, score 100/500 goals, 10 SBCs, 25 packs

Formations:
- GET /formations returns 12 valid formation strings

Multiple named squads (#20):
- POST /squad with no squad_id always creates a new squad
- POST /squad with squad_id updates that specific squad
- GET /squads: list all squads for the club (metadata only)
- GET /squads/🆔 squad with players + chemistry
- DELETE /squads/🆔 remove a squad

Per-position goal stats (#41):
- Migration 0003: position_goals table
- POST /matches/result accepts optional goal_positions: ["ST","CAM",...]
- GET /statistics now includes position_goals map

Settings (#44-46):
- GET /settings: { difficulty, preferred_formation } with defaults
- PUT /settings: upsert any key-value combination

Draft mode skeleton (#38):
- GET /draft/squad?difficulty=... returns a randomly generated 11-player squad

Integration tests:
- 9 new tests covering: formations, pack buy/open, SBC submit, settings R/W,
  multiple squads, draft endpoint, goal position tracking, win streak

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 15:46:20 -07:00
funman300 0afce0dd59 feat: Phase 2 — game feel complete
Chemistry & squads:
- Chemistry calculation on GET /squad (club/league/nation links, max 100)
- Formation validation on POST /squad (exactly 11 starters, exactly 1 GK)

Objectives:
- Weekly objectives JSON (4 objectives: warrior, goals, dedicated, SBC)
- Daily objectives auto-reset at midnight UTC (background task)

SBC validation expanded:
- max_overall per-player enforcement
- required_clubs validation
- min_players_from_same_nation validation
- min_players_from_same_club validation

Market:
- GET /market?min_overall=X&position=Y filtering
- Expiry cleanup runs before every NPC refresh
- NPC market auto-refresh every 24h (background task, runs at startup)

Matches:
- GET /matches/opponent?difficulty=beginner|professional|world_class|legendary
  generates a random AI opponent squad from the card pool

Statistics:
- win_streak and best_win_streak tracking (migration 0002)
- GET /statistics/history?limit=N — last N matches with summary stats

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 15:33:09 -07:00
funman300 34bce2ce75 feat: Phase 1 — core game loop complete
New endpoints:
- GET  /cards/:card_id       single card lookup
- POST /packs/buy            purchase pack with coins
- POST /objectives/claim     claim completed objective reward
- GET  /matches              match history (limit/mode query params)
- GET  /sbc/:sbc_id          single SBC lookup

Card pool expanded:
- Bronze: 11 → 30 cards (19 nations)
- Silver: 0 → 20 cards (20 nations)
- Rare Gold: 0 → 10 cards (10 nations)

SBC pool expanded:
- Added 5 new SBCs (tri_nations, silver_to_gold, league_loyalty,
  african_stars, gold_standard)

Infrastructure:
- SQLite WAL mode enabled on pool init
- Foreign key enforcement enabled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 15:25:30 -07:00
funman300 1ffe0ffa9f Initial commit: OpenFUT Core
Offline Ultimate Team backend — game-independent REST API.

- 19 API endpoints: auth, profiles, clubs, cards, packs, squads,
  objectives, SBCs, match rewards, NPC market, statistics
- Axum + SQLite + SQLx with full migrations
- Weighted pack generator, SBC validation engine
- JSON-driven mod data (cards, packs, objectives, SBCs)
- 5 integration tests passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 14:54:51 -07:00