Commit Graph

7 Commits

Author SHA1 Message Date
funman300 1df03d4287 feat(match): consume one-match training effects for the players who played
CI / Build, lint & test (push) Successful in 3m19s
FIFA 17 training is a ONE-MATCH effect and the trigger is the PLAYER PLAYING,
not the match completing: a card applied to someone who stays on the bench or in
the reserves "will continue to benefit from the training effect until he plays"
(DOCUMENTED, fifauteam's contemporaneous FIFA 17 guide, corroborated across two
of its pages).

So Core expires exactly the instances the caller names, and never a whole club.
The participant list is supplied rather than derived here, deliberately:

- The FIFA 17 match wire carries NO lineup. Across 36,149 captured requests the
  19 match creates carry 5 keys and the 13 ends carry 6; the tokens "lineup" and
  "substitut" appear ZERO times, while "kitNumber" appears 1311 times and the
  same extraction recovers 23 instance ids from PUT /squad/0 in that same pcap.
  The absence is measured against a working positive control, not assumed.
- Core must not resolve it from the squad at completion either: the squad at end
  is provably not the squad that started (a captured match began 20:33:20 and
  the next squad save landed 12 minutes later with no /match/end between).

Empty participants therefore expires nothing, so a caller that cannot identify
who played is inert instead of destructive.

The mutation sits inside the existing single match transaction, under the same
is_economic guard as coins and statistics, so NoContest voids it exactly as it
voids everything else, and a rollback leaves boosts intact.

Tests: participant scoping (the benched player keeps his boost), empty-participant
inertness, club scoping, replay (a resubmitted completion does not consume a
freshly reapplied boost), NoContest, and a BeforeCommit fault that fires AFTER
the delete to prove the split-brain state "match rejected but training consumed"
cannot occur.
2026-08-23 02:58:29 +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 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 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 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 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 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