9 Commits

Author SHA1 Message Date
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
49 changed files with 5040 additions and 598 deletions
+5 -4
View File
@@ -70,7 +70,7 @@ DATABASE_URL=sqlite://./myclub.db LISTEN_ADDR=127.0.0.1:8080 ./target/release/op
| `GET` | `/squad` | Get active squad |
| `POST` | `/squad` | Save squad |
| `GET` | `/objectives` | List objectives with progress |
| `POST` | `/matches/result` | Submit match result + receive rewards |
| `POST` | `/matches/complete` | Complete a match exactly once + receive rewards |
| `GET` | `/sbc` | List SBC definitions |
| `POST` | `/sbc/submit` | Submit SBC solution |
| `GET` | `/market` | Browse NPC transfer market |
@@ -95,10 +95,11 @@ curl http://localhost:8080/club
# Open your starter pack
curl -X POST http://localhost:8080/packs/open/<pack_id>
# Submit a match win
curl -X POST http://localhost:8080/matches/result \
# Submit a match win. `match_identity` keys exactly-once economy: resubmitting the
# same identity echoes the first result and grants nothing twice.
curl -X POST http://localhost:8080/matches/complete \
-H 'Content-Type: application/json' \
-d '{"squad_id":"any","opponent_name":"Beginner AI","goals_for":3,"goals_against":0,"mode":"squad_battles"}'
-d '{"match_identity":"match-1","result":"win","squad_id":"any","opponent_name":"Beginner AI","goals_for":3,"goals_against":0,"mode":"squad_battles"}'
```
---
+25 -9
View File
@@ -71,17 +71,33 @@ All game-content data is loaded at startup from `data/` into `Arc`-wrapped colle
8. Increment pack stats + objective progress
9. Return `PackOpenResult { pack_id, cards }`
## Data Flow: Match Result
## Data Flow: Match Completion
1. `POST /matches/result` → `routes::matches::post_match_result`
1. `POST /matches/complete` → `routes::matches::post_match_complete`
2. Fetch profile + club
3. `services::match_service::process_match(...)`
4. Determine outcome (win/draw/loss), compute coins + XP
5. Insert match record
6. `club::add_coins`, `profile::add_xp`
7. `statistics::record_match`
8. `objective::increment_metric` for matches_played, matches_won, goals_scored, coins_earned
9. Return `MatchRewardResult`
3. `services::match_service::complete_match(...)` — everything below runs in ONE
transaction and either commits together or rolls back whole
4. Insert the match-history row (also takes SQLite's writer lock, serializing
overlapping completions)
5. Insert the `match_completions` guard row. `UNIQUE(profile_id, match_identity)`
makes the economy exactly-once: a duplicate — sequential, concurrent, after a
restart, or a conflicting re-report — collides here and the whole attempt
rolls back, then echoes the persisted result with `applied = false`
6. Coins, XP + level-ups, W/D/L/DNF statistics, objective metrics, achievements
7. Opt-in only: `expire_loans` (loan tick-down/removal) and `advance_season`
(Core's own division model, which grants coins and a pack at season end).
Both default OFF so a game with its own loan/season model — FIFA 17 — is
unaffected
8. Commit, then the route emits player notifications for what landed (never
inside the transaction, and only when `applied`)
9. Return `MatchCompletionResult`
`POST /matches/result` was REMOVED as an economy path. It performed the same
grants across a dozen separate writes with no transaction and no idempotency
key, which made it a second economy authority that re-credited on every call and
could half-apply on any mid-way failure. It now rejects and names
`/matches/complete`. Exactly-once requires a caller-supplied match identity,
which its request shape did not carry and could not derive.
## Single-Profile Design
+29
View File
@@ -0,0 +1,29 @@
-- Durable economic idempotency for match completion.
--
-- One economic effect per (profile_id, match_identity), independent of any HTTP
-- receipt idempotency the game host/adapter layers on top. A sequential replay,
-- a restart replay, a concurrent duplicate, or a conflicting re-report of the
-- same match all collide on this UNIQUE and are refused BEFORE any coins, XP,
-- statistics, objectives, or achievements are applied — the first completion is
-- the one canonical result, the rest are idempotent no-ops.
--
-- `match_identity` is opaque to Core: the game adapter/host derives a stable
-- per-match token (e.g. the FIFA17 match-create id). Core never parses it.
CREATE TABLE match_completions (
id TEXT PRIMARY KEY NOT NULL,
profile_id TEXT NOT NULL REFERENCES profiles(id),
match_identity TEXT NOT NULL,
result TEXT NOT NULL, -- canonical: win | draw | loss | dnf | no_contest
coins_awarded INTEGER NOT NULL DEFAULT 0,
xp_awarded INTEGER NOT NULL DEFAULT 0,
match_id TEXT NOT NULL REFERENCES matches(id),
completed_at TEXT NOT NULL,
UNIQUE(profile_id, match_identity)
);
CREATE INDEX idx_match_completions_profile ON match_completions(profile_id);
-- W/D/L already live on `statistics`; add the DNF (abandon/quit) bucket so the
-- four match outcomes are mutually-exclusive counters. A did-not-finish is
-- economically a loss but is tallied here, not in `matches_lost`.
ALTER TABLE statistics ADD COLUMN matches_dnf INTEGER NOT NULL DEFAULT 0;
+25
View File
@@ -0,0 +1,25 @@
-- Squad manager assignment: an owned item assigned as a squad's manager.
--
-- Generic, game-neutral canonical state. Core does not know what a "manager"
-- means to any game; it only records that one owned item (`owned_card_id`) is
-- assigned to a squad in the manager role. The FIFA 17 adapter owns the wire
-- meaning (itemType "manager", contract, chemistry) exactly as it owns player
-- item shaping — Core just persists the ownership-backed assignment durably and
-- atomically, so a manager survives squad save / reload / server restart.
--
-- One manager per squad: `squad_id` is the primary key, so a re-assignment
-- REPLACEs rather than accumulating (no duplicate-manager rows).
--
-- `owned_card_id` references `owned_cards(id)` with ON DELETE CASCADE: quick
-- selling / discarding the manager card (a DELETE on owned_cards) removes the
-- assignment automatically, so a sold manager is never resurrected on the next
-- squad read. Reads additionally re-check the manager still belongs to the club
-- (see `club::get_squad_manager`), defending against a stale row left by a
-- market transfer (which UPDATEs owner rather than deleting).
CREATE TABLE IF NOT EXISTS squad_managers (
squad_id TEXT PRIMARY KEY NOT NULL REFERENCES squads(id) ON DELETE CASCADE,
owned_card_id TEXT NOT NULL REFERENCES owned_cards(id) ON DELETE CASCADE,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_squad_managers_owned ON squad_managers(owned_card_id);
+23
View File
@@ -0,0 +1,23 @@
-- Active home/away kits for a club. Ownership remains the generic owned_cards
-- inventory; this table only records which owned instances occupy the two kit
-- roles. FIFA-specific resource ids and wire shapes stay in the FIFA17 adapter.
CREATE TABLE IF NOT EXISTS club_kit_assignments (
club_id TEXT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
slot TEXT NOT NULL CHECK (slot IN ('home', 'away')),
owned_card_id TEXT NOT NULL UNIQUE REFERENCES owned_cards(id) ON DELETE CASCADE,
updated_at TEXT NOT NULL,
PRIMARY KEY (club_id, slot)
);
CREATE INDEX IF NOT EXISTS idx_club_kit_assignments_owned
ON club_kit_assignments(owned_card_id);
-- Market transfers change owned_cards.club_id by UPDATE rather than DELETE.
-- Remove any old-club active designation before ownership moves so a stale row
-- cannot hide or block the item for its new owner.
CREATE TRIGGER IF NOT EXISTS clear_club_kit_assignment_before_transfer
BEFORE UPDATE OF club_id ON owned_cards
WHEN OLD.club_id <> NEW.club_id
BEGIN
DELETE FROM club_kit_assignments WHERE owned_card_id = OLD.id;
END;
+39
View File
@@ -0,0 +1,39 @@
-- Generic owned-content classification on the EXISTING ownership table.
--
-- Core owns ONE instance-based ownership model for every kind of owned content.
-- 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
-- `content_kind`. Two copies of one definition remain TWO rows (instance-based
-- ownership: `card_id` is the definition, `id` is the instance).
--
-- `content_kind` is a game-INDEPENDENT vocabulary. Game adapters translate their
-- own taxonomy (e.g. FIFA 17 `cardsubtypeid` / resource ranges) into one of these
-- tokens before ownership reaches Core; a game's numeric ids NEVER land here.
--
-- BACKFILL: none needed — every pre-existing row is a player card, which is
-- exactly the column DEFAULT, so the ALTER backfills all existing ownership as
-- 'player' in place. (Verified against a real populated club snapshot: 1986
-- owned rows, all players.)
ALTER TABLE owned_cards ADD COLUMN content_kind TEXT NOT NULL DEFAULT 'player'
CHECK (content_kind IN (
'player', 'manager', 'staff', 'consumable',
'kit', 'badge', 'ball', 'stadium', 'misc'
));
-- Optional stack count for content that is owned as an instance CARRYING a
-- count rather than as a bare instance.
--
-- Evidence (real profile, 1995 owned items): consumables are instance-based with
-- an OPTIONAL count — some carry a wire `amount` (observed 1,2,4,5,10,15), some
-- omit the key entirely, and two copies of one definition exist as two distinct
-- instances. So a count is a per-instance ATTRIBUTE, never a replacement for the
-- instance: NULL means "not a stack", a positive integer is the stack size.
-- Collapsing instances into counts is forbidden by the ownership model above.
ALTER TABLE owned_cards ADD COLUMN quantity INTEGER
CHECK (quantity IS NULL OR quantity >= 1);
-- Every club projection reads one kind at a time (players for the squad, kits
-- for the club room, consumables for the item list), so the club+kind pair is
-- the hot access path.
CREATE INDEX IF NOT EXISTS idx_owned_cards_club_kind
ON owned_cards(club_id, content_kind);
+55
View File
@@ -0,0 +1,55 @@
-- Generalise the two-slot kit designation (migration 0024) into the full set of
-- active club designations.
--
-- Ownership still lives ONLY in `owned_cards`; this table records which owned
-- INSTANCE currently occupies each club-scoped role. A row here is a pointer,
-- never a second ownership authority.
--
-- Lifecycle invariants enforced by the schema, not by convention:
-- * `PRIMARY KEY (club_id, slot)` — at most one active item per role.
-- * `owned_card_id ... UNIQUE` — one owned instance can occupy at most ONE
-- slot, so "the same card is both the home and the away kit" is unstorable.
-- * `REFERENCES owned_cards(id) ON DELETE CASCADE` — quick-selling/consuming
-- the item removes the designation, so a sold item can never be projected
-- back to the client as active.
-- * the BEFORE UPDATE trigger below — a market transfer moves ownership by
-- UPDATE (the row id survives), which no FK action can see, so the
-- designation is dropped explicitly before the owner changes.
--
-- `squad_managers` (migration 0023) is squad-scoped, not club-scoped, and is
-- deliberately NOT folded in here.
CREATE TABLE IF NOT EXISTS club_active_items (
club_id TEXT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
slot TEXT NOT NULL CHECK (slot IN (
'home_kit', 'away_kit', 'badge', 'ball', 'stadium'
)),
owned_card_id TEXT NOT NULL UNIQUE REFERENCES owned_cards(id) ON DELETE CASCADE,
updated_at TEXT NOT NULL,
PRIMARY KEY (club_id, slot)
);
CREATE INDEX IF NOT EXISTS idx_club_active_items_owned
ON club_active_items(owned_card_id);
-- Carry every existing kit designation over: 'home' -> 'home_kit',
-- 'away' -> 'away_kit'. No designation is lost and none is invented.
INSERT INTO club_active_items (club_id, slot, owned_card_id, updated_at)
SELECT club_id,
CASE slot WHEN 'home' THEN 'home_kit' ELSE 'away_kit' END,
owned_card_id,
updated_at
FROM club_kit_assignments
WHERE slot IN ('home', 'away');
-- 0024's trigger lives ON owned_cards, so DROP TABLE would NOT remove it and
-- every subsequent ownership transfer would fail on a missing table. Drop it
-- explicitly first, then replace it with the generalised one.
DROP TRIGGER IF EXISTS clear_club_kit_assignment_before_transfer;
DROP TABLE club_kit_assignments;
CREATE TRIGGER IF NOT EXISTS clear_club_active_item_before_transfer
BEFORE UPDATE OF club_id ON owned_cards
WHEN OLD.club_id <> NEW.club_id
BEGIN
DELETE FROM club_active_items WHERE owned_card_id = OLD.id;
END;
@@ -0,0 +1,40 @@
-- Durable idempotency for applying a consumable to a target.
--
-- Same discipline as `match_completions` (migration 0022): ONE effect per
-- (profile_id, action_identity). A sequential replay, a restart replay, a
-- concurrent duplicate, or a retried HTTP request all collide on this UNIQUE and
-- are refused BEFORE the target is mutated and BEFORE the source is consumed —
-- so a consumable can never be spent twice, and its effect can never be applied
-- twice from one spend.
--
-- `action_identity` is opaque to Core: the game adapter/host derives a stable
-- per-application token from its own wire request. Core never parses it.
--
-- `source_owned_card_id` / `target_owned_card_id` are deliberately NOT foreign
-- keys: the source row is DELETEd (or decremented to zero and deleted) by the
-- very transaction that writes this record, and the target may later be sold.
-- This table is an audit + replay record, not an ownership reference.
--
-- `effect` is the caller-supplied outcome summary stored verbatim as JSON text.
-- Core defines NO per-category formula: what a given consumable does to its
-- target is the calling game adapter's reversed behaviour, and an unreversed
-- behaviour must not be invented here.
CREATE TABLE consumable_applications (
id TEXT PRIMARY KEY NOT NULL,
profile_id TEXT NOT NULL REFERENCES profiles(id),
action_identity TEXT NOT NULL,
source_owned_card_id TEXT NOT NULL,
source_card_id TEXT NOT NULL,
source_content_kind TEXT NOT NULL,
-- 1 = the source instance was destroyed; 0 = a stack was decremented.
source_consumed INTEGER NOT NULL,
-- Remaining stack size after a decrement, NULL when the instance was destroyed.
source_quantity_after INTEGER,
target_owned_card_id TEXT,
effect TEXT NOT NULL,
applied_at TEXT NOT NULL,
UNIQUE(profile_id, action_identity)
);
CREATE INDEX idx_consumable_applications_profile
ON consumable_applications(profile_id);
+10
View File
@@ -169,6 +169,12 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/club/checkin", get(routes::club::get_checkin_status))
.route("/club/checkin", post(routes::club::post_checkin))
.route("/club/milestones", get(routes::club::get_milestones))
// ClubB: squad manager assignment (append-only; own lines).
.route("/club/manager", get(routes::club::get_squad_manager))
.route("/club/manager", put(routes::club::put_squad_manager))
// Active club-item designations (home/away kit, badge, ball, stadium).
.route("/club/active-items", get(routes::club::get_active_items))
.route("/club/active-items", put(routes::club::put_active_item))
.route("/cards", get(routes::cards::get_cards))
.route("/cards/:card_id", get(routes::cards::get_card))
.route("/collection", get(routes::cards::get_collection))
@@ -250,6 +256,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
.route("/matches", get(routes::matches::get_matches))
.route("/matches/opponent", get(routes::matches::get_opponent))
.route("/matches/result", post(routes::matches::post_match_result))
.route(
"/matches/complete",
post(routes::matches::post_match_complete),
)
.route("/sbc", get(routes::sbc::get_sbcs))
.route("/sbc/status", get(routes::sbc::get_sbc_status))
.route("/sbc/submit", post(routes::sbc::post_sbc_submit))
+245 -1
View File
@@ -54,6 +54,165 @@ impl Quality {
}
}
/// What KIND of content one owned instance is.
///
/// The game-independent ownership vocabulary: Core has exactly one instance-based
/// ownership model (`owned_cards`) and this enum is the only thing that
/// distinguishes a manager from a player from a chemistry style. It carries NO
/// game numerics — a game adapter translates its own taxonomy (FIFA 17
/// `cardsubtypeid`, resource ranges, …) into these tokens before ownership
/// reaches Core, and translates them back on the way out.
///
/// The tokens are the persisted values of `owned_cards.content_kind` and are
/// pinned by that column's CHECK constraint (migration 0025).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[serde(rename_all = "lowercase")]
#[sqlx(rename_all = "lowercase")]
pub enum ContentKind {
/// A playable footballer card.
#[default]
Player,
/// A squad manager.
Manager,
/// Non-manager club staff (fitness/goalkeeping/… coaches, physios, scouts).
Staff,
/// A single-use item applied to a target (contract, fitness, healing,
/// chemistry style, position modifier, training).
Consumable,
/// A club kit (occupies the home or away designation).
Kit,
/// A club badge/crest.
Badge,
/// A match ball.
Ball,
/// A club stadium.
Stadium,
/// Owned content that is legitimately none of the above.
Misc,
}
impl ContentKind {
/// The canonical persisted token.
pub fn as_str(self) -> &'static str {
match self {
ContentKind::Player => "player",
ContentKind::Manager => "manager",
ContentKind::Staff => "staff",
ContentKind::Consumable => "consumable",
ContentKind::Kit => "kit",
ContentKind::Badge => "badge",
ContentKind::Ball => "ball",
ContentKind::Stadium => "stadium",
ContentKind::Misc => "misc",
}
}
/// Every kind, in declaration order (for exhaustive round-trip checks).
pub const ALL: [ContentKind; 9] = [
ContentKind::Player,
ContentKind::Manager,
ContentKind::Staff,
ContentKind::Consumable,
ContentKind::Kit,
ContentKind::Badge,
ContentKind::Ball,
ContentKind::Stadium,
ContentKind::Misc,
];
}
impl std::fmt::Display for ContentKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for ContentKind {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"player" => Ok(ContentKind::Player),
"manager" => Ok(ContentKind::Manager),
"staff" => Ok(ContentKind::Staff),
"consumable" => Ok(ContentKind::Consumable),
"kit" => Ok(ContentKind::Kit),
"badge" => Ok(ContentKind::Badge),
"ball" => Ok(ContentKind::Ball),
"stadium" => Ok(ContentKind::Stadium),
"misc" => Ok(ContentKind::Misc),
other => Err(format!("unknown content kind '{other}'")),
}
}
}
/// A club-scoped "active item" designation slot.
///
/// One owned instance may occupy at most one slot and each slot holds at most one
/// instance (migration 0026 `club_active_items`). Each slot admits exactly one
/// [`ContentKind`], so a badge can never be installed as a kit.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ActiveSlot {
HomeKit,
AwayKit,
Badge,
Ball,
Stadium,
}
impl ActiveSlot {
/// The canonical persisted token (`club_active_items.slot`).
pub fn as_str(self) -> &'static str {
match self {
ActiveSlot::HomeKit => "home_kit",
ActiveSlot::AwayKit => "away_kit",
ActiveSlot::Badge => "badge",
ActiveSlot::Ball => "ball",
ActiveSlot::Stadium => "stadium",
}
}
/// The one content kind this slot accepts.
pub fn required_kind(self) -> ContentKind {
match self {
ActiveSlot::HomeKit | ActiveSlot::AwayKit => ContentKind::Kit,
ActiveSlot::Badge => ContentKind::Badge,
ActiveSlot::Ball => ContentKind::Ball,
ActiveSlot::Stadium => ContentKind::Stadium,
}
}
pub const ALL: [ActiveSlot; 5] = [
ActiveSlot::HomeKit,
ActiveSlot::AwayKit,
ActiveSlot::Badge,
ActiveSlot::Ball,
ActiveSlot::Stadium,
];
}
impl std::fmt::Display for ActiveSlot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for ActiveSlot {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"home_kit" => Ok(ActiveSlot::HomeKit),
"away_kit" => Ok(ActiveSlot::AwayKit),
"badge" => Ok(ActiveSlot::Badge),
"ball" => Ok(ActiveSlot::Ball),
"stadium" => Ok(ActiveSlot::Stadium),
other => Err(format!("unknown active-item slot '{other}'")),
}
}
}
/// A card definition loaded from JSON data files.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardDefinition {
@@ -74,7 +233,12 @@ pub struct CardDefinition {
pub image_path: Option<String>,
}
/// A card instance owned by a club (stored in DB).
/// One owned content INSTANCE (stored in DB).
///
/// Instance-based: `id` is the instance, `card_id` the definition, so two copies
/// of one definition are two rows. `content_kind` says what the instance IS;
/// `quantity` is an optional per-instance stack size (`None` = not a stack) and
/// never a substitute for an instance.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct OwnedCard {
pub id: String,
@@ -86,4 +250,84 @@ pub struct OwnedCard {
pub chemistry_style: String,
pub position_override: Option<String>,
pub training_bonus: i64,
pub content_kind: ContentKind,
pub quantity: Option<i64>,
}
/// The ONE canonical column list for reading an [`OwnedCard`].
///
/// `sqlx::FromRow` needs every field present in the row, so a hand-written
/// partial column list decodes into a runtime `ColumnNotFound` rather than a
/// compile error. Every read goes through this const so adding a column can
/// never leave a stale SELECT behind; append `WHERE …` to it.
pub const OWNED_CARD_SELECT: &str = "SELECT id, club_id, card_id, is_loan, \
loan_matches_remaining, acquired_at, chemistry_style, position_override, \
training_bonus, content_kind, quantity FROM owned_cards";
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
/// The persisted token, the serde token and the parser MUST agree for every
/// kind: the DB CHECK, the HTTP body and the adapter all read the same
/// vocabulary, so a divergence would silently mis-classify ownership.
#[test]
fn content_kind_round_trips_token_serde_and_parse() {
for kind in ContentKind::ALL {
let token = kind.as_str();
assert_eq!(
serde_json::to_string(&kind).unwrap(),
format!("\"{token}\""),
"serde token must equal the persisted token"
);
assert_eq!(
serde_json::from_str::<ContentKind>(&format!("\"{token}\"")).unwrap(),
kind
);
assert_eq!(ContentKind::from_str(token).unwrap(), kind);
assert_eq!(kind.to_string(), token);
}
}
/// The vocabulary is closed and pinned to migration 0025's CHECK list.
#[test]
fn content_kind_vocabulary_is_exactly_the_contract() {
let tokens: Vec<&str> = ContentKind::ALL.iter().map(|k| k.as_str()).collect();
assert_eq!(
tokens,
vec![
"player",
"manager",
"staff",
"consumable",
"kit",
"badge",
"ball",
"stadium",
"misc"
]
);
assert!(ContentKind::from_str("Player").is_err(), "case-sensitive");
assert!(ContentKind::from_str("coach").is_err());
assert_eq!(ContentKind::default(), ContentKind::Player);
}
#[test]
fn active_slot_round_trips_and_pins_its_required_kind() {
for slot in ActiveSlot::ALL {
let token = slot.as_str();
assert_eq!(ActiveSlot::from_str(token).unwrap(), slot);
assert_eq!(
serde_json::to_string(&slot).unwrap(),
format!("\"{token}\"")
);
}
assert_eq!(ActiveSlot::HomeKit.required_kind(), ContentKind::Kit);
assert_eq!(ActiveSlot::AwayKit.required_kind(), ContentKind::Kit);
assert_eq!(ActiveSlot::Badge.required_kind(), ContentKind::Badge);
assert_eq!(ActiveSlot::Ball.required_kind(), ContentKind::Ball);
assert_eq!(ActiveSlot::Stadium.required_kind(), ContentKind::Stadium);
assert!(ActiveSlot::from_str("home").is_err(), "0024's old token");
}
}
+92 -16
View File
@@ -11,14 +11,43 @@ pub enum MatchOutcome {
Loss,
}
#[derive(Debug, Deserialize)]
pub struct SubmitMatchRequest {
pub squad_id: String,
pub opponent_name: String,
pub goals_for: i64,
pub goals_against: i64,
pub mode: String,
pub goal_positions: Option<Vec<String>>,
/// Canonical, game-independent economic result of a completed match. The game
/// adapter maps its own wire (FIFA17 `endReason`, score, …) onto this — Core
/// never sees a game-specific reason string.
///
/// * `Win` / `Draw` / `Loss` — a finished match; standard reward tiers.
/// * `Dnf` — did-not-finish (abandon/quit). Economically a loss, but tallied in
/// its own statistics bucket and never in `matches_lost`.
/// * `NoContest` — a voided match. Zero economic effect: no coins, XP, or
/// W/D/L/DNF change; recorded only for history + idempotency.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MatchResultKind {
Win,
Draw,
Loss,
Dnf,
NoContest,
}
impl MatchResultKind {
/// The canonical lowercase token persisted in `matches.outcome` and
/// `match_completions.result`.
pub fn as_str(self) -> &'static str {
match self {
MatchResultKind::Win => "win",
MatchResultKind::Draw => "draw",
MatchResultKind::Loss => "loss",
MatchResultKind::Dnf => "dnf",
MatchResultKind::NoContest => "no_contest",
}
}
/// Whether this result applies any economic effect (coins / XP / statistics /
/// objectives / achievements). `NoContest` is the only non-economic result.
pub fn is_economic(self) -> bool {
!matches!(self, MatchResultKind::NoContest)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
@@ -72,18 +101,65 @@ impl Match {
}
}
/// Request to atomically complete a match exactly once. `match_identity` is the
/// opaque, host-supplied per-match token that keys durable economic idempotency
/// (persona/profile + match_identity). `result` is the canonical outcome the
/// game adapter derived from its wire; `goals_for`/`goals_against` are recorded
/// for history and statistics (0-0 is normal for a DNF/no-contest).
#[derive(Debug, Clone, Deserialize)]
pub struct CompleteMatchRequest {
pub match_identity: String,
pub result: MatchResultKind,
pub squad_id: String,
pub opponent_name: String,
pub goals_for: i64,
pub goals_against: i64,
pub mode: String,
#[serde(default)]
pub goal_positions: Option<Vec<String>>,
/// Tick down `loan_matches_remaining` for this squad's starters and remove
/// the cards whose loan ran out.
///
/// OFF by default so a game whose loan model is its own (FIFA 17 does not
/// route loans through Core) is unaffected. Callers of Core's own match
/// modes opt in.
#[serde(default)]
pub expire_loans: bool,
/// Advance Core's OWN season model (division progress, and its end-of-season
/// coin/pack award).
///
/// OFF by default: this grants economy, and it is NOT the same thing as a
/// game's native seasons (FIFA 17 offline Seasons are the adapter's, keyed by
/// its own wire). Only a caller using Core's season model opts in.
#[serde(default)]
pub advance_season: bool,
}
/// Outcome of [`crate::services::match_service::complete_match`].
#[derive(Debug, Serialize)]
pub struct MatchRewardResult {
pub match_record: Match,
pub struct MatchCompletionResult {
/// `true` when THIS call applied the economic effect; `false` on an
/// idempotent replay of an already-completed match (the persisted canonical
/// result is echoed unchanged).
pub applied: bool,
pub match_identity: String,
pub result: MatchResultKind,
pub coins_awarded: i64,
pub xp_awarded: i64,
/// Club balance after completion — echoed so the host can render the wire
/// reward body without a second round-trip.
pub coins_balance: i64,
/// Objectives completed by this match (empty on a replay).
pub objectives_updated: Vec<String>,
/// Owned card IDs removed because the loan expired this match.
pub expired_loans: Vec<String>,
/// Present when this match completed the current season.
pub season_end: Option<crate::models::season::SeasonEndSummary>,
/// Non-empty when the player levelled up one or more times from this match's XP.
/// Level-ups gained from this match's XP (empty on a replay).
pub level_ups: Vec<LevelUpEvent>,
/// Achievements unlocked as a result of this match.
/// Achievements unlocked by this match (empty on a replay).
pub achievements_unlocked: Vec<AchievementDefinition>,
/// Owned card ids removed because their loan expired on this match. Empty
/// unless the caller set `expire_loans`, and empty on a replay.
pub expired_loans: Vec<String>,
/// Present when this match ended a Core season. `None` unless the caller set
/// `advance_season`, and `None` on a replay.
pub season_end: Option<crate::models::season::SeasonEndSummary>,
pub match_record: Match,
}
+3 -3
View File
@@ -1,19 +1,19 @@
pub mod achievement;
pub mod card;
pub mod chemistry_style;
pub mod notification;
pub mod club;
pub mod draft;
pub mod event;
pub mod fut_champs;
pub mod game_ext;
pub mod event;
pub mod season;
pub mod market;
pub mod match_result;
pub mod notification;
pub mod objective;
pub mod pack;
pub mod profile;
pub mod reward;
pub mod sbc;
pub mod season;
pub mod squad;
pub mod statistics;
+11 -11
View File
@@ -38,16 +38,16 @@ pub struct CreateProfileRequest {
/// XP required to reach each level (cumulative total from level 1).
/// Level 1 starts at 0 XP. Level 2 needs 500 total XP, etc.
pub const XP_THRESHOLDS: &[i64] = &[
0, // level 1
500, // level 2
1200, // level 3
2000, // level 4
3000, // level 5
4200, // level 6
5600, // level 7
7200, // level 8
9000, // level 9
11000, // level 10
0, // level 1
500, // level 2
1200, // level 3
2000, // level 4
3000, // level 5
4200, // level 6
5600, // level 7
7200, // level 8
9000, // level 9
11000, // level 10
];
/// Compute the level for a given cumulative XP total.
@@ -75,7 +75,7 @@ pub fn coins_for_level(new_level: i64) -> i64 {
/// Pack granted at milestone levels (5, 10, 15, 20, …).
pub fn pack_for_level(new_level: i64) -> Option<&'static str> {
match new_level {
5 => Some("bronze_pack"),
5 => Some("bronze_pack"),
10 => Some("silver_pack"),
15 => Some("gold_pack"),
20 => Some("rare_gold_pack"),
+2
View File
@@ -7,6 +7,7 @@ pub struct Statistics {
pub matches_won: i64,
pub matches_drawn: i64,
pub matches_lost: i64,
pub matches_dnf: i64,
pub goals_scored: i64,
pub goals_conceded: i64,
pub packs_opened: i64,
@@ -25,6 +26,7 @@ impl Statistics {
matches_won: 0,
matches_drawn: 0,
matches_lost: 0,
matches_dnf: 0,
goals_scored: 0,
goals_conceded: 0,
packs_opened: 0,
+10 -3
View File
@@ -8,12 +8,19 @@ use crate::{
services::{achievement as ach_svc, club as club_svc, profile as profile_svc},
};
pub async fn get_achievements(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
pub async fn get_achievements(
State(state): State<AppState>,
game: GameId,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let _ = ach_svc::check_and_unlock(&state.pool, &state.achievement_defs, &profile.id, &club.id).await;
let _ = ach_svc::check_and_unlock(&state.pool, &state.achievement_defs, &profile.id, &club.id)
.await;
let achievements = ach_svc::list_with_status(&state.pool, &state.achievement_defs).await?;
let earned = achievements.iter().filter(|a| a["unlocked"].as_bool().unwrap_or(false)).count();
let earned = achievements
.iter()
.filter(|a| a["unlocked"].as_bool().unwrap_or(false))
.count();
Ok(Json(json!({
"achievements": achievements,
"earned": earned,
+9 -1
View File
@@ -34,7 +34,10 @@ pub async fn post_auth_local(
/// GET /auth/status — lightweight check: does a profile exist?
/// Returns 200 `{ "has_profile": true/false }` without erroring.
pub async fn get_auth_status(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
pub async fn get_auth_status(
State(state): State<AppState>,
game: GameId,
) -> AppResult<Json<Value>> {
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles WHERE game_id = ?")
.bind(game.as_str())
.fetch_one(&state.pool)
@@ -99,7 +102,12 @@ pub async fn post_auth_reset(
}
}
// Order matters: `match_completions` carries un-cascaded foreign keys to BOTH
// `matches` and `profiles`, so it has to go before either of them or the
// reset fails with a constraint error. Any profile that completed a match
// through /matches/complete has rows here.
for table in [
"match_completions",
"fut_champs_sessions",
"sbc_submissions",
"objective_progress",
+93 -56
View File
@@ -9,10 +9,9 @@ use serde_json::{json, Value};
use crate::{
app::AppState,
error::{AppError, AppResult},
models::card::OwnedCard,
models::card::{OwnedCard, OWNED_CARD_SELECT},
services::{
club as club_svc,
economy as economy_svc,
club as club_svc, economy as economy_svc,
inventory::{self, OwnedItemQuery, OwnedItemView},
profile as profile_svc,
},
@@ -20,11 +19,17 @@ use crate::{
/// Quick-sell value for a card based on overall rating.
fn quick_sell_coins(overall: u8) -> i64 {
if overall >= 85 { 1500 }
else if overall >= 80 { 900 }
else if overall >= 75 { 600 }
else if overall >= 65 { 300 }
else { 150 }
if overall >= 85 {
1500
} else if overall >= 80 {
900
} else if overall >= 75 {
600
} else if overall >= 65 {
300
} else {
150
}
}
#[derive(Debug, Deserialize)]
@@ -96,7 +101,9 @@ pub async fn get_cards(
cards.truncate(limit);
}
Ok(Json(json!({ "cards": cards, "total": total, "returned": cards.len() })))
Ok(Json(
json!({ "cards": cards, "total": total, "returned": cards.len() }),
))
}
pub async fn get_collection(
@@ -107,46 +114,73 @@ pub async fn get_collection(
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let owned = sqlx::query_as::<_, OwnedCard>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE club_id = ?"
)
.bind(&club.id)
.fetch_all(&state.pool)
.await?;
let owned = sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} WHERE club_id = ?"))
.bind(&club.id)
.fetch_all(&state.pool)
.await?;
let views: Vec<OwnedItemView> = owned
.iter()
.filter_map(|o| {
state.card_db.get(&o.card_id).map(|def| {
let effective_overall = def.overall as i64 + o.training_bonus;
let effective_position =
o.position_override.as_deref().unwrap_or(&def.position);
let body = json!({
"owned_card_id": o.id,
"is_loan": o.is_loan,
"loan_matches_remaining": o.loan_matches_remaining,
"acquired_at": o.acquired_at,
"chemistry_style": o.chemistry_style,
"position_override": o.position_override,
"training_bonus": o.training_bonus,
"effective_overall": effective_overall,
"effective_position": effective_position,
"card": def,
});
OwnedItemView {
owned_card_id: o.id.clone(),
base_overall: def.overall,
effective_overall,
position: effective_position.to_string(),
nation: def.nation.clone(),
league: def.league.clone(),
club: def.club.clone(),
body,
}
})
})
.collect();
// An owned row whose definition is absent from the loaded content CANNOT be
// projected (there is nothing to project), but it must never vanish in
// silence: that silent `filter_map` drop is how a real club once served
// `total: 0` while 1986 owned rows sat in the DB. So: keep the drop (a
// missing definition is not a 500), but LOG each one and report the count in
// the envelope so a caller and an operator both see it.
let mut unresolved: Vec<&str> = Vec::new();
let mut views: Vec<OwnedItemView> = Vec::with_capacity(owned.len());
for o in &owned {
let Some(def) = state.card_db.get(&o.card_id) else {
tracing::warn!(
owned_card_id = %o.id,
card_id = %o.card_id,
content_kind = %o.content_kind,
club_id = %club.id,
"owned item dropped from /collection: no card definition loaded"
);
unresolved.push(o.card_id.as_str());
continue;
};
let effective_overall = def.overall as i64 + o.training_bonus;
let effective_position = o.position_override.as_deref().unwrap_or(&def.position);
let body = json!({
"owned_card_id": o.id,
"content_kind": o.content_kind,
"quantity": o.quantity,
"is_loan": o.is_loan,
"loan_matches_remaining": o.loan_matches_remaining,
"acquired_at": o.acquired_at,
"chemistry_style": o.chemistry_style,
"position_override": o.position_override,
"training_bonus": o.training_bonus,
"effective_overall": effective_overall,
"effective_position": effective_position,
"card": def,
});
views.push(OwnedItemView {
owned_card_id: o.id.clone(),
content_kind: o.content_kind,
base_overall: def.overall,
effective_overall,
position: effective_position.to_string(),
nation: def.nation.clone(),
league: def.league.clone(),
club: def.club.clone(),
body,
});
}
if !unresolved.is_empty() {
unresolved.sort_unstable();
unresolved.dedup();
tracing::warn!(
club_id = %club.id,
owned_rows = owned.len(),
dropped = owned.len() - views.len(),
definitions = ?unresolved,
"/collection dropped owned items with missing definitions"
);
}
let owned_rows = owned.len();
let unresolved_items = owned_rows - views.len();
let page = inventory::apply_query(views, &query);
let returned = page.items.len();
Ok(Json(json!({
@@ -155,6 +189,12 @@ pub async fn get_collection(
"returned": returned,
"offset": page.offset,
"limit": page.limit,
// Ownership truth vs. what could be projected. `owned_rows` counts every
// row Core actually owns for this club; `unresolved_items` counts those
// dropped for want of a definition. Both zero-cost when nothing is wrong.
"owned_rows": owned_rows,
"unresolved_items": unresolved_items,
"unresolved_definitions": unresolved,
})))
}
@@ -167,21 +207,18 @@ pub async fn delete_owned_card(
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let owned = sqlx::query_as::<_, OwnedCard>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
chemistry_style, position_override, training_bonus \
FROM owned_cards WHERE id = ? AND club_id = ?",
)
let owned = sqlx::query_as::<_, OwnedCard>(&format!(
"{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"
))
.bind(&owned_card_id)
.bind(&club.id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("owned card '{owned_card_id}' not found")))?;
let card = state
.card_db
.get(&owned.card_id)
.ok_or_else(|| AppError::NotFound(format!("card definition '{}' missing", owned.card_id)))?;
let card = state.card_db.get(&owned.card_id).ok_or_else(|| {
AppError::NotFound(format!("card definition '{}' missing", owned.card_id))
})?;
let coins = quick_sell_coins(card.overall);
+131 -32
View File
@@ -1,13 +1,16 @@
use crate::extractors::GameId;
use crate::{
app::AppState,
error::AppResult,
models::club::Club,
services::{checkin as checkin_svc, club as club_svc, profile as profile_svc, statistics as stats_svc},
error::{AppError, AppResult},
models::{card::ActiveSlot, club::Club},
services::{
checkin as checkin_svc, club as club_svc, profile as profile_svc, statistics as stats_svc,
},
};
use axum::{extract::State, Json};
use serde::Deserialize;
use serde_json::{json, Value};
use std::str::FromStr;
pub async fn get_club(State(state): State<AppState>, game: GameId) -> AppResult<Json<Club>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
@@ -38,7 +41,10 @@ pub async fn put_club(
Ok(Json(json!({ "club": updated })))
}
pub async fn get_checkin_status(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
pub async fn get_checkin_status(
State(state): State<AppState>,
game: GameId,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let status = checkin_svc::get_status(&state.pool, &profile.id).await?;
Ok(Json(json!({
@@ -67,13 +73,12 @@ pub async fn get_milestones(State(state): State<AppState>, game: GameId) -> AppR
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
let seasons_completed: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM season_history WHERE profile_id = ?",
)
.bind(&profile.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let seasons_completed: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM season_history WHERE profile_id = ?")
.bind(&profile.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let highest_division: i64 = sqlx::query_scalar(
"SELECT COALESCE(MIN(new_division), 10) FROM season_history WHERE profile_id = ?",
@@ -83,29 +88,25 @@ pub async fn get_milestones(State(state): State<AppState>, game: GameId) -> AppR
.await
.unwrap_or(10);
let cards_owned: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM owned_cards WHERE club_id = ?",
)
.bind(&club.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let cards_owned: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE club_id = ?")
.bind(&club.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let sbcs_completed: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1",
)
.bind(&club.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let sbcs_completed: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1")
.bind(&club.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let total_checkins: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM daily_checkins WHERE profile_id = ?",
)
.bind(&profile.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let total_checkins: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM daily_checkins WHERE profile_id = ?")
.bind(&profile.id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
Ok(Json(json!({
"total_wins": stats.matches_won,
@@ -124,3 +125,101 @@ pub async fn get_milestones(State(state): State<AppState>, game: GameId) -> AppR
"club_level": club.level,
})))
}
/// The owned card assigned as the active squad's manager, or `null`. Generic:
/// Core returns the ownership-backed assignment; the FIFA 17 adapter shapes the
/// manager wire item from it (itemType/contract/chemistry are adapter concerns).
pub async fn get_squad_manager(
State(state): State<AppState>,
game: GameId,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let manager = club_svc::get_squad_manager(&state.pool, &club.id).await?;
Ok(Json(json!({ "manager": manager })))
}
#[derive(Deserialize)]
pub struct SetManagerRequest {
/// The owned card to assign as manager, or `null`/absent to clear it.
pub owned_card_id: Option<String>,
}
/// Assign (or, with a null/absent `owned_card_id`, clear) the active squad's
/// manager. Fail-closed: the card must be owned by this club and the club must
/// have a squad. Returns the resulting assignment.
pub async fn put_squad_manager(
State(state): State<AppState>,
game: GameId,
Json(req): Json<SetManagerRequest>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
match req.owned_card_id {
Some(owned_card_id) => {
club_svc::set_squad_manager(&state.pool, &club.id, &owned_card_id).await?
}
None => club_svc::clear_squad_manager(&state.pool, &club.id).await?,
}
let manager = club_svc::get_squad_manager(&state.pool, &club.id).await?;
Ok(Json(json!({ "manager": manager })))
}
/// Every active club-item designation, slot-keyed and EXPLICIT: all five slots
/// are always present, an empty slot being `null`. A caller therefore never has
/// to guess whether a missing key means "no item" or "unsupported slot".
fn active_items_body(items: &club_svc::ActiveClubItems) -> AppResult<Value> {
let mut body = serde_json::Map::new();
for slot in ActiveSlot::ALL {
body.insert(
slot.as_str().to_string(),
serde_json::to_value(items.get(slot))?,
);
}
Ok(Value::Object(body))
}
/// Return the club's ownership-backed active item designations.
pub async fn get_active_items(
State(state): State<AppState>,
game: GameId,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let items = club_svc::get_active_club_items(&state.pool, &club.id).await?;
Ok(Json(json!({ "active_items": active_items_body(&items)? })))
}
#[derive(Deserialize)]
pub struct SetActiveItemRequest {
/// Which club role to write: home_kit | away_kit | badge | ball | stadium.
///
/// Taken as a string and parsed here so an unknown slot comes back as this
/// crate's `400 {"error": …}` envelope, like every other bad request, rather
/// than axum's plain-text deserialization rejection.
pub slot: String,
/// The owned instance to designate, or `null`/absent to clear the slot.
#[serde(default)]
pub owned_card_id: Option<String>,
}
/// Write ONE active club-item designation. Core enforces ownership and that the
/// slot admits the item's `content_kind`; game adapters own their own mapping
/// from a wire item onto that generic kind.
pub async fn put_active_item(
State(state): State<AppState>,
game: GameId,
Json(req): Json<SetActiveItemRequest>,
) -> AppResult<Json<Value>> {
let slot = ActiveSlot::from_str(&req.slot).map_err(AppError::BadRequest)?;
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
match req.owned_card_id {
Some(owned_card_id) => {
club_svc::set_active_club_item(&state.pool, &club.id, slot, &owned_card_id).await?
}
None => club_svc::clear_active_club_item(&state.pool, &club.id, slot).await?,
}
let items = club_svc::get_active_club_items(&state.pool, &club.id).await?;
Ok(Json(json!({ "active_items": active_items_body(&items)? })))
}
+35 -11
View File
@@ -37,13 +37,19 @@ pub async fn get_division(State(state): State<AppState>, game: GameId) -> AppRes
})))
}
pub async fn get_division_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
pub async fn get_division_history(
State(state): State<AppState>,
game: GameId,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let history = season_svc::get_history(&state.pool, &profile.id).await?;
Ok(Json(json!({ "history": history, "total": history.len() })))
}
pub async fn get_division_leaderboard(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
pub async fn get_division_leaderboard(
State(state): State<AppState>,
game: GameId,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let season = season_svc::get_or_create(&state.pool, &profile.id).await?;
@@ -53,11 +59,26 @@ pub async fn get_division_leaderboard(State(state): State<AppState>, game: GameI
let mut rng = rand::rngs::SmallRng::seed_from_u64(seed);
const NPC_NAMES: &[&str] = &[
"Riverside FC", "City Athletic", "County United", "Valley Rangers",
"Harbor Town FC", "Mountside City", "Lakewood Athletic", "Eastbrook United",
"Westfield Rovers", "Northgate FC", "Southport Athletic", "Ironbridge City",
"Milldale United", "Hillcrest Rangers", "Bayside FC", "Thornfield Athletic",
"Greenhill United", "Coldwater City", "Redbury Rangers", "Ashdown FC",
"Riverside FC",
"City Athletic",
"County United",
"Valley Rangers",
"Harbor Town FC",
"Mountside City",
"Lakewood Athletic",
"Eastbrook United",
"Westfield Rovers",
"Northgate FC",
"Southport Athletic",
"Ironbridge City",
"Milldale United",
"Hillcrest Rangers",
"Bayside FC",
"Thornfield Athletic",
"Greenhill United",
"Coldwater City",
"Redbury Rangers",
"Ashdown FC",
];
// Pick 9 NPC names without repetition using the seeded RNG
@@ -75,10 +96,13 @@ pub async fn get_division_leaderboard(State(state): State<AppState>, game: GameI
// Quality bias: index 0-2 = stronger, 6-8 = weaker
let quality: f64 = 1.0 - (idx as f64 / 8.0); // 1.0 → 0.0
let expected_win_rate = 0.2 + quality * 0.6; // 0.2–0.8
let wins = (npc_matches as f64 * expected_win_rate * (0.8 + rng.gen::<f64>() * 0.4)) as i64;
let losses = (npc_matches as f64 * (1.0 - expected_win_rate) * (0.8 + rng.gen::<f64>() * 0.4)) as i64;
let draws = (npc_matches - wins - losses).max(0);
let pts = wins * 3 + draws;
let wins =
(npc_matches as f64 * expected_win_rate * (0.8 + rng.gen::<f64>() * 0.4)) as i64;
let losses = (npc_matches as f64
* (1.0 - expected_win_rate)
* (0.8 + rng.gen::<f64>() * 0.4)) as i64;
let draws = (npc_matches - wins - losses).max(0);
let pts = wins * 3 + draws;
json!({
"club_name": name,
"wins": wins,
+4 -2
View File
@@ -45,7 +45,8 @@ pub async fn post_draft_start(
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let difficulty = query.difficulty.as_deref().unwrap_or("professional");
let session = draft_svc::start_draft(&state.pool, &state.card_db, &profile.id, difficulty).await?;
let session =
draft_svc::start_draft(&state.pool, &state.card_db, &profile.id, difficulty).await?;
Ok(Json(session))
}
@@ -56,7 +57,8 @@ pub async fn get_draft_session(
Path(session_id): Path<String>,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let session = draft_svc::get_draft(&state.pool, &state.card_db, &profile.id, &session_id).await?;
let session =
draft_svc::get_draft(&state.pool, &state.card_db, &profile.id, &session_id).await?;
Ok(Json(session))
}
+18 -11
View File
@@ -9,7 +9,9 @@ use serde_json::{json, Value};
use crate::{
app::AppState,
error::AppResult,
services::{club as club_svc, fut_champs as champs_svc, profile as profile_svc, season as season_svc},
services::{
club as club_svc, fut_champs as champs_svc, profile as profile_svc, season as season_svc,
},
};
/// GET /fut-champs — current active session, or null if none.
@@ -24,7 +26,10 @@ pub async fn get_fut_champs(State(state): State<AppState>, game: GameId) -> AppR
}
/// POST /fut-champs/start — open a new FUT Champions week.
pub async fn post_start_fut_champs(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
pub async fn post_start_fut_champs(
State(state): State<AppState>,
game: GameId,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let session = champs_svc::start_session(&state.pool, &profile.id).await?;
@@ -96,7 +101,10 @@ pub async fn post_claim_champs_rewards(
}
/// GET /fut-champs/history — past sessions, newest first.
pub async fn get_champs_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
pub async fn get_champs_history(
State(state): State<AppState>,
game: GameId,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let history = champs_svc::get_history(&state.pool, &profile.id).await?;
@@ -107,20 +115,19 @@ pub async fn get_champs_history(State(state): State<AppState>, game: GameId) ->
}
/// POST /rivals/claim-weekly — claim weekly Division Rivals reward.
pub async fn post_claim_rivals_reward(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
pub async fn post_claim_rivals_reward(
State(state): State<AppState>,
game: GameId,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
// Ensure a season row exists
season_svc::get_or_create(&state.pool, &profile.id).await?;
let result = champs_svc::claim_rivals_reward(
&state.pool,
&profile.id,
&club.id,
&state.pack_defs,
)
.await?;
let result =
champs_svc::claim_rivals_reward(&state.pool, &profile.id, &club.id, &state.pack_defs)
.await?;
Ok(Json(result))
}
+13 -5
View File
@@ -13,14 +13,16 @@ use crate::{
services::{club as club_svc, market as market_svc, profile as profile_svc},
};
pub async fn get_trade_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
pub async fn get_trade_history(
State(state): State<AppState>,
game: GameId,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let trades = market_svc::get_trade_history(&state.pool, &club.id).await?;
Ok(Json(json!({ "trades": trades, "total": trades.len() })))
}
#[derive(Deserialize)]
pub struct MarketQuery {
pub min_overall: Option<u8>,
@@ -86,11 +88,17 @@ pub async fn post_market_refresh(State(state): State<AppState>) -> AppResult<Jso
}
/// Return all active market listings posted by the current player's club.
pub async fn get_my_listings(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
pub async fn get_my_listings(
State(state): State<AppState>,
game: GameId,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let listings = market_svc::get_listings_by_seller(&state.pool, &state.card_db, &club.id).await?;
Ok(Json(json!({ "listings": listings, "total": listings.len() })))
let listings =
market_svc::get_listings_by_seller(&state.pool, &state.card_db, &club.id).await?;
Ok(Json(
json!({ "listings": listings, "total": listings.len() }),
))
}
/// Cancel a player-posted listing and return the card to the collection.
+117 -9
View File
@@ -8,9 +8,9 @@ use serde_json::{json, Value};
use crate::{
app::AppState,
error::AppResult,
models::match_result::{Match, MatchRewardResult, SubmitMatchRequest},
services::{club as club_svc, match_service, profile as profile_svc},
error::{AppError, AppResult},
models::match_result::{CompleteMatchRequest, Match, MatchCompletionResult},
services::{club as club_svc, match_service, notification, profile as profile_svc},
};
#[derive(Deserialize)]
@@ -63,17 +63,125 @@ pub async fn get_opponent(
Ok(Json(opponent))
}
pub async fn post_match_result(
/// `POST /matches/result` — REMOVED as an economy path, and deliberately kept as
/// an explicit rejection rather than a 404.
///
/// It used to grant coins, XP, level-ups, statistics, objectives, loan expiry,
/// season progression and achievements across a dozen separate writes with NO
/// transaction and NO idempotency key, which made it a second economy authority
/// that could re-credit the same match on every call and could half-apply on any
/// mid-way failure. Exactly-once needs a caller-supplied match identity, which
/// this request shape does not carry and cannot derive (a body fingerprint would
/// collapse two legitimate matches with the same scoreline into one).
///
/// Callers submit to `/matches/complete` with a `match_identity`; the loan and
/// season behaviour this route used to trigger is available there via
/// `expire_loans` / `advance_season`.
pub async fn post_match_result() -> AppResult<Json<Value>> {
Err(AppError::BadRequest(
"POST /matches/result is no longer an economy path: it had no transaction \
and no idempotency key. Submit to POST /matches/complete with a \
match_identity (and expire_loans / advance_season if you need Core's loan \
and season progression)."
.into(),
))
}
/// `POST /matches/complete` — the authoritative, atomic, exactly-once match
/// economy entry point (the game host routes a finished match here). Idempotent
/// on `(profile, match_identity)`: a replay returns the persisted canonical
/// result with `applied = false` and grants nothing twice.
pub async fn post_match_complete(
State(state): State<AppState>,
game: GameId,
Json(req): Json<SubmitMatchRequest>,
) -> AppResult<Json<MatchRewardResult>> {
Json(req): Json<CompleteMatchRequest>,
) -> AppResult<Json<MatchCompletionResult>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let result =
match_service::process_match(&state.pool, &profile.id, &club.id, &req, &state.obj_defs, &state.achievement_defs)
.await?;
let result = match_service::complete_match(
&state.pool,
&profile.id,
&club.id,
&req,
&state.obj_defs,
&state.achievement_defs,
)
.await?;
// Player-visible notifications are non-durable side effects, so they are
// emitted AFTER the economy transaction commits, never inside it — a failed
// notification must not roll back a completed match. Gated on `applied`, so
// an idempotent replay does not re-notify (the pooled path this replaced had
// no such guard). Achievement notifications are written in-transaction by
// `check_and_unlock_tx` and are deliberately not repeated here.
if result.applied {
emit_match_notifications(&state, &result).await;
}
Ok(Json(result))
}
async fn emit_match_notifications(state: &AppState, result: &MatchCompletionResult) {
for ev in &result.level_ups {
let body = match &ev.pack_granted {
Some(pack) => format!(
"You reached level {}! Reward: {} coins + {pack}.",
ev.new_level, ev.coins_granted
),
None => format!(
"You reached level {}! Reward: {} coins.",
ev.new_level, ev.coins_granted
),
};
let _ = notification::create(
&state.pool,
"level_up",
&format!("Level {}!", ev.new_level),
&body,
)
.await;
}
for obj_id in &result.objectives_updated {
let display_name = state
.obj_defs
.iter()
.find(|d| &d.id == obj_id)
.map(|d| d.title.as_str())
.unwrap_or(obj_id.as_str());
let body = format!("\"{display_name}\" is now complete. Claim your reward in Objectives.");
let _ = notification::create(
&state.pool,
"objective_complete",
"Objective complete!",
&body,
)
.await;
}
for owned_id in &result.expired_loans {
let body =
format!("Loan card (id: {owned_id}) has expired and been removed from your club.");
let _ = notification::create(&state.pool, "loan_expired", "Loan card expired", &body).await;
}
if let Some(se) = &result.season_end {
use crate::models::season::SeasonResult;
let direction = match se.result {
SeasonResult::Promoted => "Promoted",
SeasonResult::Relegated => "Relegated",
SeasonResult::Maintained => "Maintained",
};
let body = format!(
"{direction} — now in Division {}. Rewards: {} coins{}.",
se.new_division,
se.coins_awarded,
se.pack_awarded
.as_deref()
.map(|p| format!(" + {p}"))
.unwrap_or_default()
);
let _ = notification::create(&state.pool, "season_end", "Season complete!", &body).await;
}
}
+1 -1
View File
@@ -5,8 +5,8 @@ pub mod club;
pub mod division;
pub mod draft;
pub mod economy;
pub mod fut_champs;
pub mod events;
pub mod fut_champs;
pub mod health;
pub mod market;
pub mod matches;
+18 -13
View File
@@ -8,7 +8,9 @@ use serde_json::{json, Value};
use crate::{
app::AppState,
error::{AppError, AppResult},
services::{club as club_svc, notification as notif_svc, objective as obj_svc, profile as profile_svc},
services::{
club as club_svc, notification as notif_svc, objective as obj_svc, profile as profile_svc,
},
};
/// GET /notifications
@@ -17,7 +19,10 @@ use crate::{
/// notifications (unclaimed objectives, expiring loans, season ending soon).
/// Persistent notifications carry an `id` and `is_read` flag; dynamic ones
/// have `id: null` and are always considered unread.
pub async fn get_notifications(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
pub async fn get_notifications(
State(state): State<AppState>,
game: GameId,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
@@ -93,14 +98,16 @@ pub async fn get_notifications(State(state): State<AppState>, game: GameId) -> A
// Use "type" key for compatibility with dashboard and existing tests.
let all: Vec<Value> = persistent
.iter()
.map(|n| json!({
"id": n.id,
"type": n.kind,
"title": n.title,
"body": n.body,
"is_read": n.is_read,
"created_at": n.created_at,
}))
.map(|n| {
json!({
"id": n.id,
"type": n.kind,
"title": n.title,
"body": n.body,
"is_read": n.is_read,
"created_at": n.created_at,
})
})
.chain(dynamic.iter().cloned())
.collect();
@@ -125,9 +132,7 @@ pub async fn mark_notification_read(
}
/// POST /notifications/read-all
pub async fn mark_all_notifications_read(
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
pub async fn mark_all_notifications_read(State(state): State<AppState>) -> AppResult<Json<Value>> {
let count = notif_svc::mark_all_read(&state.pool).await?;
Ok(Json(json!({ "marked_read": count })))
}
+10 -3
View File
@@ -79,7 +79,10 @@ pub async fn get_packs(State(state): State<AppState>, game: GameId) -> AppResult
}
/// Return recently opened packs with the card IDs they contained.
pub async fn get_pack_history(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
pub async fn get_pack_history(
State(state): State<AppState>,
game: GameId,
) -> AppResult<Json<Value>> {
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
@@ -143,8 +146,12 @@ pub async fn post_open_pack(
objective::increment_metric(&state.pool, &profile.id, &state.obj_defs, "packsopened", 1)
.await?;
let _ = crate::services::achievement::check_and_unlock(
&state.pool, &state.achievement_defs, &profile.id, &club.id,
).await;
&state.pool,
&state.achievement_defs,
&profile.id,
&club.id,
)
.await;
Ok(Json(result))
}
+2 -7
View File
@@ -72,13 +72,8 @@ pub async fn post_change_position(
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
let updated = upgrade_svc::change_position(
&state.pool,
&club.id,
&owned_card_id,
&req.position,
)
.await?;
let updated =
upgrade_svc::change_position(&state.pool, &club.id, &owned_card_id, &req.position).await?;
let card_def = state.card_db.get(&updated.card_id);
+245 -70
View File
@@ -1,10 +1,12 @@
use crate::{
db::Pool,
error::AppResult,
error::{AppError, AppResult},
models::achievement::{AchievementDefinition, PlayerAchievement},
services::{club as club_svc, notification},
};
use anyhow::Context;
use sqlx::{Sqlite, Transaction};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use uuid::Uuid;
@@ -29,79 +31,83 @@ pub fn load_achievement_definitions(data_dir: &str) -> anyhow::Result<Vec<Achiev
}
/// Query the current value for the given trigger metric.
async fn metric_value(pool: &Pool, profile_id: &str, club_id: &str, trigger: &str) -> AppResult<i64> {
let v: i64 = match trigger {
"matches_played" => sqlx::query_scalar(
"SELECT matches_played FROM statistics WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
async fn metric_value(
pool: &Pool,
profile_id: &str,
club_id: &str,
trigger: &str,
) -> AppResult<i64> {
let v: i64 =
match trigger {
"matches_played" => {
sqlx::query_scalar("SELECT matches_played FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0)
}
"matches_won" => sqlx::query_scalar(
"SELECT matches_won FROM statistics WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
"matches_won" => {
sqlx::query_scalar("SELECT matches_won FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0)
}
"goals_scored" => sqlx::query_scalar(
"SELECT goals_scored FROM statistics WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
"goals_scored" => {
sqlx::query_scalar("SELECT goals_scored FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0)
}
"packs_opened" => sqlx::query_scalar(
"SELECT packs_opened FROM statistics WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
"packs_opened" => {
sqlx::query_scalar("SELECT packs_opened FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0)
}
"sbcs_completed" => sqlx::query_scalar(
"SELECT sbcs_completed FROM statistics WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0),
"sbcs_completed" => {
sqlx::query_scalar("SELECT sbcs_completed FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(0)
}
"cards_owned" => sqlx::query_scalar(
"SELECT COUNT(*) FROM owned_cards WHERE club_id = ?",
)
.bind(club_id)
.fetch_one(pool)
.await?,
"cards_owned" => {
sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE club_id = ?")
.bind(club_id)
.fetch_one(pool)
.await?
}
"level" => sqlx::query_scalar(
"SELECT level FROM profiles WHERE id = ?",
)
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(1),
"level" => sqlx::query_scalar("SELECT level FROM profiles WHERE id = ?")
.bind(profile_id)
.fetch_optional(pool)
.await?
.unwrap_or(1),
"objectives_completed" => sqlx::query_scalar(
"SELECT COUNT(*) FROM objective_progress WHERE profile_id = ? AND completed = 1",
)
.bind(profile_id)
.fetch_one(pool)
.await?,
"objectives_completed" => sqlx::query_scalar(
"SELECT COUNT(*) FROM objective_progress WHERE profile_id = ? AND completed = 1",
)
.bind(profile_id)
.fetch_one(pool)
.await?,
"drafts_completed" => sqlx::query_scalar(
"SELECT COUNT(*) FROM draft_sessions WHERE profile_id = ? AND status = 'completed'",
)
.bind(profile_id)
.fetch_one(pool)
.await?,
"drafts_completed" => sqlx::query_scalar(
"SELECT COUNT(*) FROM draft_sessions WHERE profile_id = ? AND status = 'completed'",
)
.bind(profile_id)
.fetch_one(pool)
.await?,
_ => 0,
};
_ => 0,
};
Ok(v)
}
@@ -165,11 +171,180 @@ pub async fn check_and_unlock(
club_svc::add_coins(pool, club_id, def.reward_coins).await?;
}
let body = format!(
"{} Reward: {} coins.",
def.description, def.reward_coins
);
let _ = notification::create(pool, "achievement", &format!("Achievement: {}", def.title), &body).await;
let body = format!("{} Reward: {} coins.", def.description, def.reward_coins);
let _ = notification::create(
pool,
"achievement",
&format!("Achievement: {}", def.title),
&body,
)
.await;
newly_unlocked.push(def.clone());
}
}
Ok(newly_unlocked)
}
/// Transaction-scoped [`metric_value`] — identical reads, run inside the
/// caller's transaction so achievement checks see the same uncommitted state the
/// rest of the match-completion transaction just wrote.
async fn metric_value_tx(
tx: &mut Transaction<'_, Sqlite>,
profile_id: &str,
club_id: &str,
trigger: &str,
) -> AppResult<i64> {
let v: i64 =
match trigger {
"matches_played" => {
sqlx::query_scalar("SELECT matches_played FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(&mut **tx)
.await?
.unwrap_or(0)
}
"matches_won" => {
sqlx::query_scalar("SELECT matches_won FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(&mut **tx)
.await?
.unwrap_or(0)
}
"goals_scored" => {
sqlx::query_scalar("SELECT goals_scored FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(&mut **tx)
.await?
.unwrap_or(0)
}
"packs_opened" => {
sqlx::query_scalar("SELECT packs_opened FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(&mut **tx)
.await?
.unwrap_or(0)
}
"sbcs_completed" => {
sqlx::query_scalar("SELECT sbcs_completed FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_optional(&mut **tx)
.await?
.unwrap_or(0)
}
"cards_owned" => {
sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE club_id = ?")
.bind(club_id)
.fetch_one(&mut **tx)
.await?
}
"level" => sqlx::query_scalar("SELECT level FROM profiles WHERE id = ?")
.bind(profile_id)
.fetch_optional(&mut **tx)
.await?
.unwrap_or(1),
"objectives_completed" => sqlx::query_scalar(
"SELECT COUNT(*) FROM objective_progress WHERE profile_id = ? AND completed = 1",
)
.bind(profile_id)
.fetch_one(&mut **tx)
.await?,
"drafts_completed" => sqlx::query_scalar(
"SELECT COUNT(*) FROM draft_sessions WHERE profile_id = ? AND status = 'completed'",
)
.bind(profile_id)
.fetch_one(&mut **tx)
.await?,
_ => 0,
};
Ok(v)
}
/// Transaction-scoped [`check_and_unlock`] for the atomic match-completion path.
/// Unlocks are inserted, coins credited, and notifications written inside the
/// caller's transaction (mirroring the inline economy writes elsewhere), so a
/// later failure rolls back the whole match — no half-granted achievement.
pub async fn check_and_unlock_tx(
tx: &mut Transaction<'_, Sqlite>,
defs: &[AchievementDefinition],
profile_id: &str,
club_id: &str,
now: &str,
) -> AppResult<Vec<AchievementDefinition>> {
if defs.is_empty() {
return Ok(vec![]);
}
let unlocked_ids: Vec<String> =
sqlx::query_scalar("SELECT achievement_id FROM player_achievements")
.fetch_all(&mut **tx)
.await?;
let unlocked_set: HashSet<&str> = unlocked_ids.iter().map(|s| s.as_str()).collect();
let candidates: Vec<&AchievementDefinition> = defs
.iter()
.filter(|d| !unlocked_set.contains(d.id.as_str()))
.collect();
if candidates.is_empty() {
return Ok(vec![]);
}
let mut trigger_cache: HashMap<String, i64> = Default::default();
let mut newly_unlocked: Vec<AchievementDefinition> = Vec::new();
for def in candidates {
let value = match trigger_cache.get(&def.trigger) {
Some(&v) => v,
None => {
let v = metric_value_tx(tx, profile_id, club_id, &def.trigger).await?;
trigger_cache.insert(def.trigger.clone(), v);
v
}
};
if value >= def.threshold {
if def.reward_coins < 0 {
return Err(AppError::Internal(anyhow::anyhow!(
"achievement {} has a negative reward",
def.id
)));
}
let inserted = sqlx::query(
"INSERT OR IGNORE INTO player_achievements (id, achievement_id, unlocked_at) VALUES (?, ?, ?)",
)
.bind(Uuid::new_v4().to_string())
.bind(&def.id)
.bind(now)
.execute(&mut **tx)
.await?;
if inserted.rows_affected() == 0 {
continue;
}
if def.reward_coins > 0 {
let credited =
sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?")
.bind(def.reward_coins)
.bind(now)
.bind(club_id)
.execute(&mut **tx)
.await?;
if credited.rows_affected() != 1 {
return Err(AppError::NotFound("club not found".into()));
}
}
let body = format!("{} Reward: {} coins.", def.description, def.reward_coins);
sqlx::query(
"INSERT INTO notifications (id, kind, title, body, is_read, created_at) VALUES (?, 'achievement', ?, ?, 0, ?)",
)
.bind(Uuid::new_v4().to_string())
.bind(format!("Achievement: {}", def.title))
.bind(body)
.bind(now)
.execute(&mut **tx)
.await?;
newly_unlocked.push(def.clone());
}
+11 -8
View File
@@ -1,4 +1,8 @@
use crate::{db::Pool, error::AppResult, services::{club, pack}};
use crate::{
db::Pool,
error::AppResult,
services::{club, pack},
};
use uuid::Uuid;
const STREAK_COINS: [i64; 7] = [500, 650, 800, 1000, 1200, 1500, 2000];
@@ -8,7 +12,7 @@ const STREAK_7_PACK: &str = "silver_pack";
#[derive(Debug, serde::Serialize)]
pub struct CheckinStatus {
pub available: bool,
pub streak_day: i64, // current streak (1–7 cycle, 0 if never checked in)
pub streak_day: i64, // current streak (1–7 cycle, 0 if never checked in)
pub next_reward_coins: i64,
pub next_reward_pack: Option<&'static str>,
pub last_checked_in: Option<String>,
@@ -56,11 +60,7 @@ pub async fn get_status(pool: &Pool, profile_id: &str) -> AppResult<CheckinStatu
}
}
pub async fn claim(
pool: &Pool,
profile_id: &str,
club_id: &str,
) -> AppResult<CheckinResult> {
pub async fn claim(pool: &Pool, profile_id: &str, club_id: &str) -> AppResult<CheckinResult> {
let row: Option<(i64, String)> = sqlx::query_as(
"SELECT streak_day, checked_in_at FROM daily_checkins \
WHERE profile_id = ? ORDER BY checked_in_at DESC LIMIT 1",
@@ -82,7 +82,10 @@ pub async fn claim(
}
}
let last_streak = row.as_ref().map(|(s, last_at)| compute_next_streak(*s, last_at)).unwrap_or(1);
let last_streak = row
.as_ref()
.map(|(s, last_at)| compute_next_streak(*s, last_at))
.unwrap_or(1);
let idx = (last_streak - 1).rem_euclid(7) as usize;
let coins = STREAK_COINS[idx];
let pack_def = if idx == 6 { Some(STREAK_7_PACK) } else { None };
+592 -1
View File
@@ -1,7 +1,10 @@
use crate::{
db::Pool,
error::{AppError, AppResult},
models::club::Club,
models::{
card::{ActiveSlot, ContentKind, OwnedCard, OWNED_CARD_SELECT},
club::Club,
},
};
use chrono::Utc;
@@ -126,3 +129,591 @@ pub async fn spend_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i
.await?;
Ok(new_balance)
}
// ─────────────────────── squad manager assignment ───────────────────────────
//
// Generic, ownership-backed canonical state: one owned item assigned as a
// squad's manager (migration 0023 `squad_managers`). Core stores the assignment
// durably and re-validates ownership on read; the FIFA 17 adapter owns the wire
// meaning of "manager" (itemType/contract/chemistry), never Core.
/// The club's most-recently-updated squad id (its "active" squad), matching the
/// selection `squad::get_squad` uses, or `None` when the club has no squad yet.
pub async fn active_squad_id(pool: &Pool, club_id: &str) -> AppResult<Option<String>> {
Ok(sqlx::query_scalar::<_, String>(
"SELECT id FROM squads WHERE club_id = ? ORDER BY updated_at DESC LIMIT 1",
)
.bind(club_id)
.fetch_optional(pool)
.await?)
}
/// The owned card assigned as the manager of `club_id`'s active squad, if any.
pub async fn get_squad_manager(pool: &Pool, club_id: &str) -> AppResult<Option<OwnedCard>> {
let Some(squad_id) = active_squad_id(pool, club_id).await? else {
return Ok(None);
};
get_squad_manager_for_squad(pool, &squad_id, club_id).await
}
/// The owned card assigned as `squad_id`'s manager, re-validated to still belong
/// to `club_id`. The club-ownership re-check means a stale assignment left by a
/// market transfer (which moves ownership by UPDATE, bypassing ON DELETE
/// CASCADE) never surfaces a manager the club no longer owns.
pub async fn get_squad_manager_for_squad(
pool: &Pool,
squad_id: &str,
club_id: &str,
) -> AppResult<Option<OwnedCard>> {
Ok(sqlx::query_as::<_, OwnedCard>(&format!(
"{OWNED_CARD_SELECT} \
WHERE id = (SELECT owned_card_id FROM squad_managers WHERE squad_id = ?) \
AND club_id = ?"
))
.bind(squad_id)
.bind(club_id)
.fetch_optional(pool)
.await?)
}
/// Assign `owned_card_id` as the manager of `club_id`'s active squad, replacing
/// any existing assignment. Fail-closed: both the squad and the owned card MUST
/// belong to `club_id`, so a client can neither manage another club's squad nor
/// assign a card it does not own. One manager per squad (the PK REPLACE), so a
/// re-assignment never accumulates duplicate rows.
pub async fn set_squad_manager(pool: &Pool, club_id: &str, owned_card_id: &str) -> AppResult<()> {
let squad_id = active_squad_id(pool, club_id)
.await?
.ok_or_else(|| AppError::NotFound("club has no squad to assign a manager to".into()))?;
set_squad_manager_for_squad(pool, club_id, &squad_id, owned_card_id).await
}
/// Squad-scoped variant of [`set_squad_manager`].
pub async fn set_squad_manager_for_squad(
pool: &Pool,
club_id: &str,
squad_id: &str,
owned_card_id: &str,
) -> AppResult<()> {
let squad_ok =
sqlx::query_scalar::<_, String>("SELECT id FROM squads WHERE id = ? AND club_id = ?")
.bind(squad_id)
.bind(club_id)
.fetch_optional(pool)
.await?;
if squad_ok.is_none() {
return Err(AppError::NotFound(format!("squad '{squad_id}' not found")));
}
let card_ok =
sqlx::query_scalar::<_, String>("SELECT id FROM owned_cards WHERE id = ? AND club_id = ?")
.bind(owned_card_id)
.bind(club_id)
.fetch_optional(pool)
.await?;
if card_ok.is_none() {
return Err(AppError::NotFound(format!(
"owned card '{owned_card_id}' not found"
)));
}
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT OR REPLACE INTO squad_managers (squad_id, owned_card_id, updated_at) \
VALUES (?, ?, ?)",
)
.bind(squad_id)
.bind(owned_card_id)
.bind(&now)
.execute(pool)
.await?;
Ok(())
}
/// Remove the manager assignment from `club_id`'s active squad (idempotent — a
/// club with no squad or no manager is a successful no-op).
pub async fn clear_squad_manager(pool: &Pool, club_id: &str) -> AppResult<()> {
sqlx::query(
"DELETE FROM squad_managers WHERE squad_id IN \
(SELECT id FROM squads WHERE club_id = ?)",
)
.bind(club_id)
.execute(pool)
.await?;
Ok(())
}
// ─────────────────────── active club item designations ──────────────────────
//
// Generic, ownership-backed club state (migration 0026 `club_active_items`):
// which owned INSTANCE currently occupies each club-scoped role (home/away kit,
// badge, ball, stadium). Ownership itself never lives here — a designation is a
// pointer into `owned_cards`, revalidated against current ownership on every
// read, so a stale row can never project an item the club does not own.
//
// Core enforces the generic invariants (ownership, one instance per slot, slot
// admits exactly one `ContentKind`); a game adapter maps its own taxonomy onto
// `ContentKind` before it gets here.
/// Every active club-item designation, keyed by slot.
///
/// Slots with no designation are simply absent. Held as a `Vec` rather than a
/// map so the projection order is the canonical [`ActiveSlot::ALL`] order.
#[derive(Debug, Clone, Default)]
pub struct ActiveClubItems {
pub items: Vec<(ActiveSlot, OwnedCard)>,
}
impl ActiveClubItems {
/// The owned instance occupying `slot`, if any.
pub fn get(&self, slot: ActiveSlot) -> Option<&OwnedCard> {
self.items
.iter()
.find(|(s, _)| *s == slot)
.map(|(_, card)| card)
}
}
/// Read one slot's designation, revalidated against current club ownership.
async fn get_active_club_item(
pool: &Pool,
club_id: &str,
slot: ActiveSlot,
) -> AppResult<Option<OwnedCard>> {
Ok(sqlx::query_as::<_, OwnedCard>(&format!(
"{OWNED_CARD_SELECT} WHERE id = ( \
SELECT owned_card_id FROM club_active_items WHERE club_id = ? AND slot = ? \
) AND club_id = ?"
))
.bind(club_id)
.bind(slot.as_str())
.bind(club_id)
.fetch_optional(pool)
.await?)
}
/// Read every active club-item designation. Each is revalidated against current
/// ownership, so a stale/corrupt row never surfaces another club's item.
pub async fn get_active_club_items(pool: &Pool, club_id: &str) -> AppResult<ActiveClubItems> {
let mut items = Vec::new();
for slot in ActiveSlot::ALL {
if let Some(card) = get_active_club_item(pool, club_id, slot).await? {
items.push((slot, card));
}
}
Ok(ActiveClubItems { items })
}
/// Designate `owned_card_id` as `club_id`'s active item for `slot`, replacing any
/// existing designation for that slot.
///
/// Fail-closed, in one transaction:
/// * the instance MUST be owned by `club_id` (so a client cannot install
/// another club's item, nor an id that does not exist);
/// * its `content_kind` MUST be the kind the slot admits (a badge in
/// `home_kit` is rejected, not silently accepted);
/// * an instance already designated for a DIFFERENT slot is released first, so
/// the `owned_card_id UNIQUE` invariant is upheld by an explicit move rather
/// than a constraint error.
pub async fn set_active_club_item(
pool: &Pool,
club_id: &str,
slot: ActiveSlot,
owned_card_id: &str,
) -> AppResult<()> {
let mut tx = pool.begin().await?;
let owned = sqlx::query_as::<_, (String, ContentKind)>(
"SELECT id, content_kind FROM owned_cards WHERE id = ? AND club_id = ?",
)
.bind(owned_card_id)
.bind(club_id)
.fetch_optional(&mut *tx)
.await?;
let Some((_, kind)) = owned else {
return Err(AppError::NotFound(format!(
"owned card '{owned_card_id}' not found"
)));
};
let required = slot.required_kind();
if kind != required {
return Err(AppError::BadRequest(format!(
"slot '{slot}' requires content kind '{required}', but owned card \
'{owned_card_id}' is '{kind}'"
)));
}
// Release this instance from any other slot, then take the target slot.
sqlx::query("DELETE FROM club_active_items WHERE owned_card_id = ?")
.bind(owned_card_id)
.execute(&mut *tx)
.await?;
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT OR REPLACE INTO club_active_items \
(club_id, slot, owned_card_id, updated_at) VALUES (?, ?, ?, ?)",
)
.bind(club_id)
.bind(slot.as_str())
.bind(owned_card_id)
.bind(&now)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
/// Clear `club_id`'s designation for `slot` (idempotent — an already-empty slot
/// is a successful no-op).
pub async fn clear_active_club_item(pool: &Pool, club_id: &str, slot: ActiveSlot) -> AppResult<()> {
sqlx::query("DELETE FROM club_active_items WHERE club_id = ? AND slot = ?")
.bind(club_id)
.bind(slot.as_str())
.execute(pool)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db;
const TS: &str = "2026-01-01T00:00:00Z";
/// A file-backed pool (so a "restart" can reopen the same DB) with two clubs.
async fn fixture() -> (tempfile::TempDir, String, db::Pool) {
let dir = tempfile::tempdir().expect("tempdir");
let url = format!("sqlite://{}", dir.path().join("core.db").display());
let pool = db::init_pool(&url, 5).await.expect("init pool");
db::run_migrations(&pool).await.expect("migrations");
for (profile, club) in [("prof-a", "club-a"), ("prof-b", "club-b")] {
sqlx::query(
"INSERT INTO profiles (id, username, created_at, updated_at) VALUES (?, ?, ?, ?)",
)
.bind(profile)
.bind(profile)
.bind(TS)
.bind(TS)
.execute(&pool)
.await
.expect("profile");
sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)")
.bind(club).bind(profile).bind(club).bind(1000i64).bind(TS).bind(TS)
.execute(&pool).await.expect("club");
}
for (id, club, definition, kind) in [
("mgr", "club-a", "def-mgr", ContentKind::Manager),
("mgr2", "club-a", "def-mgr", ContentKind::Manager),
("player", "club-a", "def-player", ContentKind::Player),
("kit-home", "club-a", "def-kit-home", ContentKind::Kit),
("kit-away", "club-a", "def-kit-away", ContentKind::Kit),
("kit-away-2", "club-a", "def-kit-away-2", ContentKind::Kit),
("badge", "club-a", "def-badge", ContentKind::Badge),
("ball", "club-a", "def-ball", ContentKind::Ball),
("stadium", "club-a", "def-stadium", ContentKind::Stadium),
("foreign", "club-b", "def-kit-foreign", ContentKind::Kit),
] {
sqlx::query(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at, content_kind) \
VALUES (?, ?, ?, 0, ?, ?)",
)
.bind(id)
.bind(club)
.bind(definition)
.bind(TS)
.bind(kind.as_str())
.execute(&pool)
.await
.expect("owned card");
}
// club-a has one squad.
sqlx::query("INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES ('sq-a', 'club-a', 'S', '4-4-2', ?, ?)")
.bind(TS).bind(TS).execute(&pool).await.expect("squad");
(dir, url, pool)
}
async fn manager_rows(pool: &db::Pool) -> i64 {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM squad_managers")
.fetch_one(pool)
.await
.unwrap()
}
async fn active_item_rows(pool: &db::Pool) -> i64 {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM club_active_items")
.fetch_one(pool)
.await
.unwrap()
}
#[tokio::test]
async fn manager_persists_across_reload_and_restart() {
let (dir, url, pool) = fixture().await;
// SAVE.
set_squad_manager(&pool, "club-a", "mgr")
.await
.expect("assign");
// RELOAD (same pool).
let got = get_squad_manager(&pool, "club-a").await.unwrap();
assert_eq!(got.as_ref().map(|c| c.id.as_str()), Some("mgr"));
// RESTART: close the pool and reopen the same DB file.
pool.close().await;
let reopened = db::init_pool(&url, 5).await.expect("reopen");
db::run_migrations(&reopened).await.expect("migrations");
let after = get_squad_manager(&reopened, "club-a").await.unwrap();
assert_eq!(
after.as_ref().map(|c| c.id.as_str()),
Some("mgr"),
"manager assignment must survive a server restart"
);
drop(dir);
}
#[tokio::test]
async fn reassignment_replaces_and_never_duplicates() {
let (_dir, _url, pool) = fixture().await;
set_squad_manager(&pool, "club-a", "mgr").await.unwrap();
set_squad_manager(&pool, "club-a", "mgr2").await.unwrap();
assert_eq!(manager_rows(&pool).await, 1, "one manager per squad");
let got = get_squad_manager(&pool, "club-a").await.unwrap();
assert_eq!(got.map(|c| c.id), Some("mgr2".to_string()));
}
#[tokio::test]
async fn clear_removes_and_no_resurrection() {
let (_dir, _url, pool) = fixture().await;
set_squad_manager(&pool, "club-a", "mgr").await.unwrap();
clear_squad_manager(&pool, "club-a").await.unwrap();
assert!(get_squad_manager(&pool, "club-a").await.unwrap().is_none());
assert_eq!(manager_rows(&pool).await, 0);
// Clearing again is an idempotent no-op.
clear_squad_manager(&pool, "club-a").await.unwrap();
}
#[tokio::test]
async fn rejects_card_the_club_does_not_own() {
let (_dir, _url, pool) = fixture().await;
let err = set_squad_manager(&pool, "club-a", "foreign").await;
assert!(err.is_err(), "cannot assign a card owned by another club");
assert_eq!(manager_rows(&pool).await, 0);
}
#[tokio::test]
async fn quick_sell_of_manager_cascades_the_assignment_away() {
let (_dir, _url, pool) = fixture().await;
set_squad_manager(&pool, "club-a", "mgr").await.unwrap();
// A quick-sell/discard DELETEs the owned row; ON DELETE CASCADE must
// remove the assignment so the sold manager is never resurrected.
sqlx::query("DELETE FROM owned_cards WHERE id = 'mgr'")
.execute(&pool)
.await
.expect("delete owned card");
assert_eq!(manager_rows(&pool).await, 0);
assert!(get_squad_manager(&pool, "club-a").await.unwrap().is_none());
}
#[tokio::test]
async fn no_manager_when_none_assigned() {
let (_dir, _url, pool) = fixture().await;
assert!(get_squad_manager(&pool, "club-a").await.unwrap().is_none());
}
// ── active club item designations ──
#[tokio::test]
async fn active_items_persist_across_reload_and_restart() {
let (dir, url, pool) = fixture().await;
for (slot, id) in [
(ActiveSlot::HomeKit, "kit-home"),
(ActiveSlot::AwayKit, "kit-away"),
(ActiveSlot::Badge, "badge"),
(ActiveSlot::Ball, "ball"),
(ActiveSlot::Stadium, "stadium"),
] {
set_active_club_item(&pool, "club-a", slot, id)
.await
.expect("designate");
}
let current = get_active_club_items(&pool, "club-a").await.unwrap();
assert_eq!(current.items.len(), 5, "every slot filled");
// Projection order is the canonical slot order, not DB insertion order.
assert_eq!(
current.items.iter().map(|(s, _)| *s).collect::<Vec<_>>(),
ActiveSlot::ALL.to_vec()
);
pool.close().await;
let reopened = db::init_pool(&url, 5).await.expect("reopen");
db::run_migrations(&reopened).await.expect("migrations");
let persisted = get_active_club_items(&reopened, "club-a").await.unwrap();
assert_eq!(
persisted.get(ActiveSlot::Stadium).map(|c| c.id.as_str()),
Some("stadium"),
"designations must survive a server restart"
);
drop(dir);
}
#[tokio::test]
async fn active_item_replace_and_clear_never_duplicate() {
let (_dir, _url, pool) = fixture().await;
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "kit-away")
.await
.unwrap();
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "kit-away-2")
.await
.unwrap();
assert_eq!(active_item_rows(&pool).await, 1, "one item per slot");
let current = get_active_club_items(&pool, "club-a").await.unwrap();
assert_eq!(
current.get(ActiveSlot::AwayKit).map(|c| c.id.as_str()),
Some("kit-away-2")
);
clear_active_club_item(&pool, "club-a", ActiveSlot::AwayKit)
.await
.unwrap();
assert_eq!(active_item_rows(&pool).await, 0);
// Clearing an empty slot is an idempotent no-op.
clear_active_club_item(&pool, "club-a", ActiveSlot::AwayKit)
.await
.unwrap();
}
#[tokio::test]
async fn active_item_rejects_unowned_card_and_leaves_state_intact() {
let (_dir, _url, pool) = fixture().await;
set_active_club_item(&pool, "club-a", ActiveSlot::HomeKit, "kit-home")
.await
.unwrap();
assert!(
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "foreign")
.await
.is_err(),
"another club's item cannot be designated"
);
assert!(
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "nope")
.await
.is_err(),
"a non-existent instance cannot be designated"
);
let unchanged = get_active_club_items(&pool, "club-a").await.unwrap();
assert_eq!(
unchanged.get(ActiveSlot::HomeKit).map(|c| c.id.as_str()),
Some("kit-home")
);
assert_eq!(active_item_rows(&pool).await, 1);
}
#[tokio::test]
async fn active_item_rejects_slot_kind_mismatch() {
let (_dir, _url, pool) = fixture().await;
// A badge is not a kit; a player is not a stadium.
for (slot, id) in [
(ActiveSlot::HomeKit, "badge"),
(ActiveSlot::Stadium, "player"),
(ActiveSlot::Ball, "kit-home"),
] {
let err = set_active_club_item(&pool, "club-a", slot, id)
.await
.expect_err("slot/kind mismatch must be refused");
assert!(
matches!(err, AppError::BadRequest(_)),
"expected a bad-request, got {err:?}"
);
}
assert_eq!(active_item_rows(&pool).await, 0);
}
/// Lifecycle invariant: one owned instance can occupy at most ONE slot.
/// Re-designating it moves it rather than duplicating it.
#[tokio::test]
async fn one_instance_cannot_occupy_two_slots() {
let (_dir, _url, pool) = fixture().await;
set_active_club_item(&pool, "club-a", ActiveSlot::HomeKit, "kit-home")
.await
.unwrap();
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "kit-home")
.await
.unwrap();
assert_eq!(active_item_rows(&pool).await, 1);
let current = get_active_club_items(&pool, "club-a").await.unwrap();
assert!(current.get(ActiveSlot::HomeKit).is_none());
assert_eq!(
current.get(ActiveSlot::AwayKit).map(|c| c.id.as_str()),
Some("kit-home")
);
// The schema itself refuses the impossible state, not just the service.
let raw = sqlx::query(
"INSERT INTO club_active_items (club_id, slot, owned_card_id, updated_at) \
VALUES ('club-a', 'home_kit', 'kit-home', ?)",
)
.bind(TS)
.execute(&pool)
.await;
assert!(
raw.is_err(),
"owned_card_id UNIQUE must reject a second slot"
);
}
/// Lifecycle invariant: a designation can never point at an item the club
/// does not own — neither after a quick sell (DELETE) nor after a transfer
/// (UPDATE of club_id, which no FK action can observe).
#[tokio::test]
async fn delete_and_transfer_clear_active_designations() {
let (_dir, _url, pool) = fixture().await;
set_active_club_item(&pool, "club-a", ActiveSlot::HomeKit, "kit-home")
.await
.unwrap();
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "kit-away")
.await
.unwrap();
sqlx::query("DELETE FROM owned_cards WHERE id = 'kit-home'")
.execute(&pool)
.await
.expect("quick sell kit");
let after_delete = get_active_club_items(&pool, "club-a").await.unwrap();
assert!(after_delete.get(ActiveSlot::HomeKit).is_none());
assert_eq!(
after_delete.get(ActiveSlot::AwayKit).map(|c| c.id.as_str()),
Some("kit-away")
);
assert_eq!(active_item_rows(&pool).await, 1);
sqlx::query("UPDATE owned_cards SET club_id = 'club-b' WHERE id = 'kit-away'")
.execute(&pool)
.await
.expect("transfer kit");
assert_eq!(active_item_rows(&pool).await, 0, "transfer clears the slot");
let after_transfer = get_active_club_items(&pool, "club-a").await.unwrap();
assert!(after_transfer.items.is_empty());
}
/// A designation whose owned row is forced out of the club WITHOUT the
/// trigger firing (raw row surgery mimicking corruption) must still never
/// project: reads revalidate ownership.
#[tokio::test]
async fn read_revalidates_ownership_of_a_stale_designation() {
let (_dir, _url, pool) = fixture().await;
set_active_club_item(&pool, "club-a", ActiveSlot::Badge, "badge")
.await
.unwrap();
sqlx::query("UPDATE club_active_items SET owned_card_id = 'foreign' WHERE slot = 'badge'")
.execute(&pool)
.await
.expect("corrupt the designation");
let items = get_active_club_items(&pool, "club-a").await.unwrap();
assert!(
items.get(ActiveSlot::Badge).is_none(),
"another club's item must never be projected"
);
}
}
File diff suppressed because it is too large Load Diff
+21 -14
View File
@@ -184,24 +184,32 @@ pub async fn pick_card(
let (new_candidates, new_status, reward_coins, reward_pack_id, squad_rating, completed_at) =
if all_filled {
let (coins, pack, avg) = compute_reward(card_db, &picks);
(None, "completed".to_string(), coins, pack, avg, Some(chrono::Utc::now().to_rfc3339()))
(
None,
"completed".to_string(),
coins,
pack,
avg,
Some(chrono::Utc::now().to_rfc3339()),
)
} else {
let min_overall = difficulty_min_overall(&session.difficulty);
let next_pos = &pick_order[next_index];
let next_candidates =
pick_candidates(card_db, next_pos, min_overall, CANDIDATES_PER_SLOT);
{
let candidates_json = serde_json::to_string(&next_candidates)
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
(
Some(candidates_json),
"active".to_string(),
0,
None,
0,
None,
)
}
let candidates_json = serde_json::to_string(&next_candidates).map_err(|e| {
AppError::Internal(anyhow::anyhow!("serialization failed: {e}"))
})?;
(
Some(candidates_json),
"active".to_string(),
0,
None,
0,
None,
)
}
};
let picks_json = serde_json::to_string(&picks)
@@ -294,8 +302,7 @@ async fn fetch_session(pool: &Pool, profile_id: &str, session_id: &str) -> AppRe
}
fn render_session(session: &DraftSession, card_db: &CardDb) -> serde_json::Value {
let pick_order: Vec<String> =
serde_json::from_str(&session.pick_order).unwrap_or_default();
let pick_order: Vec<String> = serde_json::from_str(&session.pick_order).unwrap_or_default();
let picks: Vec<String> = serde_json::from_str(&session.picks).unwrap_or_default();
let candidates: Vec<String> = session
.current_candidates
+5 -1
View File
@@ -26,7 +26,11 @@ pub async fn get_active_session(
.map_err(Into::into)
}
pub async fn get_session(pool: &Pool, session_id: &str, profile_id: &str) -> AppResult<FutChampsSession> {
pub async fn get_session(
pool: &Pool,
session_id: &str,
profile_id: &str,
) -> AppResult<FutChampsSession> {
sqlx::query_as::<_, FutChampsSession>(&format!(
"{SESSION_SELECT} WHERE id = ? AND profile_id = ?"
))
+34 -2
View File
@@ -20,6 +20,7 @@
//! - The whole thing commits together or not at all.
use crate::db::Pool;
use crate::models::card::ContentKind;
use crate::models::game_ext::{MAX_EXT_NAMESPACE_LEN, MAX_EXT_PAYLOAD_BYTES};
use crate::services::card_db::CardDb;
use crate::services::squad::squad_fingerprint;
@@ -49,6 +50,20 @@ pub struct ImportOwnedCard {
pub owned_item_id: String,
/// CardDefinitionId that MUST resolve in loaded production content.
pub card_id: String,
/// Generic content classification. Absent = `player`, which is what every
/// pre-taxonomy import produced; the adapter maps its own taxonomy (FIFA 17
/// `cardsubtypeid`, resource ranges, …) onto this before calling Core.
#[serde(default)]
pub content_kind: ContentKind,
/// Optional per-instance stack size. Absent / `null` means "not a stack"; it
/// never collapses two instances into one row.
///
/// NOT a consumable's wire `amount`: in FIFA 17 that field is the
/// definition's effect magnitude (a "+15 training" card), and every copy is
/// its own instance carrying the same value, so storing it here would claim
/// the club owns fifteen of them.
#[serde(default)]
pub quantity: Option<i64>,
}
#[derive(Debug, Deserialize)]
@@ -127,6 +142,19 @@ pub async fn apply_profile_import(
if req.owned.is_empty() {
bail!("import request has zero owned cards; refusing to import an empty profile");
}
// A stack size is either absent ("not a stack") or a real positive count.
// Reject an explicit 0/negative up front rather than letting the column
// CHECK surface it as an opaque constraint failure mid-transaction.
for o in &req.owned {
if let Some(q) = o.quantity {
if q < 1 {
bail!(
"owned card {} has quantity {q}; a stack size must be omitted or >= 1",
o.owned_item_id
);
}
}
}
// ── 1. rerun identity / single-profile-per-game ──
let existing: Option<(String, Option<String>)> = sqlx::query_as(
@@ -242,13 +270,17 @@ pub async fn apply_profile_import(
for o in &req.owned {
sqlx::query(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \
VALUES (?, ?, ?, 0, NULL, ?)",
"INSERT INTO owned_cards \
(id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
content_kind, quantity) \
VALUES (?, ?, ?, 0, NULL, ?, ?, ?)",
)
.bind(&o.owned_item_id)
.bind(&club_id)
.bind(&o.card_id)
.bind(&now)
.bind(o.content_kind.as_str())
.bind(o.quantity)
.execute(&mut *tx)
.await
.with_context(|| format!("insert owned_card {}", o.owned_item_id))?;
+60 -2
View File
@@ -15,7 +15,7 @@
use serde::Deserialize;
use crate::models::card::Quality;
use crate::models::card::{ContentKind, Quality};
/// Semantic owned-inventory query. All values are game-independent: a quality
/// tier, entity **names** (not ids), and semantic offset/limit. Every filter is
@@ -25,6 +25,9 @@ pub struct OwnedItemQuery {
/// Quality tier (gold/silver/bronze). Serialized lowercase.
#[serde(default)]
pub quality: Option<Quality>,
/// Owned-content kind (player/consumable/kit/…). Serialized lowercase.
#[serde(default)]
pub content_kind: Option<ContentKind>,
/// Playing position, e.g. "ST" (matched case-insensitively).
#[serde(default)]
pub position: Option<String>,
@@ -47,8 +50,12 @@ pub struct OwnedItemQuery {
/// One owned item projected to the attributes needed for querying, plus the
/// response body to hand back verbatim once it survives the filter+page.
#[derive(Clone)]
pub struct OwnedItemView {
pub owned_card_id: String,
/// What kind of content this instance is; lets a caller filter without
/// re-deriving the taxonomy from definition fields.
pub content_kind: ContentKind,
/// Base card overall (drives quality tier).
pub base_overall: u8,
/// Effective overall (base + training bonus); drives ordering.
@@ -78,6 +85,10 @@ pub struct QueryPage {
/// Does an item satisfy every present filter (AND semantics)?
fn matches(item: &OwnedItemView, q: &OwnedItemQuery) -> bool {
let quality_ok = q.quality.map(|want| item.quality() == want).unwrap_or(true);
let kind_ok = q
.content_kind
.map(|want| item.content_kind == want)
.unwrap_or(true);
let pos_ok = q
.position
.as_ref()
@@ -98,7 +109,7 @@ fn matches(item: &OwnedItemView, q: &OwnedItemQuery) -> bool {
.as_ref()
.map(|c| item.club.eq_ignore_ascii_case(c))
.unwrap_or(true);
quality_ok && pos_ok && nation_ok && league_ok && club_ok
quality_ok && kind_ok && pos_ok && nation_ok && league_ok && club_ok
}
/// Apply the query: filter (AND) → deterministic order → paginate.
@@ -152,6 +163,7 @@ mod tests {
) -> OwnedItemView {
OwnedItemView {
owned_card_id: id.to_string(),
content_kind: ContentKind::Player,
base_overall: overall,
effective_overall: overall as i64,
position: position.to_string(),
@@ -179,6 +191,52 @@ mod tests {
]
}
/// A club holds mixed content; a caller asking for one kind must get exactly
/// that kind, and the unfiltered read must still return everything.
#[test]
fn content_kind_filters_mixed_inventory() {
let mut items = fixture();
let mut kit = view("k", 0, "", "", "", "");
kit.content_kind = ContentKind::Kit;
let mut style = view("s", 0, "", "", "", "");
style.content_kind = ContentKind::Consumable;
items.push(kit);
items.push(style);
let all = apply_query(items.clone(), &OwnedItemQuery::default());
assert_eq!(all.total, 7, "no filter returns every kind");
let kits = apply_query(
items.clone(),
&OwnedItemQuery {
content_kind: Some(ContentKind::Kit),
..Default::default()
},
);
assert_eq!(ids(&kits), ["k"]);
let players = apply_query(
items.clone(),
&OwnedItemQuery {
content_kind: Some(ContentKind::Player),
..Default::default()
},
);
assert_eq!(players.total, 5);
let none = apply_query(
items,
&OwnedItemQuery {
content_kind: Some(ContentKind::Stadium),
..Default::default()
},
);
assert_eq!(
none.total, 0,
"a kind the club owns none of is empty, not everything"
);
}
#[test]
fn no_filter_returns_all_in_overall_desc_order() {
let p = apply_query(fixture(), &OwnedItemQuery::default());
+14 -10
View File
@@ -149,7 +149,9 @@ pub async fn buy_listing(
.await?
.rows_affected();
if claimed == 0 {
return Err(AppError::NotFound("listing not found or already sold".into()));
return Err(AppError::NotFound(
"listing not found or already sold".into(),
));
}
if let Err(e) = club::spend_coins(pool, club_id, listing.price).await {
let _ = sqlx::query("UPDATE market_listings SET sold = 0 WHERE id = ?")
@@ -206,11 +208,10 @@ pub async fn sell_card(
return Err(AppError::BadRequest("price must be non-negative".into()));
}
let owned = sqlx::query_as::<_, crate::models::card::OwnedCard>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
chemistry_style, position_override, training_bonus \
FROM owned_cards WHERE id = ? AND club_id = ?",
)
let owned = sqlx::query_as::<_, crate::models::card::OwnedCard>(&format!(
"{} WHERE id = ? AND club_id = ?",
crate::models::card::OWNED_CARD_SELECT
))
.bind(&req.owned_card_id)
.bind(club_id)
.fetch_optional(pool)
@@ -308,9 +309,10 @@ pub async fn get_listings_by_seller(
let with_cards = listings
.into_iter()
.filter_map(|l| {
card_db
.get(&l.card_id)
.map(|card| MarketListingWithCard { listing: l, card: card.clone() })
card_db.get(&l.card_id).map(|card| MarketListingWithCard {
listing: l,
card: card.clone(),
})
})
.collect();
Ok(with_cards)
@@ -326,7 +328,9 @@ pub async fn cancel_listing(pool: &Pool, club_id: &str, listing_id: &str) -> App
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("listing '{listing_id}' not found or already sold")))?;
.ok_or_else(|| {
AppError::NotFound(format!("listing '{listing_id}' not found or already sold"))
})?;
sqlx::query("DELETE FROM market_listings WHERE id = ?")
.bind(listing_id)
File diff suppressed because it is too large Load Diff
+1
View File
@@ -2,6 +2,7 @@ pub mod achievement;
pub mod card_db;
pub mod checkin;
pub mod club;
pub mod consume;
pub mod draft;
pub mod economy;
pub mod event;
+68 -4
View File
@@ -6,6 +6,7 @@ use crate::{
},
};
use anyhow::Context;
use sqlx::{Sqlite, Transaction};
use std::path::Path;
use uuid::Uuid;
@@ -67,10 +68,7 @@ pub async fn increment_metric(
) -> AppResult<Vec<String>> {
let mut completed_ids = Vec::new();
for def in defs
.iter()
.filter(|d| d.metric.as_str() == metric)
{
for def in defs.iter().filter(|d| d.metric.as_str() == metric) {
let existing = sqlx::query_as::<_, ObjectiveProgress>(
"SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?"
)
@@ -123,6 +121,72 @@ pub async fn increment_metric(
Ok(completed_ids)
}
/// Transaction-scoped [`increment_metric`] for the atomic match-completion path.
/// Same semantics, but every read/write runs inside the caller's transaction so
/// objective progress commits (or rolls back) together with the coins, XP, and
/// statistics of the same match. `now` is threaded so one match stamps a single
/// timestamp.
pub async fn increment_metric_tx(
tx: &mut Transaction<'_, Sqlite>,
profile_id: &str,
defs: &[ObjectiveDefinition],
metric: &str,
amount: i64,
now: &str,
) -> AppResult<Vec<String>> {
let mut completed_ids = Vec::new();
for def in defs.iter().filter(|d| d.metric.as_str() == metric) {
let existing = sqlx::query_as::<_, ObjectiveProgress>(
"SELECT id, profile_id, objective_id, current, completed, claimed, updated_at FROM objective_progress WHERE profile_id = ? AND objective_id = ?"
)
.bind(profile_id)
.bind(&def.id)
.fetch_optional(&mut **tx)
.await?;
if let Some(prog) = existing {
if prog.completed {
continue;
}
let new_val = (prog.current + amount).min(def.target);
let now_complete = new_val >= def.target;
sqlx::query(
"UPDATE objective_progress SET current = ?, completed = ?, updated_at = ? WHERE id = ?"
)
.bind(new_val)
.bind(now_complete)
.bind(now)
.bind(&prog.id)
.execute(&mut **tx)
.await?;
if now_complete {
completed_ids.push(def.id.clone());
}
} else {
let new_val = amount.min(def.target);
let now_complete = new_val >= def.target;
let id = Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO objective_progress (id, profile_id, objective_id, current, completed, claimed, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?)"
)
.bind(&id)
.bind(profile_id)
.bind(&def.id)
.bind(new_val)
.bind(now_complete)
.bind(now)
.execute(&mut **tx)
.await?;
if now_complete {
completed_ids.push(def.id.clone());
}
}
}
Ok(completed_ids)
}
pub async fn claim_objective(
pool: &Pool,
profile_id: &str,
+9 -10
View File
@@ -73,14 +73,13 @@ pub async fn open_pack(
// Atomically claim the pack before minting any cards: only one concurrent opener
// flips opened 0->1, so a double-open cannot mint the reward twice (duplication).
let claimed = sqlx::query(
"UPDATE packs SET opened = 1 WHERE id = ? AND club_id = ? AND opened = 0",
)
.bind(pack_id)
.bind(club_id)
.execute(pool)
.await?
.rows_affected();
let claimed =
sqlx::query("UPDATE packs SET opened = 1 WHERE id = ? AND club_id = ? AND opened = 0")
.bind(pack_id)
.bind(club_id)
.execute(pool)
.await?
.rows_affected();
if claimed == 0 {
return Err(AppError::BadRequest("pack already opened".into()));
}
@@ -134,8 +133,8 @@ pub async fn open_pack(
}
}
let card_ids_json = serde_json::to_string(&cards.iter().map(|c| &c.id).collect::<Vec<_>>())
.unwrap_or_default();
let card_ids_json =
serde_json::to_string(&cards.iter().map(|c| &c.id).collect::<Vec<_>>()).unwrap_or_default();
let now = chrono::Utc::now().to_rfc3339();
sqlx::query("UPDATE packs SET opened_cards = ?, opened_at = ? WHERE id = ?")
+5 -1
View File
@@ -100,7 +100,11 @@ pub async fn add_xp_with_levelup(
}
tracing::info!(profile_id, new_level = lvl, coins, "level up");
events.push(LevelUpEvent { new_level: lvl, coins_granted: coins, pack_granted: pack });
events.push(LevelUpEvent {
new_level: lvl,
coins_granted: coins,
pack_granted: pack,
});
}
Ok(events)
+4 -5
View File
@@ -350,11 +350,10 @@ async fn submit_sbc_transaction(
let mut cards = Vec::with_capacity(owned_card_ids.len());
for owned_id in owned_card_ids {
let row = sqlx::query_as::<_, crate::models::card::OwnedCard>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
chemistry_style, position_override, training_bonus \
FROM owned_cards WHERE id = ? AND club_id = ?",
)
let row = sqlx::query_as::<_, crate::models::card::OwnedCard>(&format!(
"{} WHERE id = ? AND club_id = ?",
crate::models::card::OWNED_CARD_SELECT
))
.bind(owned_id)
.bind(club_id)
.fetch_optional(&mut **tx)
+71 -37
View File
@@ -2,8 +2,8 @@ use crate::{
db::Pool,
error::{AppError, AppResult},
models::season::{Season, SeasonEndSummary, SeasonHistoryEntry, SeasonResult},
services::{club, pack},
};
use sqlx::{Sqlite, Transaction};
use uuid::Uuid;
/// Get the current season for a profile, creating it if it doesn't exist.
@@ -20,9 +20,11 @@ pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult<Season> {
.bind(&now)
.execute(pool)
.await?;
fetch(pool, profile_id)
.await?
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("season row missing immediately after insert")))
fetch(pool, profile_id).await?.ok_or_else(|| {
AppError::Internal(anyhow::anyhow!(
"season row missing immediately after insert"
))
})
}
async fn fetch(pool: &Pool, profile_id: &str) -> AppResult<Option<Season>> {
@@ -36,21 +38,34 @@ async fn fetch(pool: &Pool, profile_id: &str) -> AppResult<Option<Season>> {
Ok(s)
}
/// Record a match result in the season; end the season if the quota is met.
/// Record a match in Core's season model inside the caller's transaction,
/// ending the season when the quota is met.
///
/// Returns the updated season and an optional end-of-season summary.
pub async fn record_match(
pool: &Pool,
/// Mirrors [`record_match`] but every write — the season row, the rollover, the
/// end-of-season coin and pack award, and the history entry — commits or rolls
/// back with the match that caused it. Creates the season row if absent, so a
/// first match does not need a separate call.
pub async fn record_match_tx(
tx: &mut Transaction<'_, Sqlite>,
club_id: &str,
profile_id: &str,
outcome: &str,
) -> AppResult<(Season, Option<SeasonEndSummary>)> {
now: &str,
) -> AppResult<Option<SeasonEndSummary>> {
sqlx::query(
"INSERT OR IGNORE INTO seasons (profile_id, division, season_number, season_points, \
matches_played, wins, draws, losses, started_at) VALUES (?, 5, 1, 0, 0, 0, 0, 0, ?)",
)
.bind(profile_id)
.bind(now)
.execute(&mut **tx)
.await?;
let points = match outcome {
"win" => 3,
"draw" => 1,
_ => 0,
};
sqlx::query(
"UPDATE seasons SET \
season_points = season_points + ?, \
@@ -65,30 +80,28 @@ pub async fn record_match(
.bind(outcome)
.bind(outcome)
.bind(profile_id)
.execute(pool)
.execute(&mut **tx)
.await?;
let season = fetch(pool, profile_id)
.await?
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("season row missing after record_match update")))?;
let season = fetch_tx(tx, profile_id).await?.ok_or_else(|| {
AppError::Internal(anyhow::anyhow!(
"season row missing after record_match_tx update"
))
})?;
if !season.is_complete() {
return Ok((season, None));
return Ok(None);
}
// Season complete — calculate result and start next
let result = season.end_result();
let old_div = season.division;
let coins = season.season_reward_coins();
let pack_id = season.season_reward_pack();
let new_div = match result {
SeasonResult::Promoted => (old_div - 1).max(1),
SeasonResult::Relegated => (old_div + 1).min(10),
SeasonResult::Maintained => old_div,
};
let new_season = season.season_number + 1;
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"UPDATE seasons SET division = ?, season_number = ?, season_points = 0, \
@@ -97,30 +110,46 @@ pub async fn record_match(
)
.bind(new_div)
.bind(new_season)
.bind(&now)
.bind(now)
.bind(profile_id)
.execute(pool)
.execute(&mut **tx)
.await?;
// Grant rewards
club::add_coins(pool, club_id, coins).await?;
if coins > 0 {
let credited =
sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?")
.bind(coins)
.bind(now)
.bind(club_id)
.execute(&mut **tx)
.await?;
if credited.rows_affected() != 1 {
return Err(AppError::NotFound("club not found".into()));
}
}
if let Some(pack_def) = pack_id {
pack::grant_pack(pool, club_id, pack_def).await?;
sqlx::query(
"INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, 0, ?)",
)
.bind(Uuid::new_v4().to_string())
.bind(club_id)
.bind(pack_def)
.bind(now)
.execute(&mut **tx)
.await?;
}
// Persist history entry before rolling over
let result_str = match result {
SeasonResult::Promoted => "promoted",
SeasonResult::Maintained => "maintained",
SeasonResult::Relegated => "relegated",
};
let history_id = Uuid::new_v4().to_string();
let _ = sqlx::query(
sqlx::query(
"INSERT INTO season_history (id, profile_id, season_number, division, season_points, \
wins, draws, losses, result, new_division, coins_awarded, pack_awarded, ended_at) \
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
)
.bind(&history_id)
.bind(Uuid::new_v4().to_string())
.bind(profile_id)
.bind(season.season_number)
.bind(old_div)
@@ -132,23 +161,28 @@ pub async fn record_match(
.bind(new_div)
.bind(coins)
.bind(pack_id)
.bind(&now)
.execute(pool)
.await;
.bind(now)
.execute(&mut **tx)
.await?;
let summary = SeasonEndSummary {
Ok(Some(SeasonEndSummary {
result,
old_division: old_div,
new_division: new_div,
new_season_number: new_season,
coins_awarded: coins,
pack_awarded: pack_id.map(String::from),
};
}))
}
let updated = fetch(pool, profile_id)
.await?
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("season row missing after season rollover")))?;
Ok((updated, Some(summary)))
async fn fetch_tx(tx: &mut Transaction<'_, Sqlite>, profile_id: &str) -> AppResult<Option<Season>> {
Ok(sqlx::query_as::<_, Season>(
"SELECT profile_id, division, season_number, season_points, matches_played, \
wins, draws, losses, started_at FROM seasons WHERE profile_id = ?",
)
.bind(profile_id)
.fetch_optional(&mut **tx)
.await?)
}
/// Return past seasons for a profile, newest first (max 20).
+21 -19
View File
@@ -2,7 +2,7 @@ use crate::{
db::Pool,
error::{AppError, AppResult},
models::{
card::{CardDefinition, OwnedCard},
card::{CardDefinition, OwnedCard, OWNED_CARD_SELECT},
game_ext::{GameEntityExt, OpaqueExtensionWrite},
squad::{
SaveSquadRequest, SlotAssignment, Squad, SquadPlayer, SquadPlayerInput, SquadReplaced,
@@ -88,14 +88,19 @@ pub async fn validate_formation(
let mut gk_count = 0usize;
for sp in &starters {
let owned = sqlx::query_as::<_, OwnedCard>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ? AND club_id = ?",
)
let owned = sqlx::query_as::<_, OwnedCard>(&format!(
"{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"
))
.bind(&sp.owned_card_id)
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("owned card {} not found or does not belong to this club", sp.owned_card_id)))?;
.ok_or_else(|| {
AppError::NotFound(format!(
"owned card {} not found or does not belong to this club",
sp.owned_card_id
))
})?;
if let Some(card) = card_db.get(&owned.card_id) {
if card.position == "GK" {
@@ -137,13 +142,10 @@ pub async fn calculate_chemistry(
// Load all starter card definitions (N separate queries, fine for 11 players)
let mut player_cards: Vec<(String, CardDefinition)> = Vec::new();
for sp in &starters {
let owned = sqlx::query_as::<_, OwnedCard>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at \
FROM owned_cards WHERE id = ?",
)
.bind(&sp.owned_card_id)
.fetch_optional(pool)
.await?;
let owned = sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} WHERE id = ?"))
.bind(&sp.owned_card_id)
.fetch_optional(pool)
.await?;
if let Some(o) = owned {
if let Some(card) = card_db.get(&o.card_id) {
@@ -270,13 +272,13 @@ async fn replace_squad_inner(
// though it did.
let mut resolved: Vec<(SlotAssignmentRef, OwnedCard)> = Vec::new();
for s in &replacement.slots {
let owned = sqlx::query_as::<_, OwnedCard>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ?",
)
.bind(&s.owned_card_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("owned card {} not found", s.owned_card_id)))?;
let owned = sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} WHERE id = ?"))
.bind(&s.owned_card_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| {
AppError::NotFound(format!("owned card {} not found", s.owned_card_id))
})?;
if owned.club_id != club_id {
// Deliberately the same message as "not found": whether a card
+93 -1
View File
@@ -1,6 +1,7 @@
use crate::{db::Pool, error::AppResult, models::statistics::Statistics};
use sqlx::{Sqlite, Transaction};
const SELECT_STATS: &str = "SELECT profile_id, matches_played, matches_won, matches_drawn, matches_lost, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, win_streak, best_win_streak, updated_at FROM statistics WHERE profile_id = ?";
const SELECT_STATS: &str = "SELECT profile_id, matches_played, matches_won, matches_drawn, matches_lost, matches_dnf, goals_scored, goals_conceded, packs_opened, sbcs_completed, total_coins_earned, win_streak, best_win_streak, updated_at FROM statistics WHERE profile_id = ?";
pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult<Statistics> {
if let Some(s) = sqlx::query_as::<_, Statistics>(SELECT_STATS)
@@ -77,6 +78,77 @@ pub async fn record_match(
Ok(())
}
/// Record a completed match within an existing transaction (the atomic
/// match-completion path). `outcome` is `win` | `draw` | `loss` | `dnf`. A DNF
/// (abandon/quit) increments its own bucket — never `matches_lost` — and, like a
/// loss, resets the win streak. All-or-nothing with the caller's transaction; it
/// never commits on its own, so a later failure rolls this back with everything
/// else.
pub async fn record_match_tx(
tx: &mut Transaction<'_, Sqlite>,
profile_id: &str,
outcome: &str,
goals_for: i64,
goals_against: i64,
coins: i64,
now: &str,
) -> AppResult<()> {
sqlx::query("INSERT OR IGNORE INTO statistics (profile_id, updated_at) VALUES (?, ?)")
.bind(profile_id)
.bind(now)
.execute(&mut **tx)
.await?;
let current_streak: i64 =
sqlx::query_scalar("SELECT win_streak FROM statistics WHERE profile_id = ?")
.bind(profile_id)
.fetch_one(&mut **tx)
.await?;
let (w, d, l, dnf) = match outcome {
"win" => (1i64, 0i64, 0i64, 0i64),
"draw" => (0, 1, 0, 0),
"dnf" => (0, 0, 0, 1),
_ => (0, 0, 1, 0),
};
let new_streak = if outcome == "win" {
current_streak + 1
} else {
0
};
sqlx::query(
"UPDATE statistics SET
matches_played = matches_played + 1,
matches_won = matches_won + ?,
matches_drawn = matches_drawn + ?,
matches_lost = matches_lost + ?,
matches_dnf = matches_dnf + ?,
goals_scored = goals_scored + ?,
goals_conceded = goals_conceded + ?,
total_coins_earned = total_coins_earned + ?,
win_streak = ?,
best_win_streak = MAX(best_win_streak, ?),
updated_at = ?
WHERE profile_id = ?",
)
.bind(w)
.bind(d)
.bind(l)
.bind(dnf)
.bind(goals_for)
.bind(goals_against)
.bind(coins)
.bind(new_streak)
.bind(new_streak)
.bind(now)
.bind(profile_id)
.execute(&mut **tx)
.await?;
Ok(())
}
pub async fn increment_packs_opened(pool: &Pool, profile_id: &str) -> AppResult<()> {
get_or_create(pool, profile_id).await?;
let now = chrono::Utc::now().to_rfc3339();
@@ -121,6 +193,26 @@ pub async fn record_position_goals(
Ok(())
}
/// Transaction-scoped [`record_position_goals`] for the atomic match-completion
/// path.
pub async fn record_position_goals_tx(
tx: &mut Transaction<'_, Sqlite>,
profile_id: &str,
positions: &[String],
) -> AppResult<()> {
for position in positions {
sqlx::query(
"INSERT INTO position_goals (profile_id, position, goals) VALUES (?, ?, 1) \
ON CONFLICT(profile_id, position) DO UPDATE SET goals = goals + 1",
)
.bind(profile_id)
.bind(position)
.execute(&mut **tx)
.await?;
}
Ok(())
}
pub async fn get_position_goals(pool: &Pool, profile_id: &str) -> AppResult<Vec<(String, i64)>> {
let rows: Vec<(String, i64)> = sqlx::query_as(
"SELECT position, goals FROM position_goals WHERE profile_id = ? ORDER BY goals DESC",
+9 -16
View File
@@ -1,29 +1,22 @@
use crate::{
db::Pool,
error::{AppError, AppResult},
models::card::OwnedCard,
models::card::{OwnedCard, OWNED_CARD_SELECT},
models::chemistry_style::ChemistryStyle,
};
const OWNED_CARD_SELECT: &str =
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
chemistry_style, position_override, training_bonus \
FROM owned_cards";
pub const MAX_TRAINING_BONUS: i64 = 3;
/// Cost in coins to change a player's position.
pub const POSITION_CHANGE_COST: i64 = 500;
async fn fetch_owned(pool: &Pool, owned_card_id: &str, club_id: &str) -> AppResult<OwnedCard> {
sqlx::query_as::<_, OwnedCard>(&format!(
"{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"
))
.bind(owned_card_id)
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound("owned card not found".into()))
sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"))
.bind(owned_card_id)
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound("owned card not found".into()))
}
/// Apply a chemistry style to an owned card.
@@ -64,8 +57,8 @@ pub async fn change_position(
new_position: &str,
) -> AppResult<OwnedCard> {
let valid_positions = [
"GK", "RB", "LB", "CB", "RWB", "LWB", "CDM", "CM", "CAM", "RM", "LM", "RW", "LW",
"CF", "ST",
"GK", "RB", "LB", "CB", "RWB", "LWB", "CDM", "CM", "CAM", "RM", "LM", "RW", "LW", "CF",
"ST",
];
if !valid_positions.contains(&new_position) {
return Err(AppError::BadRequest(format!(
+5
View File
@@ -2,6 +2,7 @@
//! the Core-level half of the migration mutation battery: each hostile input is
//! rejected BEFORE any partial write, and re-runs converge instead of duplicating.
use openfut_core::models::card::ContentKind;
use openfut_core::services::card_db::CardDb;
use openfut_core::services::import::{
apply_profile_import, ImportClub, ImportEntitlement, ImportExtension, ImportOwnedCard,
@@ -34,6 +35,8 @@ fn owned(ids: &[String]) -> Vec<ImportOwnedCard> {
.map(|(i, id)| ImportOwnedCard {
owned_item_id: format!("oc-{i}"),
card_id: id.clone(),
content_kind: ContentKind::Player,
quantity: None,
})
.collect()
}
@@ -208,6 +211,8 @@ async fn missing_definition_fails_preflight_with_no_writes() {
ow.push(ImportOwnedCard {
owned_item_id: "oc-bad".into(),
card_id: "fifa17_definitely_absent_999999".into(),
content_kind: ContentKind::Player,
quantity: None,
});
let err = apply_profile_import(&pool, &db, &request("g_miss", "fp", ow, None))
.await
+413 -41
View File
@@ -78,6 +78,199 @@ async fn json_post(app: &axum::Router, uri: &str, payload: Value) -> (StatusCode
(status, serde_json::from_slice(&body).unwrap())
}
/// Submit ONE match through the authoritative exactly-once route.
///
/// `POST /matches/result` was removed as an economy path: it had no transaction
/// and no idempotency key. `/matches/complete` requires a caller-supplied
/// `match_identity`, so every call here mints a fresh one — each call is a
/// distinct match, which is what these tests mean. `expire_loans` and
/// `advance_season` are opted in to keep the Core-mode behaviour the old route
/// used to trigger implicitly.
///
/// Takes the legacy payload shape and derives the canonical `result` from the
/// scoreline, so call sites read the same as the match they describe.
async fn post_match(app: &axum::Router, payload: Value) -> (StatusCode, Value) {
static NEXT_MATCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
let n = NEXT_MATCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let goals_for = payload["goals_for"].as_i64().unwrap_or(0);
let goals_against = payload["goals_against"].as_i64().unwrap_or(0);
let result = match goals_for.cmp(&goals_against) {
std::cmp::Ordering::Greater => "win",
std::cmp::Ordering::Equal => "draw",
std::cmp::Ordering::Less => "loss",
};
let mut body = payload;
body["match_identity"] = serde_json::json!(format!("test-match-{n}"));
body["result"] = serde_json::json!(result);
body["expire_loans"] = serde_json::json!(true);
body["advance_season"] = serde_json::json!(true);
json_post(app, "/matches/complete", body).await
}
// ── Legacy match-result path is closed ───────────────────────────────────────
/// `POST /matches/result` used to be a SECOND economy authority: a dozen writes
/// with no transaction and no idempotency key, so it re-credited the same match
/// on every call. It must now reject and grant nothing, pointing callers at the
/// exactly-once route.
#[tokio::test]
async fn legacy_match_result_route_rejects_and_grants_nothing() {
let app = build_test_app().await;
auth(&app, "LegacyGuard").await;
let (_, before) = json_get(&app, "/club").await;
let coins_before = before["coins"].as_i64().expect("coins");
let (status, body) = json_post(
&app,
"/matches/result",
serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
}),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
assert!(
body["error"]
.as_str()
.unwrap_or_default()
.contains("/matches/complete"),
"the rejection must name the route that replaced it: {body}"
);
let (_, after) = json_get(&app, "/club").await;
assert_eq!(
after["coins"].as_i64().expect("coins"),
coins_before,
"a rejected legacy submit must not move the balance"
);
let (_, history) = json_get(&app, "/matches").await;
assert!(
history["matches"].as_array().is_none_or(|m| m.is_empty()),
"a rejected legacy submit must not write match history: {history}"
);
}
/// The behaviour the legacy route used to trigger implicitly is still reachable,
/// but only when the caller opts in — a game with its own loan and season models
/// (FIFA 17) must not have Core's advance behind its back, because Core's
/// season end GRANTS coins.
#[tokio::test]
async fn core_progression_is_opt_in_on_the_authoritative_route() {
let app = build_test_app().await;
auth(&app, "OptIn").await;
// Default: no opt-in fields at all.
let (status, body) = json_post(
&app,
"/matches/complete",
serde_json::json!({
"match_identity": "opt-in-off", "result": "win",
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
}),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["applied"], true);
assert_eq!(
body["season_end"],
serde_json::Value::Null,
"Core's season must not advance unless asked"
);
let (_, division) = json_get(&app, "/division").await;
assert_eq!(
division["matches_played"], 0,
"no opt-in means Core's season model saw no match"
);
// Opting in advances it.
let (status, body) = json_post(
&app,
"/matches/complete",
serde_json::json!({
"match_identity": "opt-in-on", "result": "win",
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 1, "goals_against": 0, "mode": "squad_battles",
"advance_season": true
}),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
let (_, division) = json_get(&app, "/division").await;
assert_eq!(division["matches_played"], 1);
}
/// A replay must not re-notify. The pooled path this replaced emitted a fresh
/// notification every time it was called, because it had no replay concept.
#[tokio::test]
async fn replayed_completion_does_not_duplicate_notifications() {
let app = build_test_app().await;
auth(&app, "ReplayNotify").await;
let submit = || {
json_post(
&app,
"/matches/complete",
serde_json::json!({
"match_identity": "same-match", "result": "win",
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
}),
)
};
let (status, first) = submit().await;
assert_eq!(status, StatusCode::OK, "{first}");
assert_eq!(first["applied"], true);
let (_, notifications) = json_get(&app, "/notifications").await;
let after_first = notifications["notifications"].as_array().unwrap().len();
let (status, second) = submit().await;
assert_eq!(status, StatusCode::OK, "{second}");
assert_eq!(second["applied"], false, "replay must not re-apply");
let (_, notifications) = json_get(&app, "/notifications").await;
assert_eq!(
notifications["notifications"].as_array().unwrap().len(),
after_first,
"a replay must not emit a second set of notifications"
);
}
/// A profile that completed a match through the authoritative route must still
/// be resettable: `match_completions` holds un-cascaded foreign keys to both
/// `matches` and `profiles`.
#[tokio::test]
async fn reset_clears_a_profile_that_completed_matches() {
let app = build_test_app().await;
auth(&app, "ResetMe").await;
let (status, body) = json_post(
&app,
"/matches/complete",
serde_json::json!({
"match_identity": "reset-match", "result": "win",
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 2, "goals_against": 0, "mode": "squad_battles"
}),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
let (status, body) = json_post(
&app,
"/auth/reset",
serde_json::json!({ "confirm": "reset" }),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
let (status, _) = json_get(&app, "/profile").await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
// ── Existing tests ───────────────────────────────────────────────────────────
#[tokio::test]
@@ -125,7 +318,7 @@ async fn test_match_result_awards_coins() {
"goals_against": 1,
"mode": "squad_battles"
});
let (status, json) = json_post(&app, "/matches/result", payload).await;
let (status, json) = post_match(&app, payload).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["match_record"]["outcome"], "win");
assert!(json["coins_awarded"].as_i64().unwrap() > 0);
@@ -154,7 +347,7 @@ async fn test_match_with_goal_positions_tracks_stats() {
"mode": "squad_battles",
"goal_positions": ["ST", "ST", "CAM"]
});
let (status, _) = json_post(&app, "/matches/result", payload).await;
let (status, _) = post_match(&app, payload).await;
assert_eq!(status, StatusCode::OK);
let (status, stats) = json_get(&app, "/statistics").await;
@@ -294,7 +487,12 @@ async fn test_sbc_rejects_duplicate_cards() {
.as_str()
.unwrap()
.to_string();
let (s, _) = json_post(&app, &format!("/packs/open/{pack_id}"), serde_json::json!({})).await;
let (s, _) = json_post(
&app,
&format!("/packs/open/{pack_id}"),
serde_json::json!({}),
)
.await;
assert_eq!(s, StatusCode::OK);
let (_, coll) = json_get(&app, "/collection").await;
@@ -312,9 +510,16 @@ async fn test_sbc_rejects_duplicate_cards() {
}),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST, "duplicate submission must be rejected: {result}");
assert_eq!(
s,
StatusCode::BAD_REQUEST,
"duplicate submission must be rejected: {result}"
);
assert!(
result["error"].as_str().unwrap_or_default().contains("duplicate"),
result["error"]
.as_str()
.unwrap_or_default()
.contains("duplicate"),
"expected a duplicate-card error, got: {result}"
);
}
@@ -412,9 +617,8 @@ async fn test_win_streak_tracking() {
auth(&app, "StreakPlayer").await;
for _ in 0..3 {
let (s, _) = json_post(
let (s, _) = post_match(
&app,
"/matches/result",
serde_json::json!({
"squad_id": "dummy",
"opponent_name": "Bot",
@@ -968,9 +1172,8 @@ async fn test_division_updates_after_wins() {
// Play 3 wins
for _ in 0..3 {
json_post(
post_match(
&app,
"/matches/result",
serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 2, "goals_against": 0, "mode": "squad_battles"
@@ -992,9 +1195,8 @@ async fn test_season_ends_after_10_matches_and_promotes() {
// Win all 10 matches of the season
for _ in 0..10 {
let (s, result) = json_post(
let (s, result) = post_match(
&app,
"/matches/result",
serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
@@ -1099,9 +1301,8 @@ async fn test_notifications_include_completed_objectives() {
auth(&app, "ObjNotifPlayer").await;
// Play enough matches to complete the "daily_play_1_match" objective
json_post(
post_match(
&app,
"/matches/result",
serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
@@ -1124,9 +1325,8 @@ async fn test_match_result_returns_season_info() {
let app = build_test_app().await;
auth(&app, "SeasonMatchPlayer").await;
let (s, result) = json_post(
let (s, result) = post_match(
&app,
"/matches/result",
serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 2, "goals_against": 1, "mode": "squad_battles"
@@ -1567,9 +1767,8 @@ async fn test_rivals_weekly_reward_claim() {
auth(&app, "RivalsClaimPlayer").await;
// Play a match to create the season row
json_post(
post_match(
&app,
"/matches/result",
serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
@@ -1589,9 +1788,8 @@ async fn test_rivals_reward_increments_week_counter() {
let app = build_test_app().await;
auth(&app, "RivalsWeekCounter").await;
json_post(
post_match(
&app,
"/matches/result",
serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
@@ -1718,9 +1916,8 @@ async fn test_match_result_includes_level_ups_on_first_win() {
// With enough wins we cross the 500 XP threshold (level 2).
let mut level_ups_seen = false;
for _ in 0..5 {
let (status, json) = json_post(
let (status, json) = post_match(
&app,
"/matches/result",
serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
@@ -1779,9 +1976,8 @@ async fn test_level_up_creates_persistent_notification() {
// Play several wins to guarantee crossing the 500 XP threshold (level 2)
for _ in 0..5 {
json_post(
post_match(
&app,
"/matches/result",
serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
@@ -1807,9 +2003,8 @@ async fn test_mark_all_notifications_read() {
// Generate a notification via level-up
for _ in 0..5 {
json_post(
post_match(
&app,
"/matches/result",
serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
@@ -1831,9 +2026,8 @@ async fn test_mark_single_notification_read() {
// Generate level-up notifications
for _ in 0..5 {
json_post(
post_match(
&app,
"/matches/result",
serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
@@ -1889,9 +2083,8 @@ async fn test_first_match_achievement_unlocks() {
let app = build_test_app().await;
auth(&app, "FirstMatchAchPlayer").await;
let (_, result) = json_post(
let (_, result) = post_match(
&app,
"/matches/result",
serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
@@ -1912,9 +2105,8 @@ async fn test_first_win_achievement_unlocks_on_win() {
let app = build_test_app().await;
auth(&app, "FirstWinAchPlayer").await;
let (_, result) = json_post(
let (_, result) = post_match(
&app,
"/matches/result",
serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 2, "goals_against": 0, "mode": "squad_battles"
@@ -1936,9 +2128,8 @@ async fn test_achievements_not_duplicated_on_second_match() {
auth(&app, "NoDupAchPlayer").await;
// First match — first_match unlocks
json_post(
post_match(
&app,
"/matches/result",
serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
@@ -1947,9 +2138,8 @@ async fn test_achievements_not_duplicated_on_second_match() {
.await;
// Second match — first_match must NOT appear again
let (_, result) = json_post(
let (_, result) = post_match(
&app,
"/matches/result",
serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
@@ -1971,9 +2161,8 @@ async fn test_achievement_grants_coins() {
let coins_before = club_before["coins"].as_i64().unwrap_or(0);
// first_match achievement grants 500 coins on top of match reward
json_post(
post_match(
&app,
"/matches/result",
serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
@@ -2015,9 +2204,8 @@ async fn test_auth_reset_clears_profile() {
auth(&app, "ResetPlayer").await;
// Play a match to generate some state
json_post(
post_match(
&app,
"/matches/result",
serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
@@ -2090,9 +2278,8 @@ async fn test_division_history_records_after_season_end() {
// Win all 10 matches to complete and promote
for _ in 0..10 {
json_post(
post_match(
&app,
"/matches/result",
serde_json::json!({
"squad_id": "dummy", "opponent_name": "Bot",
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
@@ -3191,3 +3378,188 @@ async fn test_economy_settle_sale_route_rejects_self_dealing() {
Some(SELLER_CLUB)
);
}
// ── generic active club-item designations + collection taxonomy ───────────────
/// Insert one owned instance of a given content kind directly, since there is no
/// route that grants a kit/badge/ball/stadium yet (the game adapter/import does).
async fn seed_owned_kind(
pool: &sqlx::SqlitePool,
id: &str,
club_id: &str,
card_id: &str,
kind: &str,
) {
sqlx::query(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at, content_kind) \
VALUES (?, ?, ?, 0, '2026-01-01T00:00:00Z', ?)",
)
.bind(id)
.bind(club_id)
.bind(card_id)
.bind(kind)
.execute(pool)
.await
.expect("seed owned item");
}
async fn club_id_of(pool: &sqlx::SqlitePool) -> String {
sqlx::query_scalar::<_, String>("SELECT id FROM clubs LIMIT 1")
.fetch_one(pool)
.await
.unwrap()
}
#[tokio::test]
async fn test_active_items_get_returns_every_slot_explicitly() {
let (app, _pool) = build_test_app_with_pool().await;
auth(&app, "CAGE").await;
let (s, j) = json_get(&app, "/club/active-items").await;
assert_eq!(s, StatusCode::OK, "{j}");
for slot in ["home_kit", "away_kit", "badge", "ball", "stadium"] {
assert!(
j["active_items"][slot].is_null(),
"slot {slot} must be present and null on a fresh club: {j}"
);
}
}
#[tokio::test]
async fn test_active_items_put_set_and_clear_roundtrip() {
let (app, pool) = build_test_app_with_pool().await;
auth(&app, "CAGE").await;
let club = club_id_of(&pool).await;
// A real definition id keeps the collection projection honest; the kind is
// what the designation validates against.
seed_owned_kind(&pool, "kit-1", &club, "card_bronze_001", "kit").await;
seed_owned_kind(&pool, "badge-1", &club, "card_bronze_002", "badge").await;
let (s, j) = json_put(
&app,
"/club/active-items",
serde_json::json!({ "slot": "home_kit", "owned_card_id": "kit-1" }),
)
.await;
assert_eq!(s, StatusCode::OK, "{j}");
assert_eq!(j["active_items"]["home_kit"]["id"], "kit-1");
assert_eq!(j["active_items"]["home_kit"]["content_kind"], "kit");
assert!(j["active_items"]["badge"].is_null());
let (s, j) = json_put(
&app,
"/club/active-items",
serde_json::json!({ "slot": "badge", "owned_card_id": "badge-1" }),
)
.await;
assert_eq!(s, StatusCode::OK, "{j}");
assert_eq!(j["active_items"]["badge"]["id"], "badge-1");
assert_eq!(j["active_items"]["home_kit"]["id"], "kit-1");
// A null owned_card_id clears just that slot.
let (s, j) = json_put(
&app,
"/club/active-items",
serde_json::json!({ "slot": "home_kit", "owned_card_id": null }),
)
.await;
assert_eq!(s, StatusCode::OK, "{j}");
assert!(j["active_items"]["home_kit"].is_null());
assert_eq!(j["active_items"]["badge"]["id"], "badge-1");
// The designation is durable, not per-response.
let (_, j) = json_get(&app, "/club/active-items").await;
assert_eq!(j["active_items"]["badge"]["id"], "badge-1");
}
#[tokio::test]
async fn test_active_items_put_rejects_kind_and_ownership_violations() {
let (app, pool) = build_test_app_with_pool().await;
auth(&app, "CAGE").await;
let club = club_id_of(&pool).await;
seed_owned_kind(&pool, "badge-1", &club, "card_bronze_002", "badge").await;
// A badge is not a kit.
let (s, _) = json_put(
&app,
"/club/active-items",
serde_json::json!({ "slot": "home_kit", "owned_card_id": "badge-1" }),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
// An item the club does not own.
let (s, _) = json_put(
&app,
"/club/active-items",
serde_json::json!({ "slot": "badge", "owned_card_id": "nope" }),
)
.await;
assert_eq!(s, StatusCode::NOT_FOUND);
// A slot outside the recovered equipped-state vocabulary.
let (s, _) = json_put(
&app,
"/club/active-items",
serde_json::json!({ "slot": "league_logo", "owned_card_id": "badge-1" }),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let (_, j) = json_get(&app, "/club/active-items").await;
assert!(j["active_items"]["home_kit"].is_null());
assert!(j["active_items"]["badge"].is_null());
}
#[tokio::test]
async fn test_collection_carries_content_kind_and_filters_on_it() {
let (app, pool) = build_test_app_with_pool().await;
auth(&app, "CAGE").await;
let club = club_id_of(&pool).await;
seed_owned_kind(&pool, "kit-1", &club, "card_bronze_001", "kit").await;
seed_owned_kind(&pool, "player-1", &club, "card_bronze_002", "player").await;
let (s, j) = json_get(&app, "/collection").await;
assert_eq!(s, StatusCode::OK, "{j}");
let kinds: Vec<&str> = j["collection"]
.as_array()
.unwrap()
.iter()
.map(|c| c["content_kind"].as_str().expect("content_kind present"))
.collect();
assert!(kinds.contains(&"kit"), "kinds: {kinds:?}");
assert!(kinds.contains(&"player"), "kinds: {kinds:?}");
let (_, only_kits) = json_get(&app, "/collection?content_kind=kit").await;
assert_eq!(only_kits["total"], 1);
assert_eq!(only_kits["collection"][0]["owned_card_id"], "kit-1");
let (_, none) = json_get(&app, "/collection?content_kind=stadium").await;
assert_eq!(none["total"], 0);
}
#[tokio::test]
async fn test_collection_reports_owned_rows_it_cannot_project() {
let (app, pool) = build_test_app_with_pool().await;
auth(&app, "CAGE").await;
let club = club_id_of(&pool).await;
// An owned row whose definition is NOT in loaded content: it cannot be
// projected, but it must be counted and named, never silently dropped.
seed_owned_kind(&pool, "ghost", &club, "definitely_absent_999", "consumable").await;
let (s, j) = json_get(&app, "/collection").await;
assert_eq!(s, StatusCode::OK, "a missing definition must not 500: {j}");
assert_eq!(j["unresolved_items"], 1);
assert_eq!(j["unresolved_definitions"][0], "definitely_absent_999");
assert_eq!(
j["owned_rows"].as_i64().unwrap(),
j["total"].as_i64().unwrap() + 1,
"owned_rows is ownership truth, total is what could be projected: {j}"
);
let ids: Vec<&str> = j["collection"]
.as_array()
.unwrap()
.iter()
.map(|c| c["owned_card_id"].as_str().unwrap())
.collect();
assert!(!ids.contains(&"ghost"));
}
+338
View File
@@ -0,0 +1,338 @@
//! Owned-content model migrations (0025 content_kind/quantity, 0026
//! club_active_items, 0027 consumable_applications).
//!
//! Two things must hold on a DB that already contains real ownership:
//! * every pre-existing owned row survives and reads back as a `player` with no
//! stack size (the migration is a pure widening, not a rewrite);
//! * every existing kit designation lands in `club_active_items` under its
//! generalised slot token, and the old table + trigger are gone.
//!
//! The first is proved against a COPY of a real populated club snapshot (1986
//! owned rows) when `OPENFUT_CORE_SNAPSHOT_DB` points at one; the second is
//! proved by staging a DB at migration 0025, writing 0024-era kit rows, and then
//! letting the remaining migrations run.
use std::borrow::Cow;
use openfut_core::models::card::{ActiveSlot, ContentKind};
use sqlx::migrate::Migrator;
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::{Row, SqlitePool};
const OWNED_CONTENT_MIGRATION: i64 = 25;
async fn pool_for(path: &std::path::Path) -> SqlitePool {
let opts = sqlx::sqlite::SqliteConnectOptions::new()
.filename(path)
.create_if_missing(true)
.foreign_keys(true);
SqlitePoolOptions::new()
.max_connections(1)
.connect_with(opts)
.await
.unwrap_or_else(|e| panic!("open {}: {e}", path.display()))
}
/// The full migrator, truncated after `version`. Used to stage a DB in the state
/// it had BEFORE the migrations under test, so their data carry-over is exercised
/// on rows that really pre-date them.
fn migrator_upto(version: i64) -> Migrator {
let full = sqlx::migrate!("./migrations");
let subset: Vec<_> = full
.iter()
.filter(|m| m.version < version)
.cloned()
.collect();
Migrator {
migrations: Cow::Owned(subset),
ignore_missing: true,
locking: true,
}
}
async fn table_exists(pool: &SqlitePool, name: &str) -> bool {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?")
.bind(name)
.fetch_one(pool)
.await
.unwrap()
> 0
}
async fn trigger_names(pool: &SqlitePool) -> Vec<String> {
sqlx::query_scalar::<_, String>(
"SELECT name FROM sqlite_master WHERE type='trigger' ORDER BY name",
)
.fetch_all(pool)
.await
.unwrap()
}
/// Stage a DB at pre-0025 state with two 0024-era kit designations, then run the
/// rest of the migrations: the designations MUST be carried over, not dropped.
#[tokio::test]
async fn kit_assignments_migrate_into_club_active_items() {
let dir = tempfile::tempdir().expect("tempdir");
let db = dir.path().join("staged.db");
let pool = pool_for(&db).await;
migrator_upto(OWNED_CONTENT_MIGRATION)
.run(&pool)
.await
.expect("migrate to pre-0025");
assert!(
table_exists(&pool, "club_kit_assignments").await,
"staging must actually be at the 0024 schema"
);
let ts = "2026-01-01T00:00:00Z";
sqlx::query("INSERT INTO profiles (id, username, created_at, updated_at) VALUES ('p','p',?,?)")
.bind(ts)
.bind(ts)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) \
VALUES ('c','p','c',0,?,?)",
)
.bind(ts)
.bind(ts)
.execute(&pool)
.await
.unwrap();
for id in ["kit-h", "kit-a", "spare"] {
sqlx::query(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) \
VALUES (?, 'c', ?, 0, ?)",
)
.bind(id)
.bind(format!("def-{id}"))
.bind(ts)
.execute(&pool)
.await
.unwrap();
}
for (slot, owned) in [("home", "kit-h"), ("away", "kit-a")] {
sqlx::query(
"INSERT INTO club_kit_assignments (club_id, slot, owned_card_id, updated_at) \
VALUES ('c', ?, ?, ?)",
)
.bind(slot)
.bind(owned)
.bind(ts)
.execute(&pool)
.await
.unwrap();
}
// Now the migrations under test.
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrate to head");
assert!(
!table_exists(&pool, "club_kit_assignments").await,
"the old kit table must be gone"
);
assert!(table_exists(&pool, "club_active_items").await);
assert!(table_exists(&pool, "consumable_applications").await);
let rows =
sqlx::query("SELECT slot, owned_card_id, updated_at FROM club_active_items ORDER BY slot")
.fetch_all(&pool)
.await
.unwrap();
let carried: Vec<(String, String, String)> = rows
.iter()
.map(|r| (r.get(0), r.get(1), r.get(2)))
.collect();
assert_eq!(
carried,
vec![
(
ActiveSlot::AwayKit.as_str().into(),
"kit-a".to_string(),
ts.to_string()
),
(
ActiveSlot::HomeKit.as_str().into(),
"kit-h".to_string(),
ts.to_string()
),
],
"home -> home_kit, away -> away_kit, timestamps preserved"
);
// 0024's trigger is replaced, never merely orphaned: an ownership transfer
// must still clear the designation (and must not fail on a missing table).
let names = trigger_names(&pool).await;
assert!(
!names.contains(&"clear_club_kit_assignment_before_transfer".to_string()),
"the old trigger must be dropped, got {names:?}"
);
assert!(
names.contains(&"clear_club_active_item_before_transfer".to_string()),
"the generalised trigger must exist, got {names:?}"
);
sqlx::query(
"INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) \
VALUES ('c2','p','c2',0,?,?)",
)
.bind(ts)
.bind(ts)
.execute(&pool)
.await
.unwrap();
sqlx::query("UPDATE owned_cards SET club_id = 'c2' WHERE id = 'kit-h'")
.execute(&pool)
.await
.expect("transfer must succeed after the trigger swap");
let remaining =
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM club_active_items WHERE club_id='c'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(remaining, 1, "the transferred kit's designation is cleared");
// Backfilled ownership reads back as the default kind with no stack size.
let (kind, quantity) = sqlx::query_as::<_, (ContentKind, Option<i64>)>(
"SELECT content_kind, quantity FROM owned_cards WHERE id = 'spare'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(kind, ContentKind::Player);
assert_eq!(quantity, None);
// And the new column constraints are real, not documentation.
assert!(
sqlx::query("UPDATE owned_cards SET content_kind = 'coach' WHERE id = 'spare'")
.execute(&pool)
.await
.is_err(),
"content_kind CHECK must reject a token outside the vocabulary"
);
assert!(
sqlx::query("UPDATE owned_cards SET quantity = 0 WHERE id = 'spare'")
.execute(&pool)
.await
.is_err(),
"quantity CHECK must reject a non-positive stack"
);
assert!(
sqlx::query(
"INSERT INTO club_active_items (club_id, slot, owned_card_id, updated_at) \
VALUES ('c', 'league_logo', 'spare', ?)"
)
.bind(ts)
.execute(&pool)
.await
.is_err(),
"slot CHECK must reject a token outside the recovered equipped-state set"
);
drop(dir);
}
/// The migrations must apply cleanly to a COPY of a REAL populated club DB,
/// leave every owned row intact, and carry a real kit designation over.
///
/// The snapshot predates migration 0024, so the copy is first brought up to the
/// 0024 schema and given two kit designations pointing at REAL owned instances;
/// only then do the migrations under test run. That way the carry-over is proved
/// on production ownership, not on synthetic rows.
///
/// Point `OPENFUT_CORE_SNAPSHOT_DB` at a real `core.db` to run it; without that
/// the test reports the skip rather than passing silently on nothing.
#[tokio::test]
async fn migrations_apply_to_a_real_populated_snapshot() {
let Ok(source) = std::env::var("OPENFUT_CORE_SNAPSHOT_DB") else {
eprintln!(
"SKIPPED migrations_apply_to_a_real_populated_snapshot: set \
OPENFUT_CORE_SNAPSHOT_DB=/path/to/core.db to run it"
);
return;
};
let dir = tempfile::tempdir().expect("tempdir");
let copy = dir.path().join("core.db");
// Copy, never open the source: the snapshot is read-only evidence.
std::fs::copy(&source, &copy).unwrap_or_else(|e| panic!("copy {source}: {e}"));
let pool = pool_for(&copy).await;
let before = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards")
.fetch_one(&pool)
.await
.expect("snapshot must already hold ownership");
assert!(
before > 0,
"the snapshot must be populated to prove anything"
);
// Bring the copy to the 0024 schema and designate two REAL owned instances
// as this club's kits, exactly as the pre-generalisation server would have.
migrator_upto(OWNED_CONTENT_MIGRATION)
.run(&pool)
.await
.expect("migrate the snapshot to pre-0025");
let real: Vec<(String, String)> =
sqlx::query_as("SELECT id, club_id FROM owned_cards ORDER BY id LIMIT 2")
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(real.len(), 2, "need two real owned instances");
let ts = "2026-01-01T00:00:00Z";
for (slot, (owned_id, club_id)) in ["home", "away"].into_iter().zip(&real) {
sqlx::query(
"INSERT INTO club_kit_assignments (club_id, slot, owned_card_id, updated_at) \
VALUES (?, ?, ?, ?)",
)
.bind(club_id)
.bind(slot)
.bind(owned_id)
.bind(ts)
.execute(&pool)
.await
.expect("stage a real kit designation");
}
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrations must apply to real populated data");
let (after, players, stacked) = sqlx::query_as::<_, (i64, i64, i64)>(
"SELECT COUNT(*), \
SUM(CASE WHEN content_kind = 'player' THEN 1 ELSE 0 END), \
SUM(CASE WHEN quantity IS NOT NULL THEN 1 ELSE 0 END) \
FROM owned_cards",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(after, before, "no owned row may be lost or duplicated");
assert_eq!(players, before, "every backfilled row is a player");
assert_eq!(stacked, 0, "no pre-existing row gains a stack size");
assert!(table_exists(&pool, "club_active_items").await);
assert!(!table_exists(&pool, "club_kit_assignments").await);
assert!(table_exists(&pool, "consumable_applications").await);
let carried: Vec<(String, String)> =
sqlx::query_as("SELECT slot, owned_card_id FROM club_active_items ORDER BY slot")
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(
carried,
vec![
(ActiveSlot::AwayKit.as_str().into(), real[1].0.clone()),
(ActiveSlot::HomeKit.as_str().into(), real[0].0.clone()),
],
"real kit designations must land in club_active_items"
);
eprintln!(
"snapshot: {after} owned rows survive as content_kind='player'; \
designations carried over: {carried:?}"
);
drop(dir);
}