Compare commits
28 Commits
d32dc6e3ae
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 20e281e0cf | |||
| 8819cc76a1 | |||
| 9bdc1633a0 | |||
| 1df03d4287 | |||
| 90210702c3 | |||
| a45155e0c5 | |||
| 82c3d2c85a | |||
| e8be289660 | |||
| 233df1d99d | |||
| 30fae1a2f9 | |||
| c896545cf0 | |||
| 36bc594924 | |||
| 8b1081019f | |||
| bae0a2bdaa | |||
| f0550e2ae1 | |||
| 2fb835200f | |||
| 5f9f556af8 | |||
| 9036f5f411 | |||
| b0306a9b1d | |||
| a034e74c16 | |||
| 271c3639ed | |||
| 637a21eac1 | |||
| 31ab4a683e | |||
| 68d10658c7 | |||
| fbb54eac95 | |||
| 75b183077f | |||
| 0360135322 | |||
| bcc4f5104a |
@@ -37,3 +37,4 @@ axum-macros = "0.4"
|
|||||||
axum-test = "14"
|
axum-test = "14"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tower = { version = "0.5", features = ["util"] }
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
tempfile = "3"
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ DATABASE_URL=sqlite://./myclub.db LISTEN_ADDR=127.0.0.1:8080 ./target/release/op
|
|||||||
| `GET` | `/squad` | Get active squad |
|
| `GET` | `/squad` | Get active squad |
|
||||||
| `POST` | `/squad` | Save squad |
|
| `POST` | `/squad` | Save squad |
|
||||||
| `GET` | `/objectives` | List objectives with progress |
|
| `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 |
|
| `GET` | `/sbc` | List SBC definitions |
|
||||||
| `POST` | `/sbc/submit` | Submit SBC solution |
|
| `POST` | `/sbc/submit` | Submit SBC solution |
|
||||||
| `GET` | `/market` | Browse NPC transfer market |
|
| `GET` | `/market` | Browse NPC transfer market |
|
||||||
@@ -95,10 +95,11 @@ curl http://localhost:8080/club
|
|||||||
# Open your starter pack
|
# Open your starter pack
|
||||||
curl -X POST http://localhost:8080/packs/open/<pack_id>
|
curl -X POST http://localhost:8080/packs/open/<pack_id>
|
||||||
|
|
||||||
# Submit a match win
|
# Submit a match win. `match_identity` keys exactly-once economy: resubmitting the
|
||||||
curl -X POST http://localhost:8080/matches/result \
|
# same identity echoes the first result and grants nothing twice.
|
||||||
|
curl -X POST http://localhost:8080/matches/complete \
|
||||||
-H 'Content-Type: application/json' \
|
-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
@@ -71,17 +71,33 @@ All game-content data is loaded at startup from `data/` into `Arc`-wrapped colle
|
|||||||
8. Increment pack stats + objective progress
|
8. Increment pack stats + objective progress
|
||||||
9. Return `PackOpenResult { pack_id, cards }`
|
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
|
2. Fetch profile + club
|
||||||
3. `services::match_service::process_match(...)`
|
3. `services::match_service::complete_match(...)` — everything below runs in ONE
|
||||||
4. Determine outcome (win/draw/loss), compute coins + XP
|
transaction and either commits together or rolls back whole
|
||||||
5. Insert match record
|
4. Insert the match-history row (also takes SQLite's writer lock, serializing
|
||||||
6. `club::add_coins`, `profile::add_xp`
|
overlapping completions)
|
||||||
7. `statistics::record_match`
|
5. Insert the `match_completions` guard row. `UNIQUE(profile_id, match_identity)`
|
||||||
8. `objective::increment_metric` for matches_played, matches_won, goals_scored, coins_earned
|
makes the economy exactly-once: a duplicate — sequential, concurrent, after a
|
||||||
9. Return `MatchRewardResult`
|
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
|
## Single-Profile Design
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Issue 1: sbc_submissions was created (0001_initial.sql) without a club_id column,
|
||||||
|
-- but the MY CLUB milestone query (routes/club.rs get_milestones) counts
|
||||||
|
-- SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1
|
||||||
|
-- so SQLite errored on the unknown column and the error was swallowed by
|
||||||
|
-- `.unwrap_or(0)` -> the `sbcs_completed` milestone always read 0. Add the column
|
||||||
|
-- and backfill it from the profile's club so historical submissions count.
|
||||||
|
ALTER TABLE sbc_submissions ADD COLUMN club_id TEXT;
|
||||||
|
|
||||||
|
UPDATE sbc_submissions
|
||||||
|
SET club_id = (SELECT c.id FROM clubs c WHERE c.profile_id = sbc_submissions.profile_id)
|
||||||
|
WHERE club_id IS NULL;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Durable replay and non-repeatable-completion guards for atomic SBC submissions.
|
||||||
|
-- Existing successful rows are treated as non-repeatable; if historical data already
|
||||||
|
-- violates that invariant the migration fails rather than silently discarding history.
|
||||||
|
ALTER TABLE sbc_submissions ADD COLUMN repeatable INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_sbc_nonrepeatable_completion
|
||||||
|
ON sbc_submissions(profile_id, sbc_id)
|
||||||
|
WHERE passed = 1 AND repeatable = 0;
|
||||||
|
|
||||||
|
-- submitted_card_ids is stored in canonical sorted order by the writer. This rejects
|
||||||
|
-- stale retries of the same card set even for explicitly repeatable challenges.
|
||||||
|
CREATE UNIQUE INDEX idx_sbc_submission_replay
|
||||||
|
ON sbc_submissions(profile_id, sbc_id, submitted_card_ids)
|
||||||
|
WHERE passed = 1;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Core owns durable per-profile working squads for SBC challenges. The FIFA17
|
||||||
|
-- adapter maps its numeric challenge id to the opaque generic sbc_id.
|
||||||
|
CREATE TABLE sbc_challenge_squads (
|
||||||
|
profile_id TEXT NOT NULL REFERENCES profiles(id),
|
||||||
|
sbc_id TEXT NOT NULL,
|
||||||
|
owned_card_ids TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (profile_id, sbc_id)
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Per-instance match-contract counter on an owned instance.
|
||||||
|
--
|
||||||
|
-- NULLABLE ON PURPOSE. NULL means "Core tracks no contract for this instance",
|
||||||
|
-- which is NOT the same as zero: a game whose contracts start at a pack-fresh
|
||||||
|
-- default (FIFA 17 hands out 7) must supply that default itself, so the number
|
||||||
|
-- stays in the game adapter and never becomes a Core constant. Every row that
|
||||||
|
-- pre-dates this migration therefore reads back NULL and keeps its exact prior
|
||||||
|
-- meaning — the migration is a pure widening, not a backfill.
|
||||||
|
--
|
||||||
|
-- `>= 0` only: the cap is a per-application input (the caller's game rule), not
|
||||||
|
-- a schema invariant, so the CHECK refuses the one value that is nonsense in
|
||||||
|
-- every game rather than pinning someone else's ceiling.
|
||||||
|
--
|
||||||
|
-- ALTER TABLE ADD COLUMN, NEVER a table rebuild: `owned_cards` carries the
|
||||||
|
-- `clear_club_active_item_before_transfer` trigger installed by 0026, and a
|
||||||
|
-- DROP/recreate would silently take it with it — exactly the failure 0026:44-46
|
||||||
|
-- documents for 0024's trigger.
|
||||||
|
ALTER TABLE owned_cards ADD COLUMN contract_matches INTEGER
|
||||||
|
CHECK (contract_matches IS NULL OR contract_matches >= 0);
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- Per-instance attribute training on an owned instance.
|
||||||
|
--
|
||||||
|
-- WHY A TABLE AND NOT COLUMNS. A training effect is (attribute slot, amount),
|
||||||
|
-- and a game may author one per slot. Six nullable columns would encode the
|
||||||
|
-- slot in the schema and force a migration to add a seventh; a row per slot
|
||||||
|
-- keeps the slot a value. It is also the smallest shape that lets the PRIMARY
|
||||||
|
-- KEY do the work described below.
|
||||||
|
--
|
||||||
|
-- WHY THE PRIMARY KEY IS (owned_card_id, attribute_index). Whether FIFA 17
|
||||||
|
-- REPLACES, STACKS, MERGES or REFUSES a second training on an attribute that
|
||||||
|
-- already carries one is UNKNOWN: no shipped table encodes it, and the client
|
||||||
|
-- holds no consumable-effect logic at all to reverse (no binary in the install
|
||||||
|
-- reads `fcc_trainingcards`, so effects are server-authoritative). Rather than
|
||||||
|
-- pick one of those behaviours and ship a guess as though it were recovered,
|
||||||
|
-- the key makes a second application to the SAME slot a constraint violation,
|
||||||
|
-- which the apply path turns into an explicit refusal that consumes nothing.
|
||||||
|
-- The unknown is therefore enforced by the schema instead of being papered over.
|
||||||
|
-- When the behaviour is proven, the change is a deliberate one-line relaxation
|
||||||
|
-- plus the arithmetic it implies — not an unpicking of accumulated bad state.
|
||||||
|
--
|
||||||
|
-- `attribute_index` is a slot in CORE's own six-attribute card model, in the
|
||||||
|
-- declaration order of `CardDefinition` (0 pace, 1 shooting, 2 passing,
|
||||||
|
-- 3 dribbling, 4 defending, 5 physical). It is deliberately NOT a FIFA
|
||||||
|
-- attribute name: mapping "GK speed" onto slot 4 is the FIFA 17 adapter's
|
||||||
|
-- reversed knowledge, and Core stays game-neutral by only ever indexing its own
|
||||||
|
-- model. The CHECK pins the slot to that model's width.
|
||||||
|
--
|
||||||
|
-- `amount` is bounded at 99 because it is added to an attribute whose domain is
|
||||||
|
-- 1..=99; a larger stored value could not mean anything. The tighter, per-game
|
||||||
|
-- ceiling (FIFA 17 authors only 5/10/15) is validated at apply time, where the
|
||||||
|
-- game's table is in scope, not here.
|
||||||
|
--
|
||||||
|
-- ON DELETE CASCADE is load-bearing: the pool enables `foreign_keys`
|
||||||
|
-- (`db.rs:20`), so quick-selling or otherwise destroying a trained instance
|
||||||
|
-- takes its training with it and cannot leave a row pointing at a dead item.
|
||||||
|
CREATE TABLE owned_card_training (
|
||||||
|
owned_card_id TEXT NOT NULL REFERENCES owned_cards(id) ON DELETE CASCADE,
|
||||||
|
attribute_index INTEGER NOT NULL CHECK (attribute_index BETWEEN 0 AND 5),
|
||||||
|
amount INTEGER NOT NULL CHECK (amount >= 1 AND amount <= 99),
|
||||||
|
-- The definition that granted it, kept for audit and for the eventual
|
||||||
|
-- lifecycle work; Core never interprets it.
|
||||||
|
source_card_id TEXT NOT NULL,
|
||||||
|
applied_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (owned_card_id, attribute_index)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The projection reads every effect for a set of instances on each /collection
|
||||||
|
-- call, so the lookup is by instance.
|
||||||
|
CREATE INDEX idx_owned_card_training_owned ON owned_card_training(owned_card_id);
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
-- Reshape attribute training to AT MOST ONE effect per instance, replaceable.
|
||||||
|
--
|
||||||
|
-- WHY THIS SUPERSEDES 0029'S SHAPE. 0029 keyed on (owned_card_id,
|
||||||
|
-- attribute_index) and recorded that same-slot behaviour was UNKNOWN, enforcing
|
||||||
|
-- the unknown as a refusal. That was the honest shape while the semantics were
|
||||||
|
-- unrecovered. They are now recovered, and BOTH halves of 0029's shape are
|
||||||
|
-- wrong:
|
||||||
|
--
|
||||||
|
-- * "You can only boost one attribute or all six. You can not do it with 2, 3,
|
||||||
|
-- 4 or 5 attributes." -- so two effects must never coexist on one instance,
|
||||||
|
-- which the old composite key permitted (and which staging demonstrated by
|
||||||
|
-- holding a slot-4 and a slot-1 effect at once).
|
||||||
|
-- * "When you apply a new training card to a player, he loses the improved
|
||||||
|
-- attributes of previous training cards. It does not accumulate, it
|
||||||
|
-- replaces." -- so a second apply REPLACES, it does not refuse.
|
||||||
|
--
|
||||||
|
-- Both quotes are from the contemporaneous FIFA 17-specific training guide
|
||||||
|
-- (fifauteam, published 2016-09-08), corroborated by the shipped table: each
|
||||||
|
-- family has exactly 21 rows = 7 card types x 3 levels, and the 7th type in each
|
||||||
|
-- family (subtypes 57 and 67) is the only one flagged `weightrare = 2` with
|
||||||
|
-- amounts 3/6/10, matching the documented RARE "ALL" card at +3/+6/+10.
|
||||||
|
-- DOCUMENTED, corroborated TABLE_PROVEN. It is NOT LIVE_PROVEN against EA.
|
||||||
|
--
|
||||||
|
-- 0029 is left intact rather than rewritten: it is already applied to the
|
||||||
|
-- supervised staging environment, so migration history matters there.
|
||||||
|
--
|
||||||
|
-- NEW SHAPE. One row per instance, so "one attribute or all six" is a
|
||||||
|
-- representable invariant instead of a convention:
|
||||||
|
-- attribute_index INTEGER NULL -- a slot in Core's six-attribute model, or
|
||||||
|
-- NULL meaning ALL SIX slots (the rare card).
|
||||||
|
-- The PRIMARY KEY on owned_card_id alone is what makes a second application a
|
||||||
|
-- REPLACE (delete-then-insert inside the one apply transaction) rather than an
|
||||||
|
-- accumulation.
|
||||||
|
--
|
||||||
|
-- The 1..=15 amount bound is NOT tightened here: 15 is the single-attribute
|
||||||
|
-- ceiling while the all-six card authors at most 10, and which ceiling applies
|
||||||
|
-- depends on the card family -- a per-game rule that belongs at apply time where
|
||||||
|
-- the game's table is in scope, not in the schema.
|
||||||
|
--
|
||||||
|
-- DATA CARRIED FORWARD: where an instance somehow holds several effects (only
|
||||||
|
-- reachable on staging under 0029's shape), the MOST RECENT survives, which is
|
||||||
|
-- exactly the "replaces" rule applied retroactively.
|
||||||
|
|
||||||
|
CREATE TABLE owned_card_training_new (
|
||||||
|
owned_card_id TEXT NOT NULL PRIMARY KEY REFERENCES owned_cards(id) ON DELETE CASCADE,
|
||||||
|
attribute_index INTEGER CHECK (attribute_index IS NULL OR attribute_index BETWEEN 0 AND 5),
|
||||||
|
amount INTEGER NOT NULL CHECK (amount >= 1 AND amount <= 99),
|
||||||
|
source_card_id TEXT NOT NULL,
|
||||||
|
applied_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO owned_card_training_new
|
||||||
|
(owned_card_id, attribute_index, amount, source_card_id, applied_at)
|
||||||
|
SELECT t.owned_card_id, t.attribute_index, t.amount, t.source_card_id, t.applied_at
|
||||||
|
FROM owned_card_training t
|
||||||
|
JOIN (
|
||||||
|
SELECT owned_card_id, MAX(applied_at) AS newest
|
||||||
|
FROM owned_card_training
|
||||||
|
GROUP BY owned_card_id
|
||||||
|
) pick
|
||||||
|
ON pick.owned_card_id = t.owned_card_id
|
||||||
|
AND pick.newest = t.applied_at
|
||||||
|
GROUP BY t.owned_card_id;
|
||||||
|
|
||||||
|
DROP TABLE owned_card_training;
|
||||||
|
ALTER TABLE owned_card_training_new RENAME TO owned_card_training;
|
||||||
+28
@@ -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", get(routes::club::get_checkin_status))
|
||||||
.route("/club/checkin", post(routes::club::post_checkin))
|
.route("/club/checkin", post(routes::club::post_checkin))
|
||||||
.route("/club/milestones", get(routes::club::get_milestones))
|
.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", get(routes::cards::get_cards))
|
||||||
.route("/cards/:card_id", get(routes::cards::get_card))
|
.route("/cards/:card_id", get(routes::cards::get_card))
|
||||||
.route("/collection", get(routes::cards::get_collection))
|
.route("/collection", get(routes::cards::get_collection))
|
||||||
@@ -186,6 +192,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
post(routes::economy::post_redeem_entitlement),
|
post(routes::economy::post_redeem_entitlement),
|
||||||
)
|
)
|
||||||
.route("/economy/sell-item", post(routes::economy::post_sell_item))
|
.route("/economy/sell-item", post(routes::economy::post_sell_item))
|
||||||
|
.route(
|
||||||
|
"/consumables/apply",
|
||||||
|
post(routes::consumables::post_apply_consumable),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/economy/grant-reward",
|
"/economy/grant-reward",
|
||||||
post(routes::economy::post_grant_reward),
|
post(routes::economy::post_grant_reward),
|
||||||
@@ -194,6 +204,14 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
"/economy/purchase-item",
|
"/economy/purchase-item",
|
||||||
post(routes::economy::post_purchase_item),
|
post(routes::economy::post_purchase_item),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/economy/purchase-items",
|
||||||
|
post(routes::economy::post_purchase_items),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/economy/settle-sale",
|
||||||
|
post(routes::economy::post_settle_sale),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/collection/:owned_card_id",
|
"/collection/:owned_card_id",
|
||||||
delete(routes::cards::delete_owned_card),
|
delete(routes::cards::delete_owned_card),
|
||||||
@@ -223,6 +241,7 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
.route("/squad", post(routes::squad::post_squad))
|
.route("/squad", post(routes::squad::post_squad))
|
||||||
.route("/squad/ext", get(routes::squad::get_squad_ext))
|
.route("/squad/ext", get(routes::squad::get_squad_ext))
|
||||||
.route("/squad/replace", put(routes::squad::put_squad_replace))
|
.route("/squad/replace", put(routes::squad::put_squad_replace))
|
||||||
|
.route("/squad/roles", put(routes::squad::put_squad_roles))
|
||||||
.route("/squads", get(routes::squad::get_squads))
|
.route("/squads", get(routes::squad::get_squads))
|
||||||
.route("/squads/:squad_id", get(routes::squad::get_squad_by_id))
|
.route("/squads/:squad_id", get(routes::squad::get_squad_by_id))
|
||||||
.route("/squads/:squad_id", delete(routes::squad::delete_squad))
|
.route("/squads/:squad_id", delete(routes::squad::delete_squad))
|
||||||
@@ -242,9 +261,18 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
.route("/matches", get(routes::matches::get_matches))
|
.route("/matches", get(routes::matches::get_matches))
|
||||||
.route("/matches/opponent", get(routes::matches::get_opponent))
|
.route("/matches/opponent", get(routes::matches::get_opponent))
|
||||||
.route("/matches/result", post(routes::matches::post_match_result))
|
.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", get(routes::sbc::get_sbcs))
|
||||||
|
.route("/sbc/status", get(routes::sbc::get_sbc_status))
|
||||||
.route("/sbc/submit", post(routes::sbc::post_sbc_submit))
|
.route("/sbc/submit", post(routes::sbc::post_sbc_submit))
|
||||||
.route("/sbc/:sbc_id", get(routes::sbc::get_sbc))
|
.route("/sbc/:sbc_id", get(routes::sbc::get_sbc))
|
||||||
|
.route(
|
||||||
|
"/sbc/:sbc_id/squad",
|
||||||
|
get(routes::sbc::get_sbc_squad).put(routes::sbc::put_sbc_squad),
|
||||||
|
)
|
||||||
.route("/market", get(routes::market::get_market))
|
.route("/market", get(routes::market::get_market))
|
||||||
.route("/market/buy", post(routes::market::post_market_buy))
|
.route("/market/buy", post(routes::market::post_market_buy))
|
||||||
.route("/market/sell", post(routes::market::post_market_sell))
|
.route("/market/sell", post(routes::market::post_market_sell))
|
||||||
|
|||||||
@@ -1,24 +1,40 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use sqlx::{
|
use sqlx::{
|
||||||
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
|
||||||
SqlitePool,
|
ConnectOptions, Connection, SqlitePool,
|
||||||
};
|
};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
use std::time::Duration;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
pub type Pool = SqlitePool;
|
pub type Pool = SqlitePool;
|
||||||
|
|
||||||
pub async fn init_pool(database_url: &str, max_connections: u32) -> Result<Pool> {
|
pub async fn init_pool(database_url: &str, max_connections: u32) -> Result<Pool> {
|
||||||
info!("Connecting to database: {}", database_url);
|
info!("Connecting to database: {}", database_url);
|
||||||
let opts = SqliteConnectOptions::from_str(database_url)?.create_if_missing(true);
|
// Per-connection options so EVERY pooled connection gets them: WAL for
|
||||||
|
// reader/writer concurrency, foreign keys on, and a busy_timeout so a
|
||||||
|
// transient SQLITE_BUSY under concurrent access waits-and-retries.
|
||||||
|
let opts = SqliteConnectOptions::from_str(database_url)?
|
||||||
|
.create_if_missing(true)
|
||||||
|
.journal_mode(SqliteJournalMode::Wal)
|
||||||
|
.foreign_keys(true)
|
||||||
|
.busy_timeout(Duration::from_secs(5));
|
||||||
|
// Establish WAL on the file via ONE connection BEFORE the pool opens.
|
||||||
|
// Switching a fresh DB to WAL is a one-time file-level change; letting
|
||||||
|
// several pooled connections do it concurrently at warm-up races that
|
||||||
|
// switch and can surface a spurious lock. Serialize it here so every
|
||||||
|
// pooled connection thereafter only re-asserts an already-WAL file.
|
||||||
|
{
|
||||||
|
let mut conn = opts.clone().connect().await?;
|
||||||
|
sqlx::query("PRAGMA journal_mode=WAL")
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await?;
|
||||||
|
conn.close().await?;
|
||||||
|
}
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
.max_connections(max_connections)
|
.max_connections(max_connections)
|
||||||
.connect_with(opts)
|
.connect_with(opts)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::query("PRAGMA journal_mode=WAL")
|
|
||||||
.execute(&pool)
|
|
||||||
.await?;
|
|
||||||
sqlx::query("PRAGMA foreign_keys=ON").execute(&pool).await?;
|
|
||||||
Ok(pool)
|
Ok(pool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
@@ -57,6 +57,29 @@ async fn main() -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `openfut-core reclassify <request.json> [--dry-run]` — correct the
|
||||||
|
// content_kind of already-imported owned rows. A profile import is
|
||||||
|
// once-only, so a taxonomy fix cannot arrive by re-importing; the adapter
|
||||||
|
// supplies card_id -> kind because only it can map its own taxonomy.
|
||||||
|
// Idempotent. `--dry-run` runs the same statements and rolls back, so an
|
||||||
|
// operator can see what a production run would touch before it touches it.
|
||||||
|
if std::env::args().nth(1).as_deref() == Some("reclassify") {
|
||||||
|
let path = std::env::args()
|
||||||
|
.nth(2)
|
||||||
|
.context("usage: openfut-core reclassify <request.json> [--dry-run]")?;
|
||||||
|
let dry_run = std::env::args().any(|a| a == "--dry-run");
|
||||||
|
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
|
||||||
|
db::run_migrations(&pool).await?;
|
||||||
|
let raw = std::fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("read reclassify request {path}"))?;
|
||||||
|
let mut req: openfut_core::services::import::ReclassifyRequest =
|
||||||
|
serde_json::from_str(&raw).context("parse reclassify request JSON")?;
|
||||||
|
req.dry_run |= dry_run;
|
||||||
|
let outcome = openfut_core::services::import::reclassify_owned_content(&pool, &req).await?;
|
||||||
|
println!("{}", serde_json::to_string_pretty(&outcome)?);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
info!("OpenFUT Core starting on {}", cfg.listen_addr);
|
info!("OpenFUT Core starting on {}", cfg.listen_addr);
|
||||||
|
|
||||||
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
|
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
|
||||||
|
|||||||
+263
-1
@@ -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.
|
/// A card definition loaded from JSON data files.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct CardDefinition {
|
pub struct CardDefinition {
|
||||||
@@ -72,9 +231,28 @@ pub struct CardDefinition {
|
|||||||
pub physical: u8,
|
pub physical: u8,
|
||||||
pub rarity: Rarity,
|
pub rarity: Rarity,
|
||||||
pub image_path: Option<String>,
|
pub image_path: Option<String>,
|
||||||
|
/// EA's authored definition rating for a NON-PLAYER: a staff card's `value`
|
||||||
|
/// from its shipped family table, or a consumable's own rating.
|
||||||
|
///
|
||||||
|
/// This is deliberately NOT `overall`. `overall` feeds pricing and squad
|
||||||
|
/// projection, so it stays 0 for every non-player; `source_rating` is the
|
||||||
|
/// separate authoritative number the game's own tier rules read (bronze
|
||||||
|
/// `<65`, silver `65..=74`, gold `>=75`). `None` for players, whose rating
|
||||||
|
/// IS `overall`, and `None` whenever Core tracks no authored value — a
|
||||||
|
/// caller MUST fail closed rather than substitute a tier.
|
||||||
|
///
|
||||||
|
/// MUST stay `Option`: `CardDefinition` has no `#[serde(default)]`, so a
|
||||||
|
/// required field would reject every already-shipped content pack, whereas
|
||||||
|
/// a missing `Option` deserializes to `None`.
|
||||||
|
pub source_rating: Option<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct OwnedCard {
|
pub struct OwnedCard {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -86,4 +264,88 @@ pub struct OwnedCard {
|
|||||||
pub chemistry_style: String,
|
pub chemistry_style: String,
|
||||||
pub position_override: Option<String>,
|
pub position_override: Option<String>,
|
||||||
pub training_bonus: i64,
|
pub training_bonus: i64,
|
||||||
|
pub content_kind: ContentKind,
|
||||||
|
pub quantity: Option<i64>,
|
||||||
|
/// Match-contracts remaining on this instance, or `None` when Core tracks
|
||||||
|
/// no contract for it. `None` is not zero: the pack-fresh starting value is
|
||||||
|
/// a per-game rule the caller supplies, never a Core default.
|
||||||
|
pub contract_matches: 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, contract_matches 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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+113
-16
@@ -11,14 +11,43 @@ pub enum MatchOutcome {
|
|||||||
Loss,
|
Loss,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
/// Canonical, game-independent economic result of a completed match. The game
|
||||||
pub struct SubmitMatchRequest {
|
/// adapter maps its own wire (FIFA17 `endReason`, score, …) onto this — Core
|
||||||
pub squad_id: String,
|
/// never sees a game-specific reason string.
|
||||||
pub opponent_name: String,
|
///
|
||||||
pub goals_for: i64,
|
/// * `Win` / `Draw` / `Loss` — a finished match; standard reward tiers.
|
||||||
pub goals_against: i64,
|
/// * `Dnf` — did-not-finish (abandon/quit). Economically a loss, but tallied in
|
||||||
pub mode: String,
|
/// its own statistics bucket and never in `matches_lost`.
|
||||||
pub goal_positions: Option<Vec<String>>,
|
/// * `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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
@@ -72,18 +101,86 @@ 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,
|
||||||
|
/// Owned-card instances that TOOK THE FIELD in this match, whose one-match
|
||||||
|
/// training effects it consumes.
|
||||||
|
///
|
||||||
|
/// Supplied by the caller rather than derived here, and deliberately so.
|
||||||
|
/// FIFA 17's training rule keys on the player PLAYING, and who played is
|
||||||
|
/// game-specific knowledge Core does not have: its match wire carries no
|
||||||
|
/// lineup at all (LIVE_PROVEN over 36,149 captured requests). Core must also
|
||||||
|
/// not resolve it from the squad at completion time, because the squad at
|
||||||
|
/// end is provably not the squad that started — a captured match began at
|
||||||
|
/// 20:33:20 and the next squad save landed 12 minutes later with no
|
||||||
|
/// `/match/end` in between. The adapter therefore snapshots at kickoff and
|
||||||
|
/// passes the result here.
|
||||||
|
///
|
||||||
|
/// Empty expires nothing, so a caller that cannot identify participants is
|
||||||
|
/// simply inert instead of clearing a whole club.
|
||||||
|
#[serde(default)]
|
||||||
|
pub participants: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of [`crate::services::match_service::complete_match`].
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct MatchRewardResult {
|
pub struct MatchCompletionResult {
|
||||||
pub match_record: Match,
|
/// `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 coins_awarded: i64,
|
||||||
pub xp_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>,
|
pub objectives_updated: Vec<String>,
|
||||||
/// Owned card IDs removed because the loan expired this match.
|
/// Level-ups gained from this match's XP (empty on a replay).
|
||||||
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.
|
|
||||||
pub level_ups: Vec<LevelUpEvent>,
|
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>,
|
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>,
|
||||||
|
/// Owned card instances whose one-match training effect this match consumed.
|
||||||
|
/// Empty when the caller passed no participants, and empty on a replay —
|
||||||
|
/// the effect is consumed exactly once, by the first completion.
|
||||||
|
pub expired_training: 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
@@ -1,19 +1,19 @@
|
|||||||
pub mod achievement;
|
pub mod achievement;
|
||||||
pub mod card;
|
pub mod card;
|
||||||
pub mod chemistry_style;
|
pub mod chemistry_style;
|
||||||
pub mod notification;
|
|
||||||
pub mod club;
|
pub mod club;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
|
pub mod event;
|
||||||
pub mod fut_champs;
|
pub mod fut_champs;
|
||||||
pub mod game_ext;
|
pub mod game_ext;
|
||||||
pub mod event;
|
|
||||||
pub mod season;
|
|
||||||
pub mod market;
|
pub mod market;
|
||||||
pub mod match_result;
|
pub mod match_result;
|
||||||
|
pub mod notification;
|
||||||
pub mod objective;
|
pub mod objective;
|
||||||
pub mod pack;
|
pub mod pack;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod reward;
|
pub mod reward;
|
||||||
pub mod sbc;
|
pub mod sbc;
|
||||||
|
pub mod season;
|
||||||
pub mod squad;
|
pub mod squad;
|
||||||
pub mod statistics;
|
pub mod statistics;
|
||||||
|
|||||||
+14
-2
@@ -33,16 +33,17 @@ pub struct SbcReward {
|
|||||||
pub pack_id: Option<String>,
|
pub pack_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DB record of a completed submission
|
/// DB record of a completed submission.
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct SbcSubmission {
|
pub struct SbcSubmission {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub profile_id: String,
|
pub profile_id: String,
|
||||||
|
pub club_id: Option<String>,
|
||||||
pub sbc_id: String,
|
pub sbc_id: String,
|
||||||
pub submitted_card_ids: String,
|
pub submitted_card_ids: String,
|
||||||
pub passed: bool,
|
pub passed: bool,
|
||||||
pub submitted_at: String,
|
pub submitted_at: String,
|
||||||
|
pub repeatable: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -51,6 +52,17 @@ pub struct SubmitSbcRequest {
|
|||||||
pub owned_card_ids: Vec<String>,
|
pub owned_card_ids: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SaveSbcSquadRequest {
|
||||||
|
pub owned_card_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SbcSquadState {
|
||||||
|
pub sbc_id: String,
|
||||||
|
pub owned_card_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct SbcResult {
|
pub struct SbcResult {
|
||||||
pub passed: bool,
|
pub passed: bool,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub struct Statistics {
|
|||||||
pub matches_won: i64,
|
pub matches_won: i64,
|
||||||
pub matches_drawn: i64,
|
pub matches_drawn: i64,
|
||||||
pub matches_lost: i64,
|
pub matches_lost: i64,
|
||||||
|
pub matches_dnf: i64,
|
||||||
pub goals_scored: i64,
|
pub goals_scored: i64,
|
||||||
pub goals_conceded: i64,
|
pub goals_conceded: i64,
|
||||||
pub packs_opened: i64,
|
pub packs_opened: i64,
|
||||||
@@ -25,6 +26,7 @@ impl Statistics {
|
|||||||
matches_won: 0,
|
matches_won: 0,
|
||||||
matches_drawn: 0,
|
matches_drawn: 0,
|
||||||
matches_lost: 0,
|
matches_lost: 0,
|
||||||
|
matches_dnf: 0,
|
||||||
goals_scored: 0,
|
goals_scored: 0,
|
||||||
goals_conceded: 0,
|
goals_conceded: 0,
|
||||||
packs_opened: 0,
|
packs_opened: 0,
|
||||||
|
|||||||
@@ -8,12 +8,19 @@ use crate::{
|
|||||||
services::{achievement as ach_svc, club as club_svc, profile as profile_svc},
|
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 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 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 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!({
|
Ok(Json(json!({
|
||||||
"achievements": achievements,
|
"achievements": achievements,
|
||||||
"earned": earned,
|
"earned": earned,
|
||||||
|
|||||||
+9
-1
@@ -34,7 +34,10 @@ pub async fn post_auth_local(
|
|||||||
|
|
||||||
/// GET /auth/status — lightweight check: does a profile exist?
|
/// GET /auth/status — lightweight check: does a profile exist?
|
||||||
/// Returns 200 `{ "has_profile": true/false }` without erroring.
|
/// 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 = ?")
|
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles WHERE game_id = ?")
|
||||||
.bind(game.as_str())
|
.bind(game.as_str())
|
||||||
.fetch_one(&state.pool)
|
.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 [
|
for table in [
|
||||||
|
"match_completions",
|
||||||
"fut_champs_sessions",
|
"fut_champs_sessions",
|
||||||
"sbc_submissions",
|
"sbc_submissions",
|
||||||
"objective_progress",
|
"objective_progress",
|
||||||
|
|||||||
+92
-37
@@ -9,21 +9,27 @@ use serde_json::{json, Value};
|
|||||||
use crate::{
|
use crate::{
|
||||||
app::AppState,
|
app::AppState,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::card::OwnedCard,
|
models::card::{OwnedCard, OWNED_CARD_SELECT},
|
||||||
services::{
|
services::{
|
||||||
club as club_svc,
|
club as club_svc, economy as economy_svc,
|
||||||
inventory::{self, OwnedItemQuery, OwnedItemView},
|
inventory::{self, OwnedItemQuery, OwnedItemView},
|
||||||
profile as profile_svc,
|
profile as profile_svc, training as training_svc,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Quick-sell value for a card based on overall rating.
|
/// Quick-sell value for a card based on overall rating.
|
||||||
fn quick_sell_coins(overall: u8) -> i64 {
|
fn quick_sell_coins(overall: u8) -> i64 {
|
||||||
if overall >= 85 { 1500 }
|
if overall >= 85 {
|
||||||
else if overall >= 80 { 900 }
|
1500
|
||||||
else if overall >= 75 { 600 }
|
} else if overall >= 80 {
|
||||||
else if overall >= 65 { 300 }
|
900
|
||||||
else { 150 }
|
} else if overall >= 75 {
|
||||||
|
600
|
||||||
|
} else if overall >= 65 {
|
||||||
|
300
|
||||||
|
} else {
|
||||||
|
150
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -95,7 +101,9 @@ pub async fn get_cards(
|
|||||||
cards.truncate(limit);
|
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(
|
pub async fn get_collection(
|
||||||
@@ -106,34 +114,68 @@ pub async fn get_collection(
|
|||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let owned = sqlx::query_as::<_, OwnedCard>(
|
let owned = sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} WHERE club_id = ?"))
|
||||||
"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)
|
.bind(&club.id)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let views: Vec<OwnedItemView> = owned
|
// One query for the whole club, not one per item: this projection walks
|
||||||
.iter()
|
// every owned row, and a per-item lookup here is the N+1 it has suffered
|
||||||
.filter_map(|o| {
|
// before.
|
||||||
state.card_db.get(&o.card_id).map(|def| {
|
let training = training_svc::load_for_club(&state.pool, &club.id).await?;
|
||||||
|
|
||||||
|
// 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_overall = def.overall as i64 + o.training_bonus;
|
||||||
let effective_position =
|
let effective_position = o.position_override.as_deref().unwrap_or(&def.position);
|
||||||
o.position_override.as_deref().unwrap_or(&def.position);
|
// Attribute training is per-instance state, so the finished attributes
|
||||||
|
// belong in the envelope beside the finished rating. The raw effect goes
|
||||||
|
// out too: a caller that needs to show WHICH attribute was trained
|
||||||
|
// cannot recover that by differencing against a definition it may not
|
||||||
|
// have. At most ONE effect per instance -- FIFA 17 replaces rather than
|
||||||
|
// accumulates, so this is an Option, not a list.
|
||||||
|
let effect = training.get(&o.id);
|
||||||
let body = json!({
|
let body = json!({
|
||||||
"owned_card_id": o.id,
|
"owned_card_id": o.id,
|
||||||
|
"content_kind": o.content_kind,
|
||||||
|
"quantity": o.quantity,
|
||||||
"is_loan": o.is_loan,
|
"is_loan": o.is_loan,
|
||||||
"loan_matches_remaining": o.loan_matches_remaining,
|
"loan_matches_remaining": o.loan_matches_remaining,
|
||||||
"acquired_at": o.acquired_at,
|
"acquired_at": o.acquired_at,
|
||||||
"chemistry_style": o.chemistry_style,
|
"chemistry_style": o.chemistry_style,
|
||||||
"position_override": o.position_override,
|
"position_override": o.position_override,
|
||||||
"training_bonus": o.training_bonus,
|
"training_bonus": o.training_bonus,
|
||||||
|
// Core's stored value verbatim: `null` means Core tracks no contract
|
||||||
|
// for this instance, which is NOT zero. Substituting a default here
|
||||||
|
// would bake one game's pack-fresh number into every game's envelope.
|
||||||
|
"contract_matches": o.contract_matches,
|
||||||
"effective_overall": effective_overall,
|
"effective_overall": effective_overall,
|
||||||
"effective_position": effective_position,
|
"effective_position": effective_position,
|
||||||
|
"effective_attributes": training_svc::effective_attributes_json(def, effect),
|
||||||
|
"training": effect,
|
||||||
"card": def,
|
"card": def,
|
||||||
});
|
});
|
||||||
OwnedItemView {
|
views.push(OwnedItemView {
|
||||||
owned_card_id: o.id.clone(),
|
owned_card_id: o.id.clone(),
|
||||||
|
content_kind: o.content_kind,
|
||||||
base_overall: def.overall,
|
base_overall: def.overall,
|
||||||
effective_overall,
|
effective_overall,
|
||||||
position: effective_position.to_string(),
|
position: effective_position.to_string(),
|
||||||
@@ -141,11 +183,22 @@ pub async fn get_collection(
|
|||||||
league: def.league.clone(),
|
league: def.league.clone(),
|
||||||
club: def.club.clone(),
|
club: def.club.clone(),
|
||||||
body,
|
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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
|
let owned_rows = owned.len();
|
||||||
|
let unresolved_items = owned_rows - views.len();
|
||||||
let page = inventory::apply_query(views, &query);
|
let page = inventory::apply_query(views, &query);
|
||||||
let returned = page.items.len();
|
let returned = page.items.len();
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -154,6 +207,12 @@ pub async fn get_collection(
|
|||||||
"returned": returned,
|
"returned": returned,
|
||||||
"offset": page.offset,
|
"offset": page.offset,
|
||||||
"limit": page.limit,
|
"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,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,30 +225,26 @@ pub async fn delete_owned_card(
|
|||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let owned = sqlx::query_as::<_, OwnedCard>(
|
let owned = sqlx::query_as::<_, OwnedCard>(&format!(
|
||||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
|
"{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"
|
||||||
chemistry_style, position_override, training_bonus \
|
))
|
||||||
FROM owned_cards WHERE id = ? AND club_id = ?",
|
|
||||||
)
|
|
||||||
.bind(&owned_card_id)
|
.bind(&owned_card_id)
|
||||||
.bind(&club.id)
|
.bind(&club.id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("owned card '{owned_card_id}' not found")))?;
|
.ok_or_else(|| AppError::NotFound(format!("owned card '{owned_card_id}' not found")))?;
|
||||||
|
|
||||||
let card = state
|
let card = state.card_db.get(&owned.card_id).ok_or_else(|| {
|
||||||
.card_db
|
AppError::NotFound(format!("card definition '{}' missing", owned.card_id))
|
||||||
.get(&owned.card_id)
|
})?;
|
||||||
.ok_or_else(|| AppError::NotFound(format!("card definition '{}' missing", owned.card_id)))?;
|
|
||||||
|
|
||||||
let coins = quick_sell_coins(card.overall);
|
let coins = quick_sell_coins(card.overall);
|
||||||
|
|
||||||
sqlx::query("DELETE FROM owned_cards WHERE id = ?")
|
// Delegate to the economy authority rather than hand-rolling DELETE + add_coins:
|
||||||
.bind(&owned_card_id)
|
// that pair ran on the pool with NO transaction (a failed credit left the card
|
||||||
.execute(&state.pool)
|
// destroyed for nothing) and it skipped `squad_players`, whose FK onto
|
||||||
.await?;
|
// `owned_cards(id)` made quick-selling a squadded card fail with SQLite 787.
|
||||||
|
economy_svc::sell_item(&state.pool, &club.id, &owned_card_id, coins).await?;
|
||||||
club_svc::add_coins(&state.pool, &club.id, coins).await?;
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"quick_sold": owned_card_id,
|
"quick_sold": owned_card_id,
|
||||||
|
|||||||
+139
-16
@@ -1,13 +1,16 @@
|
|||||||
use crate::extractors::GameId;
|
use crate::extractors::GameId;
|
||||||
use crate::{
|
use crate::{
|
||||||
app::AppState,
|
app::AppState,
|
||||||
error::AppResult,
|
error::{AppError, AppResult},
|
||||||
models::club::Club,
|
models::{card::ActiveSlot, club::Club},
|
||||||
services::{checkin as checkin_svc, club as club_svc, profile as profile_svc, statistics as stats_svc},
|
services::{
|
||||||
|
checkin as checkin_svc, club as club_svc, profile as profile_svc, statistics as stats_svc,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
pub async fn get_club(State(state): State<AppState>, game: GameId) -> AppResult<Json<Club>> {
|
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?;
|
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 })))
|
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 profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
let status = checkin_svc::get_status(&state.pool, &profile.id).await?;
|
let status = checkin_svc::get_status(&state.pool, &profile.id).await?;
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -67,9 +73,8 @@ 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 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 stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let seasons_completed: i64 = sqlx::query_scalar(
|
let seasons_completed: i64 =
|
||||||
"SELECT COUNT(*) FROM season_history WHERE profile_id = ?",
|
sqlx::query_scalar("SELECT COUNT(*) FROM season_history WHERE profile_id = ?")
|
||||||
)
|
|
||||||
.bind(&profile.id)
|
.bind(&profile.id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
@@ -83,25 +88,21 @@ pub async fn get_milestones(State(state): State<AppState>, game: GameId) -> AppR
|
|||||||
.await
|
.await
|
||||||
.unwrap_or(10);
|
.unwrap_or(10);
|
||||||
|
|
||||||
let cards_owned: i64 = sqlx::query_scalar(
|
let cards_owned: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE club_id = ?")
|
||||||
"SELECT COUNT(*) FROM owned_cards WHERE club_id = ?",
|
|
||||||
)
|
|
||||||
.bind(&club.id)
|
.bind(&club.id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let sbcs_completed: i64 = sqlx::query_scalar(
|
let sbcs_completed: i64 =
|
||||||
"SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1",
|
sqlx::query_scalar("SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1")
|
||||||
)
|
|
||||||
.bind(&club.id)
|
.bind(&club.id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let total_checkins: i64 = sqlx::query_scalar(
|
let total_checkins: i64 =
|
||||||
"SELECT COUNT(*) FROM daily_checkins WHERE profile_id = ?",
|
sqlx::query_scalar("SELECT COUNT(*) FROM daily_checkins WHERE profile_id = ?")
|
||||||
)
|
|
||||||
.bind(&profile.id)
|
.bind(&profile.id)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
@@ -124,3 +125,125 @@ pub async fn get_milestones(State(state): State<AppState>, game: GameId) -> AppR
|
|||||||
"club_level": club.level,
|
"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 })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A manager write. The three states are DISTINCT and must stay that way:
|
||||||
|
///
|
||||||
|
/// | body | meaning |
|
||||||
|
/// | --- | --- |
|
||||||
|
/// | `{}` — field absent | say nothing about the manager; leave it as it is |
|
||||||
|
/// | `{"owned_card_id": null}` | explicitly remove the current manager |
|
||||||
|
/// | `{"owned_card_id": "<id>"}` | assign that owned card |
|
||||||
|
///
|
||||||
|
/// A plain `Option<String>` collapsed the first two into `None`, so a caller
|
||||||
|
/// that simply had nothing to say silently deleted the assignment. That is how a
|
||||||
|
/// FIFA 17 client with a destroyed squad model wiped a real manager row. The
|
||||||
|
/// double option keeps "absent" and "null" apart.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SetManagerRequest {
|
||||||
|
#[serde(default, deserialize_with = "deserialize_present_option")]
|
||||||
|
pub owned_card_id: Option<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize a field that is present-but-null into `Some(None)`, leaving an
|
||||||
|
/// absent field as `None` (supplied by `#[serde(default)]`).
|
||||||
|
fn deserialize_present_option<'de, D>(d: D) -> Result<Option<Option<String>>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Option::<String>::deserialize(d).map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assign, explicitly remove, or leave unchanged 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(Some(owned_card_id)) => {
|
||||||
|
club_svc::set_squad_manager(&state.pool, &club.id, &owned_card_id).await?
|
||||||
|
}
|
||||||
|
// Explicit null: a deliberate removal, which is a legitimate operation.
|
||||||
|
Some(None) => club_svc::clear_squad_manager(&state.pool, &club.id).await?,
|
||||||
|
// Absent: this request expresses no manager decision. Touch nothing.
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
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)? })))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
//! `POST /consumables/apply` — the HTTP boundary for Core's atomic
|
||||||
|
//! apply-one-consumable transaction.
|
||||||
|
//!
|
||||||
|
//! Game-neutral like the rest of Core's surface: the caller names an owned source
|
||||||
|
//! instance, an owned target and a described [`InstanceEffect`]; Core resolves the
|
||||||
|
//! game-scoped active profile and its club from the `X-OpenFUT-Game` header, so no
|
||||||
|
//! caller can reach across clubs. Everything after that is one durable SQLite
|
||||||
|
//! transaction in [`consume::consume_item`], guarded by
|
||||||
|
//! `UNIQUE(profile_id, action_identity)`.
|
||||||
|
//!
|
||||||
|
//! The effect vocabulary is closed and validated by Core — see
|
||||||
|
//! [`crate::services::instance_effect`] for why the host describes an effect
|
||||||
|
//! instead of supplying one.
|
||||||
|
|
||||||
|
use axum::{extract::State, Json};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::AppState,
|
||||||
|
error::AppResult,
|
||||||
|
extractors::GameId,
|
||||||
|
models::card::ContentKind,
|
||||||
|
services::{
|
||||||
|
club as club_svc,
|
||||||
|
consume::{self, ConsumeOutcome, ConsumeRequest, ConsumeTarget, SourceConsumption},
|
||||||
|
instance_effect::InstanceEffect,
|
||||||
|
profile as profile_svc,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ApplyConsumableRequest {
|
||||||
|
/// Opaque, stable per-application token. Core never parses it; it only
|
||||||
|
/// enforces uniqueness, so a retried HTTP request replays instead of
|
||||||
|
/// applying twice.
|
||||||
|
pub action_identity: String,
|
||||||
|
pub source_owned_card_id: String,
|
||||||
|
pub target_owned_card_id: String,
|
||||||
|
/// The kind the target MUST be. The caller states it because only the caller
|
||||||
|
/// knows which family its consumable belongs to; a mismatch is refused rather
|
||||||
|
/// than applied to whatever happens to be there.
|
||||||
|
pub target_kind: ContentKind,
|
||||||
|
pub effect: InstanceEffect,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /consumables/apply` — atomic validate + apply + consume-once.
|
||||||
|
///
|
||||||
|
/// `applied: false` in the response means the `action_identity` was already
|
||||||
|
/// recorded: nothing was mutated and the recorded outcome is echoed.
|
||||||
|
pub async fn post_apply_consumable(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<ApplyConsumableRequest>,
|
||||||
|
) -> AppResult<Json<ConsumeOutcome>> {
|
||||||
|
// Both ids are needed: the profile scopes the replay guard, the club scopes
|
||||||
|
// ownership. Same resolution pair as `cards::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 outcome = consume::consume_item(
|
||||||
|
&state.pool,
|
||||||
|
&profile.id,
|
||||||
|
&club.id,
|
||||||
|
&ConsumeRequest {
|
||||||
|
action_identity: &req.action_identity,
|
||||||
|
source_owned_card_id: &req.source_owned_card_id,
|
||||||
|
// A consumable is the only thing that can be applied, and it is spent
|
||||||
|
// whole: FIFA-style stacking is the adapter's projection, not an
|
||||||
|
// ownership model Core has for these instances.
|
||||||
|
expected_source_kind: ContentKind::Consumable,
|
||||||
|
consumption: SourceConsumption::DestroyInstance,
|
||||||
|
target: ConsumeTarget::OwnedCard {
|
||||||
|
owned_card_id: &req.target_owned_card_id,
|
||||||
|
expected_kind: req.target_kind,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&req.effect,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(outcome))
|
||||||
|
}
|
||||||
+33
-9
@@ -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 profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
let history = season_svc::get_history(&state.pool, &profile.id).await?;
|
let history = season_svc::get_history(&state.pool, &profile.id).await?;
|
||||||
Ok(Json(json!({ "history": history, "total": history.len() })))
|
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 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
let season = season_svc::get_or_create(&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);
|
let mut rng = rand::rngs::SmallRng::seed_from_u64(seed);
|
||||||
|
|
||||||
const NPC_NAMES: &[&str] = &[
|
const NPC_NAMES: &[&str] = &[
|
||||||
"Riverside FC", "City Athletic", "County United", "Valley Rangers",
|
"Riverside FC",
|
||||||
"Harbor Town FC", "Mountside City", "Lakewood Athletic", "Eastbrook United",
|
"City Athletic",
|
||||||
"Westfield Rovers", "Northgate FC", "Southport Athletic", "Ironbridge City",
|
"County United",
|
||||||
"Milldale United", "Hillcrest Rangers", "Bayside FC", "Thornfield Athletic",
|
"Valley Rangers",
|
||||||
"Greenhill United", "Coldwater City", "Redbury Rangers", "Ashdown FC",
|
"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
|
// Pick 9 NPC names without repetition using the seeded RNG
|
||||||
@@ -75,8 +96,11 @@ pub async fn get_division_leaderboard(State(state): State<AppState>, game: GameI
|
|||||||
// Quality bias: index 0-2 = stronger, 6-8 = weaker
|
// Quality bias: index 0-2 = stronger, 6-8 = weaker
|
||||||
let quality: f64 = 1.0 - (idx as f64 / 8.0); // 1.0 → 0.0
|
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 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 wins =
|
||||||
let losses = (npc_matches as f64 * (1.0 - expected_win_rate) * (0.8 + rng.gen::<f64>() * 0.4)) as i64;
|
(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 draws = (npc_matches - wins - losses).max(0);
|
||||||
let pts = wins * 3 + draws;
|
let pts = wins * 3 + draws;
|
||||||
json!({
|
json!({
|
||||||
|
|||||||
+4
-2
@@ -45,7 +45,8 @@ pub async fn post_draft_start(
|
|||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
let difficulty = query.difficulty.as_deref().unwrap_or("professional");
|
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))
|
Ok(Json(session))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +57,8 @@ pub async fn get_draft_session(
|
|||||||
Path(session_id): Path<String>,
|
Path(session_id): Path<String>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
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))
|
Ok(Json(session))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -144,3 +144,77 @@ pub async fn post_purchase_item(
|
|||||||
economy::purchase_item(&state.pool, &club, req.cost, &req.item_id, &req.card_id).await?;
|
economy::purchase_item(&state.pool, &club, req.cost, &req.item_id, &req.card_id).await?;
|
||||||
Ok(Json(BalanceResponse { balance }))
|
Ok(Json(BalanceResponse { balance }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PurchaseItemsRequest {
|
||||||
|
pub cost: i64,
|
||||||
|
pub items: Vec<GrantedItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /economy/purchase-items` — atomic debit + mint several items.
|
||||||
|
pub async fn post_purchase_items(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<PurchaseItemsRequest>,
|
||||||
|
) -> AppResult<Json<BalanceResponse>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
let balance = economy::purchase_items(&state.pool, &club, req.cost, &req.items).await?;
|
||||||
|
Ok(Json(BalanceResponse { balance }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /economy/settle-sale` request.
|
||||||
|
///
|
||||||
|
/// This is the ONE economy route that names clubs explicitly, and it has to: a
|
||||||
|
/// market sale has two sides, and the module's active-profile resolution can only
|
||||||
|
/// ever describe one. Both are optional and default to the game-scoped active
|
||||||
|
/// club, so the single-player case stays as terse as every other route:
|
||||||
|
///
|
||||||
|
/// * `seller_club_id` omitted -> the active club is the seller (it listed the
|
||||||
|
/// item), which is the production shape.
|
||||||
|
/// * `buyer_club_id` omitted -> the counterparty is OUTSIDE the modelled
|
||||||
|
/// economy: no balance is debited and the item leaves the inventory. It does
|
||||||
|
/// NOT silently fall back to the active club, because that would settle a sale
|
||||||
|
/// between a club and itself.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SettleSaleRequest {
|
||||||
|
/// The authoritative owned-item instance changing hands.
|
||||||
|
pub item_id: String,
|
||||||
|
/// What the buyer pays. The fee is withheld from this, never added to it.
|
||||||
|
pub gross: i64,
|
||||||
|
/// Withheld from the seller and destroyed. The RATE is a per-game policy the
|
||||||
|
/// caller owns; Core only checks `0 <= fee <= gross`.
|
||||||
|
pub fee: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub seller_club_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub buyer_club_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /economy/settle-sale` — atomically debit the buyer, transfer the existing
|
||||||
|
/// item, credit the seller net of the fee, and destroy the fee.
|
||||||
|
pub async fn post_settle_sale(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<SettleSaleRequest>,
|
||||||
|
) -> AppResult<Json<economy::SaleReceipt>> {
|
||||||
|
let seller = match req.seller_club_id {
|
||||||
|
Some(id) => id,
|
||||||
|
None => resolve_club(&state, &game).await?,
|
||||||
|
};
|
||||||
|
let buyer = match req.buyer_club_id.as_deref() {
|
||||||
|
Some(id) => economy::SaleBuyer::Club(id),
|
||||||
|
None => economy::SaleBuyer::Outside,
|
||||||
|
};
|
||||||
|
let receipt = economy::settle_sale(
|
||||||
|
&state.pool,
|
||||||
|
&req.item_id,
|
||||||
|
&seller,
|
||||||
|
buyer,
|
||||||
|
economy::SaleTerms {
|
||||||
|
gross: req.gross,
|
||||||
|
fee: req.fee,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(receipt))
|
||||||
|
}
|
||||||
|
|||||||
+17
-10
@@ -9,7 +9,9 @@ use serde_json::{json, Value};
|
|||||||
use crate::{
|
use crate::{
|
||||||
app::AppState,
|
app::AppState,
|
||||||
error::AppResult,
|
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.
|
/// 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.
|
/// 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 profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
let session = champs_svc::start_session(&state.pool, &profile.id).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.
|
/// 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 profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
let history = champs_svc::get_history(&state.pool, &profile.id).await?;
|
let history = champs_svc::get_history(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
@@ -107,19 +115,18 @@ pub async fn get_champs_history(State(state): State<AppState>, game: GameId) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// POST /rivals/claim-weekly — claim weekly Division Rivals reward.
|
/// 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 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
// Ensure a season row exists
|
// Ensure a season row exists
|
||||||
season_svc::get_or_create(&state.pool, &profile.id).await?;
|
season_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let result = champs_svc::claim_rivals_reward(
|
let result =
|
||||||
&state.pool,
|
champs_svc::claim_rivals_reward(&state.pool, &profile.id, &club.id, &state.pack_defs)
|
||||||
&profile.id,
|
|
||||||
&club.id,
|
|
||||||
&state.pack_defs,
|
|
||||||
)
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
|
|||||||
+13
-5
@@ -13,14 +13,16 @@ use crate::{
|
|||||||
services::{club as club_svc, market as market_svc, profile as profile_svc},
|
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 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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
let trades = market_svc::get_trade_history(&state.pool, &club.id).await?;
|
let trades = market_svc::get_trade_history(&state.pool, &club.id).await?;
|
||||||
Ok(Json(json!({ "trades": trades, "total": trades.len() })))
|
Ok(Json(json!({ "trades": trades, "total": trades.len() })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct MarketQuery {
|
pub struct MarketQuery {
|
||||||
pub min_overall: Option<u8>,
|
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.
|
/// 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 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 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?;
|
let listings =
|
||||||
Ok(Json(json!({ "listings": listings, "total": listings.len() })))
|
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.
|
/// Cancel a player-posted listing and return the card to the collection.
|
||||||
|
|||||||
+116
-8
@@ -8,9 +8,9 @@ use serde_json::{json, Value};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::AppState,
|
app::AppState,
|
||||||
error::AppResult,
|
error::{AppError, AppResult},
|
||||||
models::match_result::{Match, MatchRewardResult, SubmitMatchRequest},
|
models::match_result::{CompleteMatchRequest, Match, MatchCompletionResult},
|
||||||
services::{club as club_svc, match_service, profile as profile_svc},
|
services::{club as club_svc, match_service, notification, profile as profile_svc},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -63,17 +63,125 @@ pub async fn get_opponent(
|
|||||||
Ok(Json(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>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
game: GameId,
|
||||||
Json(req): Json<SubmitMatchRequest>,
|
Json(req): Json<CompleteMatchRequest>,
|
||||||
) -> AppResult<Json<MatchRewardResult>> {
|
) -> AppResult<Json<MatchCompletionResult>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let result =
|
let result = match_service::complete_match(
|
||||||
match_service::process_match(&state.pool, &profile.id, &club.id, &req, &state.obj_defs, &state.achievement_defs)
|
&state.pool,
|
||||||
|
&profile.id,
|
||||||
|
&club.id,
|
||||||
|
&req,
|
||||||
|
&state.obj_defs,
|
||||||
|
&state.achievement_defs,
|
||||||
|
)
|
||||||
.await?;
|
.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))
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+2
-1
@@ -2,11 +2,12 @@ pub mod achievements;
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod cards;
|
pub mod cards;
|
||||||
pub mod club;
|
pub mod club;
|
||||||
|
pub mod consumables;
|
||||||
pub mod division;
|
pub mod division;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
pub mod economy;
|
pub mod economy;
|
||||||
pub mod fut_champs;
|
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
pub mod fut_champs;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
pub mod matches;
|
pub mod matches;
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ use serde_json::{json, Value};
|
|||||||
use crate::{
|
use crate::{
|
||||||
app::AppState,
|
app::AppState,
|
||||||
error::{AppError, AppResult},
|
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
|
/// GET /notifications
|
||||||
@@ -17,7 +19,10 @@ use crate::{
|
|||||||
/// notifications (unclaimed objectives, expiring loans, season ending soon).
|
/// notifications (unclaimed objectives, expiring loans, season ending soon).
|
||||||
/// Persistent notifications carry an `id` and `is_read` flag; dynamic ones
|
/// Persistent notifications carry an `id` and `is_read` flag; dynamic ones
|
||||||
/// have `id: null` and are always considered unread.
|
/// 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 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 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.
|
// Use "type" key for compatibility with dashboard and existing tests.
|
||||||
let all: Vec<Value> = persistent
|
let all: Vec<Value> = persistent
|
||||||
.iter()
|
.iter()
|
||||||
.map(|n| json!({
|
.map(|n| {
|
||||||
|
json!({
|
||||||
"id": n.id,
|
"id": n.id,
|
||||||
"type": n.kind,
|
"type": n.kind,
|
||||||
"title": n.title,
|
"title": n.title,
|
||||||
"body": n.body,
|
"body": n.body,
|
||||||
"is_read": n.is_read,
|
"is_read": n.is_read,
|
||||||
"created_at": n.created_at,
|
"created_at": n.created_at,
|
||||||
}))
|
})
|
||||||
|
})
|
||||||
.chain(dynamic.iter().cloned())
|
.chain(dynamic.iter().cloned())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -125,9 +132,7 @@ pub async fn mark_notification_read(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// POST /notifications/read-all
|
/// POST /notifications/read-all
|
||||||
pub async fn mark_all_notifications_read(
|
pub async fn mark_all_notifications_read(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> AppResult<Json<Value>> {
|
|
||||||
let count = notif_svc::mark_all_read(&state.pool).await?;
|
let count = notif_svc::mark_all_read(&state.pool).await?;
|
||||||
Ok(Json(json!({ "marked_read": count })))
|
Ok(Json(json!({ "marked_read": count })))
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -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.
|
/// 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 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 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)
|
objective::increment_metric(&state.pool, &profile.id, &state.obj_defs, "packsopened", 1)
|
||||||
.await?;
|
.await?;
|
||||||
let _ = crate::services::achievement::check_and_unlock(
|
let _ = crate::services::achievement::check_and_unlock(
|
||||||
&state.pool, &state.achievement_defs, &profile.id, &club.id,
|
&state.pool,
|
||||||
).await;
|
&state.achievement_defs,
|
||||||
|
&profile.id,
|
||||||
|
&club.id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-12
@@ -8,7 +8,7 @@ use serde_json::{json, Value};
|
|||||||
use crate::{
|
use crate::{
|
||||||
app::AppState,
|
app::AppState,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::sbc::{SbcResult, SubmitSbcRequest},
|
models::sbc::{SaveSbcSquadRequest, SbcResult, SubmitSbcRequest},
|
||||||
services::{club as club_svc, profile as profile_svc, sbc as sbc_svc},
|
services::{club as club_svc, profile as profile_svc, sbc as sbc_svc},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,6 +28,49 @@ pub async fn get_sbc(
|
|||||||
Ok(Json(json!({ "sbc": sbc })))
|
Ok(Json(json!({ "sbc": sbc })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_sbc_status(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||||
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
|
let completions = sbc_svc::completion_counts(&state.pool, &profile.id).await?;
|
||||||
|
Ok(Json(json!({ "completions": completions })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_sbc_squad(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Path(sbc_id): Path<String>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
if !state
|
||||||
|
.sbc_defs
|
||||||
|
.iter()
|
||||||
|
.any(|definition| definition.id == sbc_id)
|
||||||
|
{
|
||||||
|
return Err(AppError::NotFound(format!("SBC '{sbc_id}' not found")));
|
||||||
|
}
|
||||||
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
|
let squad = sbc_svc::load_sbc_squad(&state.pool, &profile.id, &sbc_id).await?;
|
||||||
|
Ok(Json(json!({ "squad": squad })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put_sbc_squad(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Path(sbc_id): Path<String>,
|
||||||
|
Json(req): Json<SaveSbcSquadRequest>,
|
||||||
|
) -> 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 squad = sbc_svc::save_sbc_squad(
|
||||||
|
&state.pool,
|
||||||
|
&state.sbc_defs,
|
||||||
|
&profile.id,
|
||||||
|
&club.id,
|
||||||
|
&sbc_id,
|
||||||
|
&req.owned_card_ids,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({ "squad": squad })))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn post_sbc_submit(
|
pub async fn post_sbc_submit(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
game: GameId,
|
||||||
@@ -38,20 +81,17 @@ pub async fn post_sbc_submit(
|
|||||||
|
|
||||||
let result = sbc_svc::submit_sbc(
|
let result = sbc_svc::submit_sbc(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
&state.card_db,
|
sbc_svc::SbcSubmissionContext {
|
||||||
&state.sbc_defs,
|
card_db: &state.card_db,
|
||||||
&state.obj_defs,
|
sbc_defs: &state.sbc_defs,
|
||||||
&profile.id,
|
objective_defs: &state.obj_defs,
|
||||||
&club.id,
|
achievement_defs: &state.achievement_defs,
|
||||||
|
profile_id: &profile.id,
|
||||||
|
club_id: &club.id,
|
||||||
|
},
|
||||||
&req,
|
&req,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if result.passed {
|
|
||||||
let _ = crate::services::achievement::check_and_unlock(
|
|
||||||
&state.pool, &state.achievement_defs, &profile.id, &club.id,
|
|
||||||
).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,3 +235,45 @@ pub async fn put_squad_replace(
|
|||||||
"slots_written": out.slots_written,
|
"slots_written": out.slots_written,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RolePatchReq {
|
||||||
|
/// Owned card to flag as captain. Omitted means "leave the captain alone" —
|
||||||
|
/// it is NEVER a request to clear it. Clearing has no established client
|
||||||
|
/// semantics and is deliberately not invented here.
|
||||||
|
#[serde(default)]
|
||||||
|
pub captain_owned_card_id: Option<String>,
|
||||||
|
pub extension: OpaqueExtensionWrite,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PUT /squad/roles` — patch ONLY role assignments (captain) plus the opaque
|
||||||
|
/// game extension, atomically.
|
||||||
|
///
|
||||||
|
/// Distinct from `/squad/replace` on purpose. A role-only update carries no slot
|
||||||
|
/// array, and describing it as a replacement with zero slots trips the
|
||||||
|
/// empty-replacement guard — which is correct behaviour for a replacement and
|
||||||
|
/// wrong for a patch. This route never touches player assignments, the squad
|
||||||
|
/// manager, or club actives.
|
||||||
|
pub async fn put_squad_roles(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<RolePatchReq>,
|
||||||
|
) -> 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 out = squad_svc::patch_squad_roles(
|
||||||
|
&state.pool,
|
||||||
|
game.as_str(),
|
||||||
|
&club.id,
|
||||||
|
req.captain_owned_card_id.as_deref(),
|
||||||
|
&req.extension,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"squad_id": out.squad.id,
|
||||||
|
"canonical_fingerprint": out.canonical_fingerprint,
|
||||||
|
"captain_changed": out.captain_changed,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,13 +72,8 @@ pub async fn post_change_position(
|
|||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let updated = upgrade_svc::change_position(
|
let updated =
|
||||||
&state.pool,
|
upgrade_svc::change_position(&state.pool, &club.id, &owned_card_id, &req.position).await?;
|
||||||
&club.id,
|
|
||||||
&owned_card_id,
|
|
||||||
&req.position,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let card_def = state.card_db.get(&updated.card_id);
|
let card_def = state.card_db.get(&updated.card_id);
|
||||||
|
|
||||||
|
|||||||
+210
-35
@@ -1,10 +1,12 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
db::Pool,
|
db::Pool,
|
||||||
error::AppResult,
|
error::{AppError, AppResult},
|
||||||
models::achievement::{AchievementDefinition, PlayerAchievement},
|
models::achievement::{AchievementDefinition, PlayerAchievement},
|
||||||
services::{club as club_svc, notification},
|
services::{club as club_svc, notification},
|
||||||
};
|
};
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
use sqlx::{Sqlite, Transaction};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -29,58 +31,62 @@ pub fn load_achievement_definitions(data_dir: &str) -> anyhow::Result<Vec<Achiev
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Query the current value for the given trigger metric.
|
/// 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> {
|
async fn metric_value(
|
||||||
let v: i64 = match trigger {
|
pool: &Pool,
|
||||||
"matches_played" => sqlx::query_scalar(
|
profile_id: &str,
|
||||||
"SELECT matches_played FROM statistics WHERE profile_id = ?",
|
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)
|
.bind(profile_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?
|
.await?
|
||||||
.unwrap_or(0),
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
"matches_won" => sqlx::query_scalar(
|
"matches_won" => {
|
||||||
"SELECT matches_won FROM statistics WHERE profile_id = ?",
|
sqlx::query_scalar("SELECT matches_won FROM statistics WHERE profile_id = ?")
|
||||||
)
|
|
||||||
.bind(profile_id)
|
.bind(profile_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?
|
.await?
|
||||||
.unwrap_or(0),
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
"goals_scored" => sqlx::query_scalar(
|
"goals_scored" => {
|
||||||
"SELECT goals_scored FROM statistics WHERE profile_id = ?",
|
sqlx::query_scalar("SELECT goals_scored FROM statistics WHERE profile_id = ?")
|
||||||
)
|
|
||||||
.bind(profile_id)
|
.bind(profile_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?
|
.await?
|
||||||
.unwrap_or(0),
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
"packs_opened" => sqlx::query_scalar(
|
"packs_opened" => {
|
||||||
"SELECT packs_opened FROM statistics WHERE profile_id = ?",
|
sqlx::query_scalar("SELECT packs_opened FROM statistics WHERE profile_id = ?")
|
||||||
)
|
|
||||||
.bind(profile_id)
|
.bind(profile_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?
|
.await?
|
||||||
.unwrap_or(0),
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
"sbcs_completed" => sqlx::query_scalar(
|
"sbcs_completed" => {
|
||||||
"SELECT sbcs_completed FROM statistics WHERE profile_id = ?",
|
sqlx::query_scalar("SELECT sbcs_completed FROM statistics WHERE profile_id = ?")
|
||||||
)
|
|
||||||
.bind(profile_id)
|
.bind(profile_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?
|
.await?
|
||||||
.unwrap_or(0),
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
"cards_owned" => sqlx::query_scalar(
|
"cards_owned" => {
|
||||||
"SELECT COUNT(*) FROM owned_cards WHERE club_id = ?",
|
sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE club_id = ?")
|
||||||
)
|
|
||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?,
|
.await?
|
||||||
|
}
|
||||||
|
|
||||||
"level" => sqlx::query_scalar(
|
"level" => sqlx::query_scalar("SELECT level FROM profiles WHERE id = ?")
|
||||||
"SELECT level FROM profiles WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(profile_id)
|
.bind(profile_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?
|
.await?
|
||||||
@@ -165,11 +171,180 @@ pub async fn check_and_unlock(
|
|||||||
club_svc::add_coins(pool, club_id, def.reward_coins).await?;
|
club_svc::add_coins(pool, club_id, def.reward_coins).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let body = format!(
|
let body = format!("{} Reward: {} coins.", def.description, def.reward_coins);
|
||||||
"{} Reward: {} coins.",
|
let _ = notification::create(
|
||||||
def.description, def.reward_coins
|
pool,
|
||||||
);
|
"achievement",
|
||||||
let _ = notification::create(pool, "achievement", &format!("Achievement: {}", def.title), &body).await;
|
&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());
|
newly_unlocked.push(def.clone());
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-17
@@ -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;
|
use uuid::Uuid;
|
||||||
|
|
||||||
const STREAK_COINS: [i64; 7] = [500, 650, 800, 1000, 1200, 1500, 2000];
|
const STREAK_COINS: [i64; 7] = [500, 650, 800, 1000, 1200, 1500, 2000];
|
||||||
@@ -44,7 +48,7 @@ pub async fn get_status(pool: &Pool, profile_id: &str) -> AppResult<CheckinStatu
|
|||||||
let last_day = &last_at[..10]; // YYYY-MM-DD
|
let last_day = &last_at[..10]; // YYYY-MM-DD
|
||||||
let available = last_day != today.as_str();
|
let available = last_day != today.as_str();
|
||||||
let next_streak = compute_next_streak(last_streak, &last_at);
|
let next_streak = compute_next_streak(last_streak, &last_at);
|
||||||
let idx = ((next_streak - 1) % 7) as usize;
|
let idx = (next_streak - 1).rem_euclid(7) as usize;
|
||||||
Ok(CheckinStatus {
|
Ok(CheckinStatus {
|
||||||
available,
|
available,
|
||||||
streak_day: if available { next_streak } else { last_streak },
|
streak_day: if available { next_streak } else { last_streak },
|
||||||
@@ -56,11 +60,7 @@ pub async fn get_status(pool: &Pool, profile_id: &str) -> AppResult<CheckinStatu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn claim(
|
pub async fn claim(pool: &Pool, profile_id: &str, club_id: &str) -> AppResult<CheckinResult> {
|
||||||
pool: &Pool,
|
|
||||||
profile_id: &str,
|
|
||||||
club_id: &str,
|
|
||||||
) -> AppResult<CheckinResult> {
|
|
||||||
let row: Option<(i64, String)> = sqlx::query_as(
|
let row: Option<(i64, String)> = sqlx::query_as(
|
||||||
"SELECT streak_day, checked_in_at FROM daily_checkins \
|
"SELECT streak_day, checked_in_at FROM daily_checkins \
|
||||||
WHERE profile_id = ? ORDER BY checked_in_at DESC LIMIT 1",
|
WHERE profile_id = ? ORDER BY checked_in_at DESC LIMIT 1",
|
||||||
@@ -82,20 +82,21 @@ 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
|
||||||
let idx = ((last_streak - 1) % 7) as usize;
|
.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 coins = STREAK_COINS[idx];
|
||||||
let pack_def = if idx == 6 { Some(STREAK_7_PACK) } else { None };
|
let pack_def = if idx == 6 { Some(STREAK_7_PACK) } else { None };
|
||||||
|
|
||||||
club::add_coins(pool, club_id, coins).await?;
|
// Atomically claim today's check-in: the INSERT lands only if no row exists for
|
||||||
if let Some(def) = pack_def {
|
// today, so two concurrent claims cannot both pay out (was a check-then-act race).
|
||||||
let _ = pack::grant_pack(pool, club_id, def).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
sqlx::query(
|
let inserted = sqlx::query(
|
||||||
"INSERT INTO daily_checkins (id, profile_id, club_id, streak_day, coins_awarded, pack_granted, checked_in_at) \
|
"INSERT INTO daily_checkins (id, profile_id, club_id, streak_day, coins_awarded, pack_granted, checked_in_at) \
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
SELECT ?, ?, ?, ?, ?, ?, ? \
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM daily_checkins WHERE profile_id = ? AND substr(checked_in_at, 1, 10) = ?)",
|
||||||
)
|
)
|
||||||
.bind(Uuid::new_v4().to_string())
|
.bind(Uuid::new_v4().to_string())
|
||||||
.bind(profile_id)
|
.bind(profile_id)
|
||||||
@@ -104,8 +105,26 @@ pub async fn claim(
|
|||||||
.bind(coins)
|
.bind(coins)
|
||||||
.bind(pack_def)
|
.bind(pack_def)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
|
.bind(profile_id)
|
||||||
|
.bind(&today)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
if inserted == 0 {
|
||||||
|
// A concurrent claim already recorded today's check-in — do not pay out again.
|
||||||
|
return Ok(CheckinResult {
|
||||||
|
coins_awarded: 0,
|
||||||
|
pack_awarded: None,
|
||||||
|
new_streak: last_streak,
|
||||||
|
already_claimed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
club::add_coins(pool, club_id, coins).await?;
|
||||||
|
if let Some(def) = pack_def {
|
||||||
|
let _ = pack::grant_pack(pool, club_id, def).await;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(CheckinResult {
|
Ok(CheckinResult {
|
||||||
coins_awarded: coins,
|
coins_awarded: coins,
|
||||||
|
|||||||
+627
-11
@@ -1,7 +1,10 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
db::Pool,
|
db::Pool,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::club::Club,
|
models::{
|
||||||
|
card::{ActiveSlot, ContentKind, OwnedCard, OWNED_CARD_SELECT},
|
||||||
|
club::Club,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|
||||||
@@ -86,24 +89,637 @@ pub async fn add_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn spend_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64> {
|
pub async fn spend_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64> {
|
||||||
|
if amount < 0 {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"cannot spend a negative amount: {amount}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
// Atomic compare-and-swap: the `coins >= ?` guard makes the debit conditional in a
|
||||||
|
// single statement, so two concurrent spends can never both pass a stale balance
|
||||||
|
// check and drive coins negative (the old SELECT-then-UPDATE was a TOCTOU race).
|
||||||
|
let affected = sqlx::query(
|
||||||
|
"UPDATE clubs SET coins = coins - ?, updated_at = ? WHERE id = ? AND coins >= ?",
|
||||||
|
)
|
||||||
|
.bind(amount)
|
||||||
|
.bind(now)
|
||||||
|
.bind(club_id)
|
||||||
|
.bind(amount)
|
||||||
|
.execute(pool)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
if affected == 0 {
|
||||||
|
// No row updated: the club is missing, or it could not afford the debit.
|
||||||
|
// Disambiguate so callers keep the NotFound vs BadRequest distinction.
|
||||||
let balance = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
|
let balance = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
|
||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
.fetch_one(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("club not found: {club_id}")))?;
|
||||||
if balance < amount {
|
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(AppError::BadRequest(format!(
|
||||||
"insufficient coins: have {balance}, need {amount}"
|
"insufficient coins: have {balance}, need {amount}"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = Utc::now();
|
let new_balance = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
|
||||||
sqlx::query("UPDATE clubs SET coins = coins - ?, updated_at = ? WHERE id = ?")
|
.bind(club_id)
|
||||||
.bind(amount)
|
.fetch_one(pool)
|
||||||
.bind(now)
|
.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<()> {
|
||||||
|
// One transaction: both existence checks and the write. Validating on the
|
||||||
|
// pool and then inserting left a window in which the squad or the card could
|
||||||
|
// be removed between the check and the write, persisting an assignment whose
|
||||||
|
// preconditions no longer held.
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
let squad_ok =
|
||||||
|
sqlx::query_scalar::<_, String>("SELECT id FROM squads WHERE id = ? AND club_id = ?")
|
||||||
|
.bind(squad_id)
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.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(&mut *tx)
|
||||||
|
.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(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().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)
|
.bind(club_id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
Ok(())
|
||||||
Ok(balance - amount)
|
}
|
||||||
|
|
||||||
|
// ─────────────────────── 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
+12
-5
@@ -184,15 +184,23 @@ pub async fn pick_card(
|
|||||||
let (new_candidates, new_status, reward_coins, reward_pack_id, squad_rating, completed_at) =
|
let (new_candidates, new_status, reward_coins, reward_pack_id, squad_rating, completed_at) =
|
||||||
if all_filled {
|
if all_filled {
|
||||||
let (coins, pack, avg) = compute_reward(card_db, &picks);
|
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 {
|
} else {
|
||||||
let min_overall = difficulty_min_overall(&session.difficulty);
|
let min_overall = difficulty_min_overall(&session.difficulty);
|
||||||
let next_pos = &pick_order[next_index];
|
let next_pos = &pick_order[next_index];
|
||||||
let next_candidates =
|
let next_candidates =
|
||||||
pick_candidates(card_db, next_pos, min_overall, CANDIDATES_PER_SLOT);
|
pick_candidates(card_db, next_pos, min_overall, CANDIDATES_PER_SLOT);
|
||||||
{
|
{
|
||||||
let candidates_json = serde_json::to_string(&next_candidates)
|
let candidates_json = serde_json::to_string(&next_candidates).map_err(|e| {
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
|
AppError::Internal(anyhow::anyhow!("serialization failed: {e}"))
|
||||||
|
})?;
|
||||||
(
|
(
|
||||||
Some(candidates_json),
|
Some(candidates_json),
|
||||||
"active".to_string(),
|
"active".to_string(),
|
||||||
@@ -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 {
|
fn render_session(session: &DraftSession, card_db: &CardDb) -> serde_json::Value {
|
||||||
let pick_order: Vec<String> =
|
let pick_order: Vec<String> = serde_json::from_str(&session.pick_order).unwrap_or_default();
|
||||||
serde_json::from_str(&session.pick_order).unwrap_or_default();
|
|
||||||
let picks: Vec<String> = serde_json::from_str(&session.picks).unwrap_or_default();
|
let picks: Vec<String> = serde_json::from_str(&session.picks).unwrap_or_default();
|
||||||
let candidates: Vec<String> = session
|
let candidates: Vec<String> = session
|
||||||
.current_candidates
|
.current_candidates
|
||||||
|
|||||||
+813
-22
@@ -183,6 +183,68 @@ async fn remove_item(
|
|||||||
Ok(card_id)
|
Ok(card_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop `item_id` out of every lineup that references it.
|
||||||
|
///
|
||||||
|
/// REQUIRED before an item changes owner or leaves the inventory, for two
|
||||||
|
/// independent reasons:
|
||||||
|
///
|
||||||
|
/// * `squad_players.owned_card_id` is a FK onto `owned_cards(id)` and the pool
|
||||||
|
/// enables `foreign_keys`, so DELETEing a squadded item fails outright;
|
||||||
|
/// * on a transfer the row id is preserved, so a stale `squad_players` row
|
||||||
|
/// would leave the PREVIOUS owner fielding a card they no longer own.
|
||||||
|
///
|
||||||
|
/// Every existing reference is invalid the moment ownership moves, so this is
|
||||||
|
/// scoped by item rather than by club. Returns the number of lineup slots freed.
|
||||||
|
async fn evict_from_squads(conn: &mut SqliteConnection, item_id: &str) -> AppResult<u64> {
|
||||||
|
Ok(
|
||||||
|
sqlx::query("DELETE FROM squad_players WHERE owned_card_id = ?")
|
||||||
|
.bind(item_id)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?
|
||||||
|
.rows_affected(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reassign one existing owned item from `from_club` to `to_club`, returning its
|
||||||
|
/// definition ref.
|
||||||
|
///
|
||||||
|
/// This is a TRANSFER, not a mint: there is no INSERT, so the instance id and its
|
||||||
|
/// upgrade state survive and the inventory row count is unchanged. The
|
||||||
|
/// `club_id = from_club` predicate makes the UPDATE an ownership compare-and-swap
|
||||||
|
/// — if a concurrent settlement moved the item first, `rows_affected` is 0 and
|
||||||
|
/// this is a [`AppError::Conflict`] rather than a second transfer.
|
||||||
|
///
|
||||||
|
/// Callers MUST have run [`evict_from_squads`] first: the row id is preserved, so
|
||||||
|
/// any surviving lineup reference would belong to the previous owner.
|
||||||
|
async fn transfer_item(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
item_id: &str,
|
||||||
|
from_club: &str,
|
||||||
|
to_club: &str,
|
||||||
|
) -> AppResult<String> {
|
||||||
|
let card_id = sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT card_id FROM owned_cards WHERE id = ? AND club_id = ?",
|
||||||
|
)
|
||||||
|
.bind(item_id)
|
||||||
|
.bind(from_club)
|
||||||
|
.fetch_optional(&mut *conn)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("item not owned by club: {item_id}")))?;
|
||||||
|
let affected = sqlx::query("UPDATE owned_cards SET club_id = ? WHERE id = ? AND club_id = ?")
|
||||||
|
.bind(to_club)
|
||||||
|
.bind(item_id)
|
||||||
|
.bind(from_club)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
if affected != 1 {
|
||||||
|
return Err(AppError::Conflict(format!(
|
||||||
|
"ownership of {item_id} changed during settlement"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(card_id)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- composed atomic operations ----------------------------------------------
|
// ---- composed atomic operations ----------------------------------------------
|
||||||
|
|
||||||
/// Read a club's current currency balance.
|
/// Read a club's current currency balance.
|
||||||
@@ -215,6 +277,25 @@ pub async fn list_unopened_entitlements(pool: &Pool, club_id: &str) -> AppResult
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Commit on `Ok`, roll back on `Err`. Paired with a `BEGIN IMMEDIATE` opened on
|
||||||
|
/// the same connection, so the write lock is held for the whole op and a
|
||||||
|
/// concurrent writer waits (honoring `busy_timeout`) instead of failing: a
|
||||||
|
/// DEFERRED `pool.begin()` upgrades to a write only at the first write, where
|
||||||
|
/// SQLite returns SQLITE_BUSY *immediately* (bypassing the busy handler to avoid
|
||||||
|
/// deadlock) — the fresh-DB multi-connection write failure.
|
||||||
|
pub(crate) async fn finish<T>(conn: &mut SqliteConnection, result: AppResult<T>) -> AppResult<T> {
|
||||||
|
match result {
|
||||||
|
Ok(v) => {
|
||||||
|
sqlx::query("COMMIT").execute(&mut *conn).await?;
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = sqlx::query("ROLLBACK").execute(&mut *conn).await;
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Debit `cost` and grant one entitlement, atomically. Fail-closed: if the club
|
/// Debit `cost` and grant one entitlement, atomically. Fail-closed: if the club
|
||||||
/// cannot afford `cost`, nothing is debited and no entitlement is created.
|
/// cannot afford `cost`, nothing is debited and no entitlement is created.
|
||||||
pub async fn purchase_entitlement(
|
pub async fn purchase_entitlement(
|
||||||
@@ -223,15 +304,19 @@ pub async fn purchase_entitlement(
|
|||||||
cost: i64,
|
cost: i64,
|
||||||
definition_id: &str,
|
definition_id: &str,
|
||||||
) -> AppResult<PurchaseReceipt> {
|
) -> AppResult<PurchaseReceipt> {
|
||||||
let mut tx = pool.begin().await?;
|
let mut conn = pool.acquire().await?;
|
||||||
let balance = debit(&mut tx, club_id, cost).await?;
|
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
|
||||||
let entitlement_id = grant_entitlement(&mut tx, club_id, definition_id).await?;
|
let result = async {
|
||||||
tx.commit().await?;
|
let balance = debit(&mut conn, club_id, cost).await?;
|
||||||
|
let entitlement_id = grant_entitlement(&mut conn, club_id, definition_id).await?;
|
||||||
Ok(PurchaseReceipt {
|
Ok(PurchaseReceipt {
|
||||||
balance,
|
balance,
|
||||||
entitlement_id,
|
entitlement_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
.await;
|
||||||
|
finish(&mut conn, result).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Debit `cost` and mint one owned item, atomically. Fail-closed: if the club
|
/// Debit `cost` and mint one owned item, atomically. Fail-closed: if the club
|
||||||
/// cannot afford `cost`, nothing is debited and no item is added. This is the
|
/// cannot afford `cost`, nothing is debited and no item is added. This is the
|
||||||
@@ -245,12 +330,40 @@ pub async fn purchase_item(
|
|||||||
item_id: &str,
|
item_id: &str,
|
||||||
card_id: &str,
|
card_id: &str,
|
||||||
) -> AppResult<i64> {
|
) -> AppResult<i64> {
|
||||||
let mut tx = pool.begin().await?;
|
let mut conn = pool.acquire().await?;
|
||||||
let balance = debit(&mut tx, club_id, cost).await?;
|
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
|
||||||
add_item(&mut tx, club_id, item_id, card_id).await?;
|
let result = async {
|
||||||
tx.commit().await?;
|
let balance = debit(&mut conn, club_id, cost).await?;
|
||||||
|
add_item(&mut conn, club_id, item_id, card_id).await?;
|
||||||
Ok(balance)
|
Ok(balance)
|
||||||
}
|
}
|
||||||
|
.await;
|
||||||
|
finish(&mut conn, result).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debit `cost` and mint several owned items, atomically. Fail-closed: if the
|
||||||
|
/// club cannot afford `cost`, nothing is debited and no items are added; if any
|
||||||
|
/// item insert fails the whole purchase rolls back. This is the "buy + open"
|
||||||
|
/// primitive (Store packs that open on purchase): one debit paired with the
|
||||||
|
/// minted pack contents. Returns the post-debit balance.
|
||||||
|
pub async fn purchase_items(
|
||||||
|
pool: &Pool,
|
||||||
|
club_id: &str,
|
||||||
|
cost: i64,
|
||||||
|
items: &[GrantedItem],
|
||||||
|
) -> AppResult<i64> {
|
||||||
|
let mut conn = pool.acquire().await?;
|
||||||
|
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
|
||||||
|
let result = async {
|
||||||
|
let balance = debit(&mut conn, club_id, cost).await?;
|
||||||
|
for item in items {
|
||||||
|
add_item(&mut conn, club_id, &item.item_id, &item.card_id).await?;
|
||||||
|
}
|
||||||
|
Ok(balance)
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
finish(&mut conn, result).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Consume an entitlement once and add its granted items, atomically. If any
|
/// Consume an entitlement once and add its granted items, atomically. If any
|
||||||
/// item insert fails (e.g. a colliding instance id) the whole redemption rolls
|
/// item insert fails (e.g. a colliding instance id) the whole redemption rolls
|
||||||
@@ -261,31 +374,196 @@ pub async fn redeem_entitlement(
|
|||||||
entitlement_id: &str,
|
entitlement_id: &str,
|
||||||
items: &[GrantedItem],
|
items: &[GrantedItem],
|
||||||
) -> AppResult<String> {
|
) -> AppResult<String> {
|
||||||
let mut tx = pool.begin().await?;
|
let mut conn = pool.acquire().await?;
|
||||||
let definition_id = consume_entitlement(&mut tx, club_id, entitlement_id).await?;
|
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
|
||||||
|
let result = async {
|
||||||
|
let definition_id = consume_entitlement(&mut conn, club_id, entitlement_id).await?;
|
||||||
for item in items {
|
for item in items {
|
||||||
add_item(&mut tx, club_id, &item.item_id, &item.card_id).await?;
|
add_item(&mut conn, club_id, &item.item_id, &item.card_id).await?;
|
||||||
}
|
}
|
||||||
tx.commit().await?;
|
|
||||||
Ok(definition_id)
|
Ok(definition_id)
|
||||||
}
|
}
|
||||||
|
.await;
|
||||||
|
finish(&mut conn, result).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove an owned item and credit `price`, atomically. Fail-closed: if the item
|
/// Remove an owned item and credit `price`, atomically. Fail-closed: if the item
|
||||||
/// is not owned by the club nothing is credited.
|
/// is not owned by the club nothing is credited and no lineup is disturbed.
|
||||||
|
///
|
||||||
|
/// The item is dropped from any lineup first. `squad_players.owned_card_id` is a FK
|
||||||
|
/// onto `owned_cards(id)` and the pool enables `foreign_keys`, so without this a
|
||||||
|
/// quick sell of a squadded card fails with SQLite error 787 instead of selling it
|
||||||
|
/// — and selling a card that happens to be in your squad is ordinary, not an edge
|
||||||
|
/// case.
|
||||||
pub async fn sell_item(pool: &Pool, club_id: &str, item_id: &str, price: i64) -> AppResult<i64> {
|
pub async fn sell_item(pool: &Pool, club_id: &str, item_id: &str, price: i64) -> AppResult<i64> {
|
||||||
let mut tx = pool.begin().await?;
|
let mut conn = pool.acquire().await?;
|
||||||
remove_item(&mut tx, club_id, item_id).await?;
|
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
|
||||||
let balance = credit(&mut tx, club_id, price).await?;
|
let result = async {
|
||||||
tx.commit().await?;
|
// Safe to free slots before the ownership check in `remove_item`: both share
|
||||||
Ok(balance)
|
// this transaction, so a wrong-owner sale rolls the eviction back with it.
|
||||||
|
evict_from_squads(&mut conn, item_id).await?;
|
||||||
|
remove_item(&mut conn, club_id, item_id).await?;
|
||||||
|
credit(&mut conn, club_id, price).await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
finish(&mut conn, result).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Credit a reward to a club's balance atomically.
|
/// Credit a reward to a club's balance atomically.
|
||||||
pub async fn grant_reward(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64> {
|
pub async fn grant_reward(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64> {
|
||||||
let mut tx = pool.begin().await?;
|
let mut conn = pool.acquire().await?;
|
||||||
let balance = credit(&mut tx, club_id, amount).await?;
|
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
|
||||||
tx.commit().await?;
|
let result = async { credit(&mut conn, club_id, amount).await }.await;
|
||||||
Ok(balance)
|
finish(&mut conn, result).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who acquires the item in a settled sale.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SaleBuyer<'a> {
|
||||||
|
/// A club held by this Core: its balance is debited `gross` and it becomes
|
||||||
|
/// the item's owner. Coins move BETWEEN modelled balances, so the economy
|
||||||
|
/// only loses the fee.
|
||||||
|
Club(&'a str),
|
||||||
|
/// A counterparty outside the modelled economy (e.g. a synthetic market
|
||||||
|
/// buyer, which owns no `clubs` row). Nothing is debited and the item leaves
|
||||||
|
/// the inventory. The seller's proceeds therefore ENTER the economy from
|
||||||
|
/// outside — the accounting invariant differs from [`SaleBuyer::Club`] and
|
||||||
|
/// the caller is responsible for wanting that.
|
||||||
|
Outside,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Money terms of a sale. `fee` is supplied by the caller, never computed here:
|
||||||
|
/// the rate is a per-game policy constant and Core is game-neutral.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct SaleTerms {
|
||||||
|
/// What the buyer pays.
|
||||||
|
pub gross: i64,
|
||||||
|
/// Withheld from the seller and destroyed. `0 <= fee <= gross`.
|
||||||
|
pub fee: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a settlement did, for the caller to render and for audit.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct SaleReceipt {
|
||||||
|
pub item_id: String,
|
||||||
|
pub card_id: String,
|
||||||
|
pub seller_club_id: String,
|
||||||
|
pub buyer_club_id: Option<String>,
|
||||||
|
pub gross: i64,
|
||||||
|
pub fee: i64,
|
||||||
|
/// `gross - fee`, credited to the seller.
|
||||||
|
pub proceeds: i64,
|
||||||
|
pub seller_balance: i64,
|
||||||
|
/// Post-debit buyer balance; `None` for [`SaleBuyer::Outside`].
|
||||||
|
pub buyer_balance: Option<i64>,
|
||||||
|
/// Lineup slots freed because the item changed hands.
|
||||||
|
pub squad_slots_freed: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Settle a completed market sale as ONE atomic transaction: debit the buyer,
|
||||||
|
/// move the EXISTING item, credit the seller their proceeds, destroy the fee.
|
||||||
|
///
|
||||||
|
/// `seller_club_id` is the club the CALLER believes owns the item, and every
|
||||||
|
/// ownership statement is predicated on it. Core deliberately does NOT infer the
|
||||||
|
/// seller from current ownership: doing so makes a replayed settlement look like a
|
||||||
|
/// brand-new sale by the item's new owner, and Core has no listing concept with
|
||||||
|
/// which to notice. Pinning the expected seller turns the ownership UPDATE into a
|
||||||
|
/// compare-and-swap that is the authority on "this sale has already happened",
|
||||||
|
/// independent of any caller-side listing state. A caller still cannot credit a
|
||||||
|
/// club that did not own the item, because the credit only follows a matched CAS.
|
||||||
|
///
|
||||||
|
/// Ownership moves by UPDATE — no row is inserted or deleted on the
|
||||||
|
/// [`SaleBuyer::Club`] path, which is what structurally rules out the duplication
|
||||||
|
/// a mint-based "buy" causes.
|
||||||
|
///
|
||||||
|
/// Fail-closed and all-or-nothing. Rejected without touching any balance:
|
||||||
|
/// negative `gross`/`fee`, `fee > gross`, an item the named seller does not own
|
||||||
|
/// (including a replay, where ownership has already moved), a buyer that cannot
|
||||||
|
/// afford `gross`, or a buyer that is already the seller (not a market path — it
|
||||||
|
/// would otherwise credit and debit the same club and destroy the fee for nothing).
|
||||||
|
///
|
||||||
|
/// Ownership is judged BEFORE affordability, so a replay is reported as "not owned"
|
||||||
|
/// rather than as the buyer being broke from the sale that already succeeded.
|
||||||
|
///
|
||||||
|
/// Coin conservation for [`SaleBuyer::Club`]: `gross` leaves the buyer, `gross -
|
||||||
|
/// fee` reaches the seller, and the economy shrinks by exactly `fee`.
|
||||||
|
pub async fn settle_sale(
|
||||||
|
pool: &Pool,
|
||||||
|
item_id: &str,
|
||||||
|
seller_club_id: &str,
|
||||||
|
buyer: SaleBuyer<'_>,
|
||||||
|
terms: SaleTerms,
|
||||||
|
) -> AppResult<SaleReceipt> {
|
||||||
|
let SaleTerms { gross, fee } = terms;
|
||||||
|
if gross < 0 {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"sale gross must be non-negative".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if fee < 0 {
|
||||||
|
return Err(AppError::BadRequest("sale fee must be non-negative".into()));
|
||||||
|
}
|
||||||
|
if fee > gross {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"sale fee {fee} exceeds gross {gross}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if let SaleBuyer::Club(buyer_club) = buyer {
|
||||||
|
if buyer_club == seller_club_id {
|
||||||
|
return Err(AppError::Conflict(format!(
|
||||||
|
"buyer and seller are the same club: {buyer_club}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let proceeds = gross - fee;
|
||||||
|
let mut conn = pool.acquire().await?;
|
||||||
|
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
|
||||||
|
let result = async {
|
||||||
|
let (card_id, buyer_club_id, buyer_balance, squad_slots_freed) = match buyer {
|
||||||
|
SaleBuyer::Club(buyer_club) => {
|
||||||
|
// Ownership is checked FIRST, and the order matters for the REASON a
|
||||||
|
// rejection carries even though it cannot change the final state
|
||||||
|
// (everything here shares one transaction, so any error rolls the
|
||||||
|
// whole thing back either way).
|
||||||
|
//
|
||||||
|
// Debiting first made a replayed settlement fail as "insufficient
|
||||||
|
// balance": after a sale the buyer has already spent the coins, so the
|
||||||
|
// affordability guard fired before the ownership CAS was ever
|
||||||
|
// consulted. That is a misleading answer to "why was this refused",
|
||||||
|
// and a retry test written against it passes without ever exercising
|
||||||
|
// the replay guard it claims to test.
|
||||||
|
let freed = evict_from_squads(&mut conn, item_id).await?;
|
||||||
|
let card_id = transfer_item(&mut conn, item_id, seller_club_id, buyer_club).await?;
|
||||||
|
let buyer_balance = debit(&mut conn, buyer_club, gross).await?;
|
||||||
|
(
|
||||||
|
card_id,
|
||||||
|
Some(buyer_club.to_string()),
|
||||||
|
Some(buyer_balance),
|
||||||
|
freed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SaleBuyer::Outside => {
|
||||||
|
let freed = evict_from_squads(&mut conn, item_id).await?;
|
||||||
|
let card_id = remove_item(&mut conn, seller_club_id, item_id).await?;
|
||||||
|
(card_id, None, None, freed)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let seller_balance = credit(&mut conn, seller_club_id, proceeds).await?;
|
||||||
|
Ok(SaleReceipt {
|
||||||
|
item_id: item_id.to_string(),
|
||||||
|
card_id,
|
||||||
|
seller_club_id: seller_club_id.to_string(),
|
||||||
|
buyer_club_id,
|
||||||
|
gross,
|
||||||
|
fee,
|
||||||
|
proceeds,
|
||||||
|
seller_balance,
|
||||||
|
buyer_balance,
|
||||||
|
squad_slots_freed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
finish(&mut conn, result).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -351,6 +629,442 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Two-party market fixture. Deliberately NOT the single-club `fixture()`:
|
||||||
|
/// settling a sale where the buyer is also the seller is not a market path and
|
||||||
|
/// would hide every ownership bug this suite exists to catch.
|
||||||
|
///
|
||||||
|
/// Seller `club-a` holds 1_000 coins and owns `item-x`; buyer `club-b` holds
|
||||||
|
/// 20_000 and owns nothing. Profiles are seeded by raw SQL, which sidesteps the
|
||||||
|
/// one-profile-per-game guard in `services::profile`.
|
||||||
|
async fn market_fixture() -> Pool {
|
||||||
|
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.expect("in-memory sqlite");
|
||||||
|
sqlx::migrate!("./migrations")
|
||||||
|
.run(&pool)
|
||||||
|
.await
|
||||||
|
.expect("migrations");
|
||||||
|
for (profile, club, coins) in [("prof-a", "club-a", 1_000i64), ("prof-b", "club-b", 20_000)]
|
||||||
|
{
|
||||||
|
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(coins)
|
||||||
|
.bind(TS)
|
||||||
|
.bind(TS)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("club");
|
||||||
|
}
|
||||||
|
sqlx::query("INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) VALUES (?, ?, ?, 0, ?)")
|
||||||
|
.bind("item-x")
|
||||||
|
.bind("club-a")
|
||||||
|
.bind("def-x")
|
||||||
|
.bind(TS)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("owned card");
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn owner(pool: &Pool, item_id: &str) -> Option<String> {
|
||||||
|
sqlx::query_scalar::<_, String>("SELECT club_id FROM owned_cards WHERE id = ?")
|
||||||
|
.bind(item_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total coins across every modelled balance — the quantity a sale between two
|
||||||
|
/// clubs must reduce by EXACTLY the fee.
|
||||||
|
async fn total_coins(pool: &Pool) -> i64 {
|
||||||
|
sqlx::query_scalar::<_, i64>("SELECT COALESCE(SUM(coins), 0) FROM clubs")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put `item-x` in a squad owned by `club_id`, returning nothing. Used to prove
|
||||||
|
/// a sold card cannot stay in the previous owner's lineup.
|
||||||
|
async fn squad_up(pool: &Pool, club_id: &str, item_id: &str) {
|
||||||
|
sqlx::query("INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES (?, ?, 'S', '4-4-2', ?, ?)")
|
||||||
|
.bind("squad-1")
|
||||||
|
.bind(club_id)
|
||||||
|
.bind(TS)
|
||||||
|
.bind(TS)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("squad");
|
||||||
|
sqlx::query("INSERT INTO squad_players (id, squad_id, owned_card_id, position_index) VALUES (?, ?, ?, 0)")
|
||||||
|
.bind("sp-1")
|
||||||
|
.bind("squad-1")
|
||||||
|
.bind(item_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("squad player");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn squad_slot_count(pool: &Pool) -> i64 {
|
||||||
|
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM squad_players")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
const CANON_GROSS: i64 = 15_000;
|
||||||
|
const CANON_FEE: i64 = 750;
|
||||||
|
|
||||||
|
/// THE canonical settlement: gross 15_000, fee 750, proceeds 14_250.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn settle_sale_transfers_ownership_and_splits_coins() {
|
||||||
|
let pool = market_fixture().await;
|
||||||
|
assert_eq!(total_coins(&pool).await, 21_000);
|
||||||
|
|
||||||
|
let receipt = settle_sale(
|
||||||
|
&pool,
|
||||||
|
"item-x",
|
||||||
|
"club-a",
|
||||||
|
SaleBuyer::Club("club-b"),
|
||||||
|
SaleTerms {
|
||||||
|
gross: CANON_GROSS,
|
||||||
|
fee: CANON_FEE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("settlement");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
receipt.seller_club_id, "club-a",
|
||||||
|
"seller derived from owner"
|
||||||
|
);
|
||||||
|
assert_eq!(receipt.buyer_club_id.as_deref(), Some("club-b"));
|
||||||
|
assert_eq!(receipt.card_id, "def-x");
|
||||||
|
assert_eq!(receipt.gross, 15_000);
|
||||||
|
assert_eq!(receipt.fee, 750);
|
||||||
|
assert_eq!(receipt.proceeds, 14_250);
|
||||||
|
assert_eq!(receipt.buyer_balance, Some(5_000), "20_000 - 15_000");
|
||||||
|
assert_eq!(receipt.seller_balance, 15_250, "1_000 + 14_250");
|
||||||
|
|
||||||
|
assert_eq!(balance(&pool, "club-b").await.unwrap(), 5_000);
|
||||||
|
assert_eq!(balance(&pool, "club-a").await.unwrap(), 15_250);
|
||||||
|
assert_eq!(owner(&pool, "item-x").await.as_deref(), Some("club-b"));
|
||||||
|
// The decisive anti-duplication assertion: ONE authoritative instance,
|
||||||
|
// the same id as before. A mint-based buy would make this 2.
|
||||||
|
assert_eq!(item_count(&pool, "item-x").await, 1);
|
||||||
|
assert_eq!(
|
||||||
|
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
1,
|
||||||
|
"no second row anywhere in the inventory"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// buyer_debit == seller_credit + fee, and the economy shrinks by exactly the
|
||||||
|
/// fee. This is the invariant that catches a coin created or destroyed by a
|
||||||
|
/// rounding or ordering mistake.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sale_conserves_coins_minus_the_fee() {
|
||||||
|
let pool = market_fixture().await;
|
||||||
|
let before = total_coins(&pool).await;
|
||||||
|
let receipt = settle_sale(
|
||||||
|
&pool,
|
||||||
|
"item-x",
|
||||||
|
"club-a",
|
||||||
|
SaleBuyer::Club("club-b"),
|
||||||
|
SaleTerms {
|
||||||
|
gross: CANON_GROSS,
|
||||||
|
fee: CANON_FEE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let after = total_coins(&pool).await;
|
||||||
|
|
||||||
|
assert_eq!(before, 21_000);
|
||||||
|
assert_eq!(after, 20_250);
|
||||||
|
assert_eq!(before - after, receipt.fee, "economy shrinks by the fee");
|
||||||
|
assert_eq!(
|
||||||
|
receipt.gross,
|
||||||
|
receipt.proceeds + receipt.fee,
|
||||||
|
"buyer debit == seller credit + fee"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A sold card must leave the previous owner's lineup. Without eviction the
|
||||||
|
/// seller keeps fielding a card the buyer owns (and on the `Outside` path the
|
||||||
|
/// FK makes the delete fail outright).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sale_evicts_the_item_from_the_sellers_squad() {
|
||||||
|
let pool = market_fixture().await;
|
||||||
|
squad_up(&pool, "club-a", "item-x").await;
|
||||||
|
assert_eq!(squad_slot_count(&pool).await, 1);
|
||||||
|
|
||||||
|
let receipt = settle_sale(
|
||||||
|
&pool,
|
||||||
|
"item-x",
|
||||||
|
"club-a",
|
||||||
|
SaleBuyer::Club("club-b"),
|
||||||
|
SaleTerms {
|
||||||
|
gross: 1_000,
|
||||||
|
fee: 50,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(receipt.squad_slots_freed, 1);
|
||||||
|
assert_eq!(
|
||||||
|
squad_slot_count(&pool).await,
|
||||||
|
0,
|
||||||
|
"stale lineup slot survived"
|
||||||
|
);
|
||||||
|
assert_eq!(owner(&pool, "item-x").await.as_deref(), Some("club-b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selling to a counterparty outside the modelled economy: the seller is paid
|
||||||
|
/// net and the item leaves the inventory. No club is debited, so the seller's
|
||||||
|
/// proceeds legitimately ENTER the economy.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn settle_sale_to_outside_retires_the_item_and_pays_net() {
|
||||||
|
let pool = market_fixture().await;
|
||||||
|
squad_up(&pool, "club-a", "item-x").await;
|
||||||
|
|
||||||
|
let receipt = settle_sale(
|
||||||
|
&pool,
|
||||||
|
"item-x",
|
||||||
|
"club-a",
|
||||||
|
SaleBuyer::Outside,
|
||||||
|
SaleTerms {
|
||||||
|
gross: CANON_GROSS,
|
||||||
|
fee: CANON_FEE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(receipt.buyer_club_id, None);
|
||||||
|
assert_eq!(receipt.buyer_balance, None);
|
||||||
|
assert_eq!(receipt.seller_balance, 15_250);
|
||||||
|
assert_eq!(receipt.squad_slots_freed, 1);
|
||||||
|
assert_eq!(item_count(&pool, "item-x").await, 0, "item was retired");
|
||||||
|
assert_eq!(balance(&pool, "club-b").await.unwrap(), 20_000, "untouched");
|
||||||
|
// Buyer coins are not modelled, so total coins RISE by the proceeds here.
|
||||||
|
assert_eq!(total_coins(&pool).await, 21_000 + 14_250);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every invalid sale must leave the economy bit-identical.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalid_sales_change_nothing() {
|
||||||
|
let terms = SaleTerms {
|
||||||
|
gross: CANON_GROSS,
|
||||||
|
fee: CANON_FEE,
|
||||||
|
};
|
||||||
|
// (label, item, buyer, terms) -> must fail without mutating anything
|
||||||
|
let cases: Vec<(&str, &str, &str, SaleBuyer<'_>, SaleTerms)> = vec![
|
||||||
|
(
|
||||||
|
"buyer cannot afford",
|
||||||
|
"item-x",
|
||||||
|
"club-a",
|
||||||
|
SaleBuyer::Club("club-b"),
|
||||||
|
SaleTerms {
|
||||||
|
gross: 20_001,
|
||||||
|
fee: 0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"buyer is the seller",
|
||||||
|
"item-x",
|
||||||
|
"club-a",
|
||||||
|
SaleBuyer::Club("club-a"),
|
||||||
|
terms,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"item does not exist",
|
||||||
|
"ghost",
|
||||||
|
"club-a",
|
||||||
|
SaleBuyer::Club("club-b"),
|
||||||
|
terms,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"buyer club does not exist",
|
||||||
|
"item-x",
|
||||||
|
"club-a",
|
||||||
|
SaleBuyer::Club("ghost"),
|
||||||
|
terms,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"fee exceeds gross",
|
||||||
|
"item-x",
|
||||||
|
"club-a",
|
||||||
|
SaleBuyer::Club("club-b"),
|
||||||
|
SaleTerms {
|
||||||
|
gross: 100,
|
||||||
|
fee: 101,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"negative fee",
|
||||||
|
"item-x",
|
||||||
|
"club-a",
|
||||||
|
SaleBuyer::Club("club-b"),
|
||||||
|
SaleTerms {
|
||||||
|
gross: 100,
|
||||||
|
fee: -1,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"negative gross",
|
||||||
|
"item-x",
|
||||||
|
"club-a",
|
||||||
|
SaleBuyer::Club("club-b"),
|
||||||
|
SaleTerms { gross: -1, fee: 0 },
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"outside sale of a missing item",
|
||||||
|
"ghost",
|
||||||
|
"club-a",
|
||||||
|
SaleBuyer::Outside,
|
||||||
|
terms,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (label, item, seller, buyer, terms) in cases {
|
||||||
|
let pool = market_fixture().await;
|
||||||
|
let err = settle_sale(&pool, item, seller, buyer, terms).await;
|
||||||
|
assert!(err.is_err(), "{label}: expected rejection");
|
||||||
|
assert_eq!(balance(&pool, "club-a").await.unwrap(), 1_000, "{label}");
|
||||||
|
assert_eq!(balance(&pool, "club-b").await.unwrap(), 20_000, "{label}");
|
||||||
|
assert_eq!(
|
||||||
|
owner(&pool, "item-x").await.as_deref(),
|
||||||
|
Some("club-a"),
|
||||||
|
"{label}: ownership moved on a rejected sale"
|
||||||
|
);
|
||||||
|
assert_eq!(item_count(&pool, "item-x").await, 1, "{label}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A zero-price sale is legal (a free transfer) and pays no fee.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn zero_price_sale_is_a_free_transfer() {
|
||||||
|
let pool = market_fixture().await;
|
||||||
|
let receipt = settle_sale(
|
||||||
|
&pool,
|
||||||
|
"item-x",
|
||||||
|
"club-a",
|
||||||
|
SaleBuyer::Club("club-b"),
|
||||||
|
SaleTerms { gross: 0, fee: 0 },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(receipt.proceeds, 0);
|
||||||
|
assert_eq!(total_coins(&pool).await, 21_000, "no coins moved");
|
||||||
|
assert_eq!(owner(&pool, "item-x").await.as_deref(), Some("club-b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Settling the SAME sale twice must not pay the seller twice, and must be
|
||||||
|
/// refused for the RIGHT reason: the item is no longer the seller's.
|
||||||
|
///
|
||||||
|
/// Asserting only "the second call failed" is not enough, and this test used to
|
||||||
|
/// make exactly that mistake. When the buyer's debit ran first, a replay of the
|
||||||
|
/// canonical sale was rejected because the buyer had already spent the coins —
|
||||||
|
/// the ownership CAS was never reached, so the test passed while proving nothing
|
||||||
|
/// about replay safety. Both a same-price replay and a cheap AFFORDABLE replay
|
||||||
|
/// are checked, and the error must name ownership in each.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn settling_the_same_sale_twice_pays_once() {
|
||||||
|
let pool = market_fixture().await;
|
||||||
|
let terms = SaleTerms {
|
||||||
|
gross: CANON_GROSS,
|
||||||
|
fee: CANON_FEE,
|
||||||
|
};
|
||||||
|
settle_sale(&pool, "item-x", "club-a", SaleBuyer::Club("club-b"), terms)
|
||||||
|
.await
|
||||||
|
.expect("first settlement");
|
||||||
|
|
||||||
|
for (label, replay) in [
|
||||||
|
("same price", terms),
|
||||||
|
// Trivially affordable out of the 5_000 the buyer has left, so ownership
|
||||||
|
// is the ONLY thing that can refuse it.
|
||||||
|
("affordable", SaleTerms { gross: 100, fee: 5 }),
|
||||||
|
] {
|
||||||
|
let again =
|
||||||
|
settle_sale(&pool, "item-x", "club-a", SaleBuyer::Club("club-b"), replay).await;
|
||||||
|
assert!(
|
||||||
|
matches!(again, Err(AppError::NotFound(_))),
|
||||||
|
"{label} replay must be refused on OWNERSHIP, got {again:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(balance(&pool, "club-a").await.unwrap(), 15_250, "paid once");
|
||||||
|
assert_eq!(
|
||||||
|
balance(&pool, "club-b").await.unwrap(),
|
||||||
|
5_000,
|
||||||
|
"debited once"
|
||||||
|
);
|
||||||
|
assert_eq!(item_count(&pool, "item-x").await, 1);
|
||||||
|
assert_eq!(total_coins(&pool).await, 20_250, "fee taken once");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two buyers racing the same listing: exactly one wins, and the loser's
|
||||||
|
/// balance is untouched. This is where a market bug becomes a duplication
|
||||||
|
/// exploit, so it is asserted on the authoritative state, not on call counts.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn two_buyers_racing_one_item_settle_once() {
|
||||||
|
let pool = market_fixture().await;
|
||||||
|
sqlx::query("INSERT INTO profiles (id, username, created_at, updated_at) VALUES ('prof-c','prof-c',?,?)")
|
||||||
|
.bind(TS).bind(TS).execute(&pool).await.unwrap();
|
||||||
|
sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES ('club-c','prof-c','club-c',20000,?,?)")
|
||||||
|
.bind(TS).bind(TS).execute(&pool).await.unwrap();
|
||||||
|
|
||||||
|
let terms = SaleTerms {
|
||||||
|
gross: CANON_GROSS,
|
||||||
|
fee: CANON_FEE,
|
||||||
|
};
|
||||||
|
let (b, c) = tokio::join!(
|
||||||
|
settle_sale(&pool, "item-x", "club-a", SaleBuyer::Club("club-b"), terms),
|
||||||
|
settle_sale(&pool, "item-x", "club-a", SaleBuyer::Club("club-c"), terms),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
[b.is_ok(), c.is_ok()].iter().filter(|ok| **ok).count(),
|
||||||
|
1,
|
||||||
|
"exactly one buyer may win"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
item_count(&pool, "item-x").await,
|
||||||
|
1,
|
||||||
|
"no duplicate instance"
|
||||||
|
);
|
||||||
|
let winner = owner(&pool, "item-x").await.expect("item still owned");
|
||||||
|
assert!(winner == "club-b" || winner == "club-c");
|
||||||
|
let loser = if winner == "club-b" {
|
||||||
|
"club-c"
|
||||||
|
} else {
|
||||||
|
"club-b"
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
balance(&pool, loser).await.unwrap(),
|
||||||
|
20_000,
|
||||||
|
"the losing buyer must be untouched"
|
||||||
|
);
|
||||||
|
assert_eq!(balance(&pool, &winner).await.unwrap(), 5_000);
|
||||||
|
assert_eq!(balance(&pool, "club-a").await.unwrap(), 15_250, "paid once");
|
||||||
|
assert_eq!(total_coins(&pool).await, 40_250, "41_000 - 750 fee, once");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn balance_reads_seeded_value() {
|
async fn balance_reads_seeded_value() {
|
||||||
let pool = fixture().await;
|
let pool = fixture().await;
|
||||||
@@ -486,6 +1200,49 @@ mod tests {
|
|||||||
assert_eq!(balance(&pool, "club").await.unwrap(), 1250);
|
assert_eq!(balance(&pool, "club").await.unwrap(), 1250);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Quick-selling a card that is IN A SQUAD must work.
|
||||||
|
///
|
||||||
|
/// `squad_players.owned_card_id` is a FK onto `owned_cards(id)` and the pool
|
||||||
|
/// enables `foreign_keys`, so deleting a squadded item fails outright. This is
|
||||||
|
/// the live FIFA 17 quick-sell path (`econ.sell_item` -> `POST
|
||||||
|
/// /economy/sell-item`), and a player selling a card that is in their lineup is
|
||||||
|
/// completely ordinary — it is not an edge case.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn selling_a_squadded_item_succeeds_and_frees_the_slot() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
squad_up(&pool, "club", "item-x").await;
|
||||||
|
assert_eq!(squad_slot_count(&pool).await, 1);
|
||||||
|
|
||||||
|
let new_balance = sell_item(&pool, "club", "item-x", 250)
|
||||||
|
.await
|
||||||
|
.expect("quick sell of a squadded card");
|
||||||
|
assert_eq!(new_balance, 1250);
|
||||||
|
assert_eq!(item_count(&pool, "item-x").await, 0);
|
||||||
|
assert_eq!(
|
||||||
|
squad_slot_count(&pool).await,
|
||||||
|
0,
|
||||||
|
"the lineup slot must be freed, not left dangling"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rejected quick sell must not strip the real owner's lineup. Eviction runs
|
||||||
|
/// before the ownership check, so this proves the shared transaction actually
|
||||||
|
/// rolls it back rather than leaving a half-applied squad change.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_rejected_quick_sell_leaves_the_lineup_intact() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
squad_up(&pool, "club", "item-x").await;
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
sell_item(&pool, "someone-else", "item-x", 250).await,
|
||||||
|
Err(AppError::NotFound(_))
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(squad_slot_count(&pool).await, 1, "lineup was disturbed");
|
||||||
|
assert_eq!(item_count(&pool, "item-x").await, 1);
|
||||||
|
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn grant_reward_credits() {
|
async fn grant_reward_credits() {
|
||||||
let pool = fixture().await;
|
let pool = fixture().await;
|
||||||
@@ -515,4 +1272,38 @@ mod tests {
|
|||||||
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
|
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
|
||||||
assert_eq!(item_count(&pool, "item-new").await, 0);
|
assert_eq!(item_count(&pool, "item-new").await, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn purchase_items_debits_and_mints_all() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
let items = vec![
|
||||||
|
GrantedItem {
|
||||||
|
item_id: "p-1".into(),
|
||||||
|
card_id: "d-1".into(),
|
||||||
|
},
|
||||||
|
GrantedItem {
|
||||||
|
item_id: "p-2".into(),
|
||||||
|
card_id: "d-2".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let bal = purchase_items(&pool, "club", 700, &items).await.unwrap();
|
||||||
|
assert_eq!(bal, 300);
|
||||||
|
assert_eq!(item_count(&pool, "p-1").await, 1);
|
||||||
|
assert_eq!(item_count(&pool, "p-2").await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn purchase_items_insufficient_funds_rolls_back() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
let items = vec![GrantedItem {
|
||||||
|
item_id: "p-1".into(),
|
||||||
|
card_id: "d-1".into(),
|
||||||
|
}];
|
||||||
|
let err = purchase_items(&pool, "club", 9000, &items)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::BadRequest(_)));
|
||||||
|
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
|
||||||
|
assert_eq!(item_count(&pool, "p-1").await, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ pub async fn get_active_session(
|
|||||||
.map_err(Into::into)
|
.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!(
|
sqlx::query_as::<_, FutChampsSession>(&format!(
|
||||||
"{SESSION_SELECT} WHERE id = ? AND profile_id = ?"
|
"{SESSION_SELECT} WHERE id = ? AND profile_id = ?"
|
||||||
))
|
))
|
||||||
|
|||||||
+173
-2
@@ -19,7 +19,10 @@
|
|||||||
//! non-imported profile is never clobbered.
|
//! non-imported profile is never clobbered.
|
||||||
//! - The whole thing commits together or not at all.
|
//! - The whole thing commits together or not at all.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use crate::db::Pool;
|
use crate::db::Pool;
|
||||||
|
use crate::models::card::ContentKind;
|
||||||
use crate::models::game_ext::{MAX_EXT_NAMESPACE_LEN, MAX_EXT_PAYLOAD_BYTES};
|
use crate::models::game_ext::{MAX_EXT_NAMESPACE_LEN, MAX_EXT_PAYLOAD_BYTES};
|
||||||
use crate::services::card_db::CardDb;
|
use crate::services::card_db::CardDb;
|
||||||
use crate::services::squad::squad_fingerprint;
|
use crate::services::squad::squad_fingerprint;
|
||||||
@@ -49,6 +52,27 @@ pub struct ImportOwnedCard {
|
|||||||
pub owned_item_id: String,
|
pub owned_item_id: String,
|
||||||
/// CardDefinitionId that MUST resolve in loaded production content.
|
/// CardDefinitionId that MUST resolve in loaded production content.
|
||||||
pub card_id: String,
|
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)]
|
||||||
|
pub struct ImportEntitlement {
|
||||||
|
/// Opaque definition reference for one unconsumed entitlement (e.g. a pack
|
||||||
|
/// id as text). Core stores it verbatim; it never interprets the value.
|
||||||
|
pub definition_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -93,6 +117,9 @@ pub struct ProfileImportRequest {
|
|||||||
pub owned: Vec<ImportOwnedCard>,
|
pub owned: Vec<ImportOwnedCard>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub squad: Option<ImportSquad>,
|
pub squad: Option<ImportSquad>,
|
||||||
|
/// Unconsumed entitlements to seed (e.g. from a source's unopened packs).
|
||||||
|
#[serde(default)]
|
||||||
|
pub entitlements: Vec<ImportEntitlement>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, PartialEq, Eq)]
|
#[derive(Debug, Serialize, PartialEq, Eq)]
|
||||||
@@ -117,6 +144,19 @@ pub async fn apply_profile_import(
|
|||||||
if req.owned.is_empty() {
|
if req.owned.is_empty() {
|
||||||
bail!("import request has zero owned cards; refusing to import an empty profile");
|
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 ──
|
// ── 1. rerun identity / single-profile-per-game ──
|
||||||
let existing: Option<(String, Option<String>)> = sqlx::query_as(
|
let existing: Option<(String, Option<String>)> = sqlx::query_as(
|
||||||
@@ -232,18 +272,35 @@ pub async fn apply_profile_import(
|
|||||||
|
|
||||||
for o in &req.owned {
|
for o in &req.owned {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \
|
"INSERT INTO owned_cards \
|
||||||
VALUES (?, ?, ?, 0, NULL, ?)",
|
(id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
|
||||||
|
content_kind, quantity) \
|
||||||
|
VALUES (?, ?, ?, 0, NULL, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&o.owned_item_id)
|
.bind(&o.owned_item_id)
|
||||||
.bind(&club_id)
|
.bind(&club_id)
|
||||||
.bind(&o.card_id)
|
.bind(&o.card_id)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
|
.bind(o.content_kind.as_str())
|
||||||
|
.bind(o.quantity)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("insert owned_card {}", o.owned_item_id))?;
|
.with_context(|| format!("insert owned_card {}", o.owned_item_id))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for e in &req.entitlements {
|
||||||
|
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(&e.definition_id)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("insert entitlement {}", e.definition_id))?;
|
||||||
|
}
|
||||||
|
|
||||||
let mut squad_slots = 0usize;
|
let mut squad_slots = 0usize;
|
||||||
if let Some(sq) = &req.squad {
|
if let Some(sq) = &req.squad {
|
||||||
let squad_id = Uuid::new_v4().to_string();
|
let squad_id = Uuid::new_v4().to_string();
|
||||||
@@ -316,3 +373,117 @@ pub async fn apply_profile_import(
|
|||||||
squad_slots,
|
squad_slots,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One adapter-supplied classification: "every owned row of this definition is
|
||||||
|
/// really this kind of content".
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ContentKindAssignment {
|
||||||
|
pub card_id: String,
|
||||||
|
pub content_kind: ContentKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request for [`reclassify_owned_content`].
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ReclassifyRequest {
|
||||||
|
pub game_id: String,
|
||||||
|
pub assignments: Vec<ContentKindAssignment>,
|
||||||
|
/// Compute the outcome and roll back instead of committing. Lets an operator
|
||||||
|
/// see exactly what a production run would touch before it touches it.
|
||||||
|
#[serde(default)]
|
||||||
|
pub dry_run: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, PartialEq, Eq)]
|
||||||
|
pub struct ReclassifyOutcome {
|
||||||
|
/// Rows whose `content_kind` actually changed. On a dry run, the rows that
|
||||||
|
/// WOULD change; nothing is committed.
|
||||||
|
pub updated: usize,
|
||||||
|
/// Rows already carrying the requested kind (a rerun updates nothing).
|
||||||
|
pub unchanged: usize,
|
||||||
|
/// Assignments naming a definition this game owns no copy of.
|
||||||
|
pub unmatched_definitions: Vec<String>,
|
||||||
|
/// True when the transaction was rolled back rather than committed.
|
||||||
|
pub dry_run: bool,
|
||||||
|
/// Per-kind tally of the rows that changed, so an operator can sanity-check
|
||||||
|
/// the shape of the change ("3 staff, 17 consumable") before committing.
|
||||||
|
pub updated_by_kind: BTreeMap<String, usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Correct the `content_kind` of ALREADY-IMPORTED owned rows, in one transaction.
|
||||||
|
///
|
||||||
|
/// A profile import is once-only (same fingerprint no-ops, a different one is
|
||||||
|
/// refused), so a taxonomy fix cannot arrive by re-importing. Core defaults an
|
||||||
|
/// unstated row to `player`, which means every pre-taxonomy import durably
|
||||||
|
/// recorded coaches, kits and consumables as players — wrong in the ownership
|
||||||
|
/// authority even where a catalog-driven wire looked right.
|
||||||
|
///
|
||||||
|
/// Core stays generic: the caller supplies `card_id -> kind`, because only the
|
||||||
|
/// game adapter can map its own taxonomy. Idempotent, and scoped to one game's
|
||||||
|
/// clubs so a shared database cannot be reclassified across games.
|
||||||
|
pub async fn reclassify_owned_content(
|
||||||
|
pool: &Pool,
|
||||||
|
req: &ReclassifyRequest,
|
||||||
|
) -> Result<ReclassifyOutcome> {
|
||||||
|
if req.assignments.is_empty() {
|
||||||
|
bail!("reclassify request has zero assignments");
|
||||||
|
}
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
let mut updated = 0usize;
|
||||||
|
let mut unchanged = 0usize;
|
||||||
|
let mut unmatched = Vec::new();
|
||||||
|
let mut by_kind: BTreeMap<String, usize> = BTreeMap::new();
|
||||||
|
for a in &req.assignments {
|
||||||
|
// Scope by game through the owning club, so the same definition id in
|
||||||
|
// another game is never touched.
|
||||||
|
let present: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM owned_cards o JOIN clubs c ON c.id = o.club_id \
|
||||||
|
JOIN profiles p ON p.id = c.profile_id \
|
||||||
|
WHERE p.game_id = ? AND o.card_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&req.game_id)
|
||||||
|
.bind(&a.card_id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.context("count owned rows for definition")?;
|
||||||
|
if present == 0 {
|
||||||
|
unmatched.push(a.card_id.clone());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let changed = sqlx::query(
|
||||||
|
"UPDATE owned_cards SET content_kind = ? \
|
||||||
|
WHERE card_id = ? AND content_kind != ? AND club_id IN \
|
||||||
|
(SELECT c.id FROM clubs c JOIN profiles p ON p.id = c.profile_id \
|
||||||
|
WHERE p.game_id = ?)",
|
||||||
|
)
|
||||||
|
.bind(a.content_kind.as_str())
|
||||||
|
.bind(&a.card_id)
|
||||||
|
.bind(a.content_kind.as_str())
|
||||||
|
.bind(&req.game_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.context("update owned content_kind")?
|
||||||
|
.rows_affected() as usize;
|
||||||
|
updated += changed;
|
||||||
|
unchanged += present as usize - changed;
|
||||||
|
if changed > 0 {
|
||||||
|
*by_kind
|
||||||
|
.entry(a.content_kind.as_str().to_string())
|
||||||
|
.or_default() += changed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A dry run does the real UPDATEs and then throws them away, so the counts
|
||||||
|
// it reports are measured rather than predicted — the same statements, the
|
||||||
|
// same WHERE clauses, just no commit.
|
||||||
|
if req.dry_run {
|
||||||
|
tx.rollback().await?;
|
||||||
|
} else {
|
||||||
|
tx.commit().await?;
|
||||||
|
}
|
||||||
|
Ok(ReclassifyOutcome {
|
||||||
|
updated,
|
||||||
|
unchanged,
|
||||||
|
unmatched_definitions: unmatched,
|
||||||
|
dry_run: req.dry_run,
|
||||||
|
updated_by_kind: by_kind,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
use serde::Deserialize;
|
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
|
/// Semantic owned-inventory query. All values are game-independent: a quality
|
||||||
/// tier, entity **names** (not ids), and semantic offset/limit. Every filter is
|
/// 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.
|
/// Quality tier (gold/silver/bronze). Serialized lowercase.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub quality: Option<Quality>,
|
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).
|
/// Playing position, e.g. "ST" (matched case-insensitively).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub position: Option<String>,
|
pub position: Option<String>,
|
||||||
@@ -47,8 +50,12 @@ pub struct OwnedItemQuery {
|
|||||||
|
|
||||||
/// One owned item projected to the attributes needed for querying, plus the
|
/// One owned item projected to the attributes needed for querying, plus the
|
||||||
/// response body to hand back verbatim once it survives the filter+page.
|
/// response body to hand back verbatim once it survives the filter+page.
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct OwnedItemView {
|
pub struct OwnedItemView {
|
||||||
pub owned_card_id: String,
|
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).
|
/// Base card overall (drives quality tier).
|
||||||
pub base_overall: u8,
|
pub base_overall: u8,
|
||||||
/// Effective overall (base + training bonus); drives ordering.
|
/// Effective overall (base + training bonus); drives ordering.
|
||||||
@@ -78,6 +85,10 @@ pub struct QueryPage {
|
|||||||
/// Does an item satisfy every present filter (AND semantics)?
|
/// Does an item satisfy every present filter (AND semantics)?
|
||||||
fn matches(item: &OwnedItemView, q: &OwnedItemQuery) -> bool {
|
fn matches(item: &OwnedItemView, q: &OwnedItemQuery) -> bool {
|
||||||
let quality_ok = q.quality.map(|want| item.quality() == want).unwrap_or(true);
|
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
|
let pos_ok = q
|
||||||
.position
|
.position
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -98,7 +109,7 @@ fn matches(item: &OwnedItemView, q: &OwnedItemQuery) -> bool {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|c| item.club.eq_ignore_ascii_case(c))
|
.map(|c| item.club.eq_ignore_ascii_case(c))
|
||||||
.unwrap_or(true);
|
.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.
|
/// Apply the query: filter (AND) → deterministic order → paginate.
|
||||||
@@ -152,6 +163,7 @@ mod tests {
|
|||||||
) -> OwnedItemView {
|
) -> OwnedItemView {
|
||||||
OwnedItemView {
|
OwnedItemView {
|
||||||
owned_card_id: id.to_string(),
|
owned_card_id: id.to_string(),
|
||||||
|
content_kind: ContentKind::Player,
|
||||||
base_overall: overall,
|
base_overall: overall,
|
||||||
effective_overall: overall as i64,
|
effective_overall: overall as i64,
|
||||||
position: position.to_string(),
|
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]
|
#[test]
|
||||||
fn no_filter_returns_all_in_overall_desc_order() {
|
fn no_filter_returns_all_in_overall_desc_order() {
|
||||||
let p = apply_query(fixture(), &OwnedItemQuery::default());
|
let p = apply_query(fixture(), &OwnedItemQuery::default());
|
||||||
|
|||||||
+37
-15
@@ -141,12 +141,25 @@ pub async fn buy_listing(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("listing not found or already sold".into()))?;
|
.ok_or_else(|| AppError::NotFound("listing not found or already sold".into()))?;
|
||||||
|
|
||||||
club::spend_coins(pool, club_id, listing.price).await?;
|
// Atomically claim the listing (flip sold 0->1) before charging, so two concurrent
|
||||||
|
// buyers cannot both mint the same card. If the debit then fails, release the claim.
|
||||||
sqlx::query("UPDATE market_listings SET sold = 1 WHERE id = ?")
|
let claimed = sqlx::query("UPDATE market_listings SET sold = 1 WHERE id = ? AND sold = 0")
|
||||||
.bind(&listing.id)
|
.bind(&listing.id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
if claimed == 0 {
|
||||||
|
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 = ?")
|
||||||
|
.bind(&listing.id)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
|
||||||
let owned_id = Uuid::new_v4().to_string();
|
let owned_id = Uuid::new_v4().to_string();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -195,21 +208,27 @@ pub async fn sell_card(
|
|||||||
return Err(AppError::BadRequest("price must be non-negative".into()));
|
return Err(AppError::BadRequest("price must be non-negative".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let owned = sqlx::query_as::<_, crate::models::card::OwnedCard>(
|
let owned = sqlx::query_as::<_, crate::models::card::OwnedCard>(&format!(
|
||||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
|
"{} WHERE id = ? AND club_id = ?",
|
||||||
chemistry_style, position_override, training_bonus \
|
crate::models::card::OWNED_CARD_SELECT
|
||||||
FROM owned_cards WHERE id = ? AND club_id = ?",
|
))
|
||||||
)
|
|
||||||
.bind(&req.owned_card_id)
|
.bind(&req.owned_card_id)
|
||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("owned card not found".into()))?;
|
.ok_or_else(|| AppError::NotFound("owned card not found".into()))?;
|
||||||
|
|
||||||
sqlx::query("DELETE FROM owned_cards WHERE id = ?")
|
// Atomically claim the card: guard the DELETE with the owner + rows_affected so two
|
||||||
|
// concurrent sells of the same card cannot both credit (double payout).
|
||||||
|
let deleted = sqlx::query("DELETE FROM owned_cards WHERE id = ? AND club_id = ?")
|
||||||
.bind(&req.owned_card_id)
|
.bind(&req.owned_card_id)
|
||||||
|
.bind(club_id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
if deleted == 0 {
|
||||||
|
return Err(AppError::NotFound("owned card not found".into()));
|
||||||
|
}
|
||||||
|
|
||||||
let coins = (req.price as f64 * 0.4) as i64;
|
let coins = (req.price as f64 * 0.4) as i64;
|
||||||
let new_balance = club::add_coins(pool, club_id, coins).await?;
|
let new_balance = club::add_coins(pool, club_id, coins).await?;
|
||||||
@@ -290,9 +309,10 @@ pub async fn get_listings_by_seller(
|
|||||||
let with_cards = listings
|
let with_cards = listings
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|l| {
|
.filter_map(|l| {
|
||||||
card_db
|
card_db.get(&l.card_id).map(|card| MarketListingWithCard {
|
||||||
.get(&l.card_id)
|
listing: l,
|
||||||
.map(|card| MarketListingWithCard { listing: l, card: card.clone() })
|
card: card.clone(),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok(with_cards)
|
Ok(with_cards)
|
||||||
@@ -308,7 +328,9 @@ pub async fn cancel_listing(pool: &Pool, club_id: &str, listing_id: &str) -> App
|
|||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?
|
.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 = ?")
|
sqlx::query("DELETE FROM market_listings WHERE id = ?")
|
||||||
.bind(listing_id)
|
.bind(listing_id)
|
||||||
|
|||||||
+952
-150
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,14 @@ pub mod achievement;
|
|||||||
pub mod card_db;
|
pub mod card_db;
|
||||||
pub mod checkin;
|
pub mod checkin;
|
||||||
pub mod club;
|
pub mod club;
|
||||||
|
pub mod consume;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
pub mod economy;
|
pub mod economy;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod fut_champs;
|
pub mod fut_champs;
|
||||||
pub mod game_ext;
|
pub mod game_ext;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
|
pub mod instance_effect;
|
||||||
pub mod inventory;
|
pub mod inventory;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
pub mod match_service;
|
pub mod match_service;
|
||||||
@@ -21,4 +23,5 @@ pub mod settings;
|
|||||||
pub mod squad;
|
pub mod squad;
|
||||||
pub mod squad_rules;
|
pub mod squad_rules;
|
||||||
pub mod statistics;
|
pub mod statistics;
|
||||||
|
pub mod training;
|
||||||
pub mod upgrades;
|
pub mod upgrades;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
use sqlx::{Sqlite, Transaction};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -67,10 +68,7 @@ pub async fn increment_metric(
|
|||||||
) -> AppResult<Vec<String>> {
|
) -> AppResult<Vec<String>> {
|
||||||
let mut completed_ids = Vec::new();
|
let mut completed_ids = Vec::new();
|
||||||
|
|
||||||
for def in defs
|
for def in defs.iter().filter(|d| d.metric.as_str() == metric) {
|
||||||
.iter()
|
|
||||||
.filter(|d| d.metric.as_str() == metric)
|
|
||||||
{
|
|
||||||
let existing = sqlx::query_as::<_, ObjectiveProgress>(
|
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 = ?"
|
"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)
|
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(
|
pub async fn claim_objective(
|
||||||
pool: &Pool,
|
pool: &Pool,
|
||||||
profile_id: &str,
|
profile_id: &str,
|
||||||
|
|||||||
+13
-4
@@ -71,7 +71,16 @@ pub async fn open_pack(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("pack {pack_id} not found")))?;
|
.ok_or_else(|| AppError::NotFound(format!("pack {pack_id} not found")))?;
|
||||||
|
|
||||||
if pack.opened {
|
// 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();
|
||||||
|
if claimed == 0 {
|
||||||
return Err(AppError::BadRequest("pack already opened".into()));
|
return Err(AppError::BadRequest("pack already opened".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,11 +133,11 @@ pub async fn open_pack(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let card_ids_json = serde_json::to_string(&cards.iter().map(|c| &c.id).collect::<Vec<_>>())
|
let card_ids_json =
|
||||||
.unwrap_or_default();
|
serde_json::to_string(&cards.iter().map(|c| &c.id).collect::<Vec<_>>()).unwrap_or_default();
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
sqlx::query("UPDATE packs SET opened = 1, opened_cards = ?, opened_at = ? WHERE id = ?")
|
sqlx::query("UPDATE packs SET opened_cards = ?, opened_at = ? WHERE id = ?")
|
||||||
.bind(&card_ids_json)
|
.bind(&card_ids_json)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.bind(pack_id)
|
.bind(pack_id)
|
||||||
|
|||||||
@@ -100,7 +100,11 @@ pub async fn add_xp_with_levelup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!(profile_id, new_level = lvl, coins, "level up");
|
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)
|
Ok(events)
|
||||||
|
|||||||
+1171
-47
File diff suppressed because it is too large
Load Diff
+72
-32
@@ -1,9 +1,9 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
db::Pool,
|
db::Pool,
|
||||||
error::AppResult,
|
error::{AppError, AppResult},
|
||||||
models::season::{Season, SeasonEndSummary, SeasonHistoryEntry, SeasonResult},
|
models::season::{Season, SeasonEndSummary, SeasonHistoryEntry, SeasonResult},
|
||||||
services::{club, pack},
|
|
||||||
};
|
};
|
||||||
|
use sqlx::{Sqlite, Transaction};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Get the current season for a profile, creating it if it doesn't exist.
|
/// Get the current season for a profile, creating it if it doesn't exist.
|
||||||
@@ -20,7 +20,11 @@ pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult<Season> {
|
|||||||
.bind(&now)
|
.bind(&now)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(fetch(pool, profile_id).await?.expect("just inserted"))
|
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>> {
|
async fn fetch(pool: &Pool, profile_id: &str) -> AppResult<Option<Season>> {
|
||||||
@@ -34,21 +38,34 @@ async fn fetch(pool: &Pool, profile_id: &str) -> AppResult<Option<Season>> {
|
|||||||
Ok(s)
|
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.
|
/// Mirrors [`record_match`] but every write — the season row, the rollover, the
|
||||||
pub async fn record_match(
|
/// end-of-season coin and pack award, and the history entry — commits or rolls
|
||||||
pool: &Pool,
|
/// 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,
|
club_id: &str,
|
||||||
profile_id: &str,
|
profile_id: &str,
|
||||||
outcome: &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 {
|
let points = match outcome {
|
||||||
"win" => 3,
|
"win" => 3,
|
||||||
"draw" => 1,
|
"draw" => 1,
|
||||||
_ => 0,
|
_ => 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE seasons SET \
|
"UPDATE seasons SET \
|
||||||
season_points = season_points + ?, \
|
season_points = season_points + ?, \
|
||||||
@@ -63,28 +80,28 @@ pub async fn record_match(
|
|||||||
.bind(outcome)
|
.bind(outcome)
|
||||||
.bind(outcome)
|
.bind(outcome)
|
||||||
.bind(profile_id)
|
.bind(profile_id)
|
||||||
.execute(pool)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let season = fetch(pool, profile_id).await?.expect("season must exist");
|
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() {
|
if !season.is_complete() {
|
||||||
return Ok((season, None));
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Season complete — calculate result and start next
|
|
||||||
let result = season.end_result();
|
let result = season.end_result();
|
||||||
let old_div = season.division;
|
let old_div = season.division;
|
||||||
let coins = season.season_reward_coins();
|
let coins = season.season_reward_coins();
|
||||||
let pack_id = season.season_reward_pack();
|
let pack_id = season.season_reward_pack();
|
||||||
|
|
||||||
let new_div = match result {
|
let new_div = match result {
|
||||||
SeasonResult::Promoted => (old_div - 1).max(1),
|
SeasonResult::Promoted => (old_div - 1).max(1),
|
||||||
SeasonResult::Relegated => (old_div + 1).min(10),
|
SeasonResult::Relegated => (old_div + 1).min(10),
|
||||||
SeasonResult::Maintained => old_div,
|
SeasonResult::Maintained => old_div,
|
||||||
};
|
};
|
||||||
let new_season = season.season_number + 1;
|
let new_season = season.season_number + 1;
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE seasons SET division = ?, season_number = ?, season_points = 0, \
|
"UPDATE seasons SET division = ?, season_number = ?, season_points = 0, \
|
||||||
@@ -93,30 +110,46 @@ pub async fn record_match(
|
|||||||
)
|
)
|
||||||
.bind(new_div)
|
.bind(new_div)
|
||||||
.bind(new_season)
|
.bind(new_season)
|
||||||
.bind(&now)
|
.bind(now)
|
||||||
.bind(profile_id)
|
.bind(profile_id)
|
||||||
.execute(pool)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Grant rewards
|
if coins > 0 {
|
||||||
club::add_coins(pool, club_id, coins).await?;
|
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 {
|
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 {
|
let result_str = match result {
|
||||||
SeasonResult::Promoted => "promoted",
|
SeasonResult::Promoted => "promoted",
|
||||||
SeasonResult::Maintained => "maintained",
|
SeasonResult::Maintained => "maintained",
|
||||||
SeasonResult::Relegated => "relegated",
|
SeasonResult::Relegated => "relegated",
|
||||||
};
|
};
|
||||||
let history_id = Uuid::new_v4().to_string();
|
sqlx::query(
|
||||||
let _ = sqlx::query(
|
|
||||||
"INSERT INTO season_history (id, profile_id, season_number, division, season_points, \
|
"INSERT INTO season_history (id, profile_id, season_number, division, season_points, \
|
||||||
wins, draws, losses, result, new_division, coins_awarded, pack_awarded, ended_at) \
|
wins, draws, losses, result, new_division, coins_awarded, pack_awarded, ended_at) \
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
)
|
)
|
||||||
.bind(&history_id)
|
.bind(Uuid::new_v4().to_string())
|
||||||
.bind(profile_id)
|
.bind(profile_id)
|
||||||
.bind(season.season_number)
|
.bind(season.season_number)
|
||||||
.bind(old_div)
|
.bind(old_div)
|
||||||
@@ -128,21 +161,28 @@ pub async fn record_match(
|
|||||||
.bind(new_div)
|
.bind(new_div)
|
||||||
.bind(coins)
|
.bind(coins)
|
||||||
.bind(pack_id)
|
.bind(pack_id)
|
||||||
.bind(&now)
|
.bind(now)
|
||||||
.execute(pool)
|
.execute(&mut **tx)
|
||||||
.await;
|
.await?;
|
||||||
|
|
||||||
let summary = SeasonEndSummary {
|
Ok(Some(SeasonEndSummary {
|
||||||
result,
|
result,
|
||||||
old_division: old_div,
|
old_division: old_div,
|
||||||
new_division: new_div,
|
new_division: new_div,
|
||||||
new_season_number: new_season,
|
new_season_number: new_season,
|
||||||
coins_awarded: coins,
|
coins_awarded: coins,
|
||||||
pack_awarded: pack_id.map(String::from),
|
pack_awarded: pack_id.map(String::from),
|
||||||
};
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
let updated = fetch(pool, profile_id).await?.expect("season must exist");
|
async fn fetch_tx(tx: &mut Transaction<'_, Sqlite>, profile_id: &str) -> AppResult<Option<Season>> {
|
||||||
Ok((updated, Some(summary)))
|
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).
|
/// Return past seasons for a profile, newest first (max 20).
|
||||||
|
|||||||
+166
-13
@@ -2,7 +2,7 @@ use crate::{
|
|||||||
db::Pool,
|
db::Pool,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::{
|
models::{
|
||||||
card::{CardDefinition, OwnedCard},
|
card::{CardDefinition, OwnedCard, OWNED_CARD_SELECT},
|
||||||
game_ext::{GameEntityExt, OpaqueExtensionWrite},
|
game_ext::{GameEntityExt, OpaqueExtensionWrite},
|
||||||
squad::{
|
squad::{
|
||||||
SaveSquadRequest, SlotAssignment, Squad, SquadPlayer, SquadPlayerInput, SquadReplaced,
|
SaveSquadRequest, SlotAssignment, Squad, SquadPlayer, SquadPlayerInput, SquadReplaced,
|
||||||
@@ -88,14 +88,19 @@ pub async fn validate_formation(
|
|||||||
|
|
||||||
let mut gk_count = 0usize;
|
let mut gk_count = 0usize;
|
||||||
for sp in &starters {
|
for sp in &starters {
|
||||||
let owned = sqlx::query_as::<_, OwnedCard>(
|
let owned = sqlx::query_as::<_, OwnedCard>(&format!(
|
||||||
"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 = ?",
|
"{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"
|
||||||
)
|
))
|
||||||
.bind(&sp.owned_card_id)
|
.bind(&sp.owned_card_id)
|
||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?
|
.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 let Some(card) = card_db.get(&owned.card_id) {
|
||||||
if card.position == "GK" {
|
if card.position == "GK" {
|
||||||
@@ -137,10 +142,7 @@ pub async fn calculate_chemistry(
|
|||||||
// Load all starter card definitions (N separate queries, fine for 11 players)
|
// Load all starter card definitions (N separate queries, fine for 11 players)
|
||||||
let mut player_cards: Vec<(String, CardDefinition)> = Vec::new();
|
let mut player_cards: Vec<(String, CardDefinition)> = Vec::new();
|
||||||
for sp in &starters {
|
for sp in &starters {
|
||||||
let owned = sqlx::query_as::<_, OwnedCard>(
|
let owned = sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} WHERE id = ?"))
|
||||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at \
|
|
||||||
FROM owned_cards WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(&sp.owned_card_id)
|
.bind(&sp.owned_card_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -270,13 +272,13 @@ async fn replace_squad_inner(
|
|||||||
// though it did.
|
// though it did.
|
||||||
let mut resolved: Vec<(SlotAssignmentRef, OwnedCard)> = Vec::new();
|
let mut resolved: Vec<(SlotAssignmentRef, OwnedCard)> = Vec::new();
|
||||||
for s in &replacement.slots {
|
for s in &replacement.slots {
|
||||||
let owned = sqlx::query_as::<_, OwnedCard>(
|
let owned = sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} WHERE id = ?"))
|
||||||
"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)
|
.bind(&s.owned_card_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("owned card {} not found", s.owned_card_id)))?;
|
.ok_or_else(|| {
|
||||||
|
AppError::NotFound(format!("owned card {} not found", s.owned_card_id))
|
||||||
|
})?;
|
||||||
|
|
||||||
if owned.club_id != club_id {
|
if owned.club_id != club_id {
|
||||||
// Deliberately the same message as "not found": whether a card
|
// Deliberately the same message as "not found": whether a card
|
||||||
@@ -343,6 +345,32 @@ async fn replace_squad_inner(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A replacement carrying no slots would DELETE every assignment below and
|
||||||
|
// insert nothing, silently emptying the squad. No product flow does that:
|
||||||
|
// a full-replacement client sends its COMPLETE slot array, so an empty list
|
||||||
|
// means the caller's own model was destroyed, not that the user emptied
|
||||||
|
// their squad. Mirroring that damage into the authority is unrecoverable,
|
||||||
|
// so refuse it.
|
||||||
|
//
|
||||||
|
// Observed for real: a FIFA 17 client whose in-memory squad had been
|
||||||
|
// destroyed by a bad parse wrote its emptiness back twice, taking
|
||||||
|
// `squad_players` from 18 rows to 0 while the request logged 200/ok.
|
||||||
|
//
|
||||||
|
// Checked inside the transaction so a concurrent write cannot slip between
|
||||||
|
// the count and the delete. A newly created squad counts 0 and is unaffected.
|
||||||
|
if replacement.slots.is_empty() {
|
||||||
|
let existing =
|
||||||
|
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM squad_players WHERE squad_id = ?")
|
||||||
|
.bind(&squad_id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if existing > 0 {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"refusing to empty a populated squad: replacement carried no slots, but squad '{squad_id}' holds {existing} assignments"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sqlx::query("DELETE FROM squad_players WHERE squad_id = ?")
|
sqlx::query("DELETE FROM squad_players WHERE squad_id = ?")
|
||||||
.bind(&squad_id)
|
.bind(&squad_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
@@ -558,6 +586,131 @@ pub async fn read_squad_with_ext(
|
|||||||
Ok((squad, players, state))
|
Ok((squad, players, state))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Outcome of a role-only squad patch.
|
||||||
|
pub struct SquadRolesPatched {
|
||||||
|
pub squad: Squad,
|
||||||
|
/// Re-anchored fingerprint of the committed canonical state. The captain
|
||||||
|
/// flag is part of the fingerprint, so a captain change MUST re-anchor the
|
||||||
|
/// extension or every later read reports it stale.
|
||||||
|
pub canonical_fingerprint: String,
|
||||||
|
/// Whether the captain flag actually moved (false when it was already set).
|
||||||
|
pub captain_changed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Patch ONLY a squad's role assignments plus its opaque game extension, in one
|
||||||
|
/// transaction. Never inserts, deletes or reorders a single assignment row.
|
||||||
|
///
|
||||||
|
/// This exists because a full replacement and a role-only update are different
|
||||||
|
/// operations that the FIFA 17 client sends down the same wire path. Routing a
|
||||||
|
/// role-only update through [`replace_squad_with_extension`] means presenting it
|
||||||
|
/// as a replacement carrying zero slots, which the empty-replacement guard
|
||||||
|
/// correctly refuses — the client's captain/kick-taker change was being lost
|
||||||
|
/// with a 400. The fix is to stop mis-describing the operation, NOT to relax the
|
||||||
|
/// guard: that guard is load-bearing and stays exactly as strict.
|
||||||
|
///
|
||||||
|
/// Player assignments, the squad manager and club actives are untouched by
|
||||||
|
/// construction — this function issues no statement that can affect them.
|
||||||
|
///
|
||||||
|
/// `captain_owned_card_id` must already be assigned to this squad. Anything else
|
||||||
|
/// is refused before any write, so an invalid target leaves the whole patch
|
||||||
|
/// unapplied (captain AND extension), never half-applied.
|
||||||
|
pub async fn patch_squad_roles(
|
||||||
|
pool: &Pool,
|
||||||
|
game_id: &str,
|
||||||
|
club_id: &str,
|
||||||
|
captain_owned_card_id: Option<&str>,
|
||||||
|
ext: &OpaqueExtensionWrite,
|
||||||
|
) -> AppResult<SquadRolesPatched> {
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
|
// Resolve the club's active squad. A role patch NEVER creates a squad: with
|
||||||
|
// no squad there is nothing to assign a captain within, and inventing one
|
||||||
|
// here would let a stray patch materialise empty canonical state.
|
||||||
|
let squad = sqlx::query_as::<_, Squad>(
|
||||||
|
"SELECT id, club_id, name, formation, created_at, updated_at FROM squads \
|
||||||
|
WHERE club_id = ? ORDER BY updated_at DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("no squad found for this club".into()))?;
|
||||||
|
|
||||||
|
let assigned = sqlx::query_as::<_, (String, i64, bool, bool)>(
|
||||||
|
"SELECT owned_card_id, position_index, is_captain, is_on_bench \
|
||||||
|
FROM squad_players WHERE squad_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&squad.id)
|
||||||
|
.fetch_all(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut captain_changed = false;
|
||||||
|
if let Some(captain) = captain_owned_card_id {
|
||||||
|
// Validate against THIS squad's assignments, not the whole collection:
|
||||||
|
// a captain the user does not field is not a captain, and accepting an
|
||||||
|
// arbitrary owned card here would let a patch reference any inventory
|
||||||
|
// item.
|
||||||
|
// Validated BEFORE any write, so an invalid target aborts the whole
|
||||||
|
// patch — captain and extension both — rather than half-applying it.
|
||||||
|
if !assigned.iter().any(|(owned, _, _, _)| owned == captain) {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"captain '{captain}' is not assigned to squad '{}'",
|
||||||
|
squad.id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let already_captain = assigned
|
||||||
|
.iter()
|
||||||
|
.any(|(owned, _, cap, _)| owned == captain && *cap);
|
||||||
|
let someone_else_captain = assigned
|
||||||
|
.iter()
|
||||||
|
.any(|(owned, _, cap, _)| *cap && owned != captain);
|
||||||
|
captain_changed = !already_captain || someone_else_captain;
|
||||||
|
sqlx::query("UPDATE squad_players SET is_captain = (owned_card_id = ?) WHERE squad_id = ?")
|
||||||
|
.bind(captain)
|
||||||
|
.bind(&squad.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-anchor to the state as it now stands, applying the captain move to the
|
||||||
|
// in-memory view rather than re-reading: same transaction, same result, one
|
||||||
|
// fewer round trip.
|
||||||
|
let canonical_fingerprint = squad_fingerprint(
|
||||||
|
&squad.id,
|
||||||
|
&squad.formation,
|
||||||
|
assigned.iter().map(|(owned, slot, cap, bench)| {
|
||||||
|
let is_cap = match captain_owned_card_id {
|
||||||
|
Some(c) => owned.as_str() == c,
|
||||||
|
None => *cap,
|
||||||
|
};
|
||||||
|
(*slot, owned.as_str(), is_cap, *bench)
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR REPLACE INTO game_entity_ext \
|
||||||
|
(game_id, entity_kind, entity_id, namespace, schema_version, canonical_fingerprint, payload, updated_at) \
|
||||||
|
VALUES (?, 'squad', ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(game_id)
|
||||||
|
.bind(&squad.id)
|
||||||
|
.bind(&ext.namespace)
|
||||||
|
.bind(ext.schema_version)
|
||||||
|
.bind(&canonical_fingerprint)
|
||||||
|
.bind(&ext.payload)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(SquadRolesPatched {
|
||||||
|
squad,
|
||||||
|
canonical_fingerprint,
|
||||||
|
captain_changed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Compatibility wrapper over [`replace_squad`].
|
/// Compatibility wrapper over [`replace_squad`].
|
||||||
///
|
///
|
||||||
/// Kept so the existing Core REST route keeps working, but it no longer has its
|
/// Kept so the existing Core REST route keeps working, but it no longer has its
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::{db::Pool, error::AppResult, models::statistics::Statistics};
|
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> {
|
pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult<Statistics> {
|
||||||
if let Some(s) = sqlx::query_as::<_, Statistics>(SELECT_STATS)
|
if let Some(s) = sqlx::query_as::<_, Statistics>(SELECT_STATS)
|
||||||
@@ -77,6 +78,77 @@ pub async fn record_match(
|
|||||||
Ok(())
|
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<()> {
|
pub async fn increment_packs_opened(pool: &Pool, profile_id: &str) -> AppResult<()> {
|
||||||
get_or_create(pool, profile_id).await?;
|
get_or_create(pool, profile_id).await?;
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
@@ -121,6 +193,26 @@ pub async fn record_position_goals(
|
|||||||
Ok(())
|
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)>> {
|
pub async fn get_position_goals(pool: &Pool, profile_id: &str) -> AppResult<Vec<(String, i64)>> {
|
||||||
let rows: Vec<(String, i64)> = sqlx::query_as(
|
let rows: Vec<(String, i64)> = sqlx::query_as(
|
||||||
"SELECT position, goals FROM position_goals WHERE profile_id = ? ORDER BY goals DESC",
|
"SELECT position, goals FROM position_goals WHERE profile_id = ? ORDER BY goals DESC",
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
//! Reading per-instance attribute training back out for projection.
|
||||||
|
//!
|
||||||
|
//! Writing is [`crate::services::instance_effect::InstanceEffect::ApplyTraining`],
|
||||||
|
//! inside the one apply transaction. This module is the read half: it loads the
|
||||||
|
//! effects a club's instances carry and folds them onto a definition's
|
||||||
|
//! attributes.
|
||||||
|
//!
|
||||||
|
//! The fold lives in Core rather than in each game host on purpose. The stored
|
||||||
|
//! effect names a SLOT in Core's own card model, so only Core knows which field
|
||||||
|
//! slot 4 is; a host that did the arithmetic itself would have to re-derive that
|
||||||
|
//! mapping and could disagree with the next host. Core answers with the finished
|
||||||
|
//! numbers and the raw effects, and the host chooses which it needs.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
use crate::{db::Pool, error::AppResult, models::card::CardDefinition};
|
||||||
|
|
||||||
|
/// The upper bound of a FIFA-style attribute. Training is added to a value whose
|
||||||
|
/// domain is 1..=99, so the fold clamps there.
|
||||||
|
///
|
||||||
|
/// This is a DOMAIN invariant of the six-attribute card model, not a reversed
|
||||||
|
/// training rule: whether FIFA 17 itself refuses to train a 95-pace player past
|
||||||
|
/// 99, or clamps like this, or wraps, is UNKNOWN. Clamping is the only behaviour
|
||||||
|
/// that keeps the projected card inside the model it is drawn from.
|
||||||
|
pub const ATTRIBUTE_MAX: i64 = 99;
|
||||||
|
|
||||||
|
/// The one training effect an instance may carry.
|
||||||
|
///
|
||||||
|
/// At most one per instance: FIFA 17 allows "one attribute or all six" and a new
|
||||||
|
/// card replaces the old, so a second concurrent effect is not representable.
|
||||||
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
|
pub struct TrainingEffect {
|
||||||
|
/// Slot in Core's six-attribute model, `CardDefinition` declaration order,
|
||||||
|
/// or `None` for an effect that boosts ALL SIX slots.
|
||||||
|
pub attribute_index: Option<i64>,
|
||||||
|
pub amount: i64,
|
||||||
|
pub source_card_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every training effect held by the given club's instances, keyed by instance.
|
||||||
|
///
|
||||||
|
/// One query for the whole club rather than one per item: the projection walks
|
||||||
|
/// up to a couple of thousand owned rows, and a per-item lookup there is the
|
||||||
|
/// classic N+1 that has bitten this projection before.
|
||||||
|
pub async fn load_for_club(
|
||||||
|
pool: &Pool,
|
||||||
|
club_id: &str,
|
||||||
|
) -> AppResult<HashMap<String, TrainingEffect>> {
|
||||||
|
let rows = sqlx::query_as::<_, (String, Option<i64>, i64, String)>(
|
||||||
|
"SELECT t.owned_card_id, t.attribute_index, t.amount, t.source_card_id \
|
||||||
|
FROM owned_card_training t \
|
||||||
|
JOIN owned_cards o ON o.id = t.owned_card_id \
|
||||||
|
WHERE o.club_id = ?",
|
||||||
|
)
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(owned_card_id, attribute_index, amount, source_card_id)| {
|
||||||
|
(
|
||||||
|
owned_card_id,
|
||||||
|
TrainingEffect {
|
||||||
|
attribute_index,
|
||||||
|
amount,
|
||||||
|
source_card_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume the training effects of the instances that took the field, inside a
|
||||||
|
/// caller-supplied transaction. Returns the instances actually cleared.
|
||||||
|
///
|
||||||
|
/// FIFA 17 training is a ONE-MATCH effect: it "is reflected in the following
|
||||||
|
/// match and expires after this", and a card applied to someone who stays on the
|
||||||
|
/// bench or in the reserves "will continue to benefit from the training effect
|
||||||
|
/// until he plays" (DOCUMENTED — fifauteam's contemporaneous FIFA 17 guide).
|
||||||
|
/// So the trigger is the PLAYER PLAYING, not the match merely completing, and
|
||||||
|
/// the caller must pass the instances that played — never a whole club.
|
||||||
|
///
|
||||||
|
/// `club_id` is not redundant with the ids: it scopes the delete so a caller
|
||||||
|
/// cannot expire another club's effects by guessing an instance id.
|
||||||
|
///
|
||||||
|
/// Idempotent by construction. Deleting an already-absent row is a no-op, so a
|
||||||
|
/// replayed match cannot "expire twice"; combined with the caller's
|
||||||
|
/// `match_completions` uniqueness guard, the mutation happens exactly once and a
|
||||||
|
/// replay is a silent no-op rather than a second effect.
|
||||||
|
pub async fn expire_for_instances_tx(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
|
club_id: &str,
|
||||||
|
instance_ids: &[String],
|
||||||
|
) -> AppResult<Vec<String>> {
|
||||||
|
let mut expired = Vec::new();
|
||||||
|
for id in instance_ids {
|
||||||
|
// DELETE .. RETURNING so the report is what the database actually
|
||||||
|
// removed, not what we hoped it would: an id that carried no training,
|
||||||
|
// or belongs to another club, simply does not appear.
|
||||||
|
let hit: Option<(String,)> = sqlx::query_as(
|
||||||
|
"DELETE FROM owned_card_training \
|
||||||
|
WHERE owned_card_id = ? \
|
||||||
|
AND owned_card_id IN (SELECT id FROM owned_cards WHERE club_id = ?) \
|
||||||
|
RETURNING owned_card_id",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_optional(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
if let Some((got,)) = hit {
|
||||||
|
expired.push(got);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(expired)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The definition's six attributes in canonical slot order.
|
||||||
|
///
|
||||||
|
/// THIS ORDER IS THE CONTRACT that `attribute_index` indexes. It is
|
||||||
|
/// `CardDefinition`'s own declaration order, and changing it would silently
|
||||||
|
/// re-point every stored effect at a different attribute.
|
||||||
|
pub fn base_attributes(def: &CardDefinition) -> [i64; 6] {
|
||||||
|
[
|
||||||
|
def.pace as i64,
|
||||||
|
def.shooting as i64,
|
||||||
|
def.passing as i64,
|
||||||
|
def.dribbling as i64,
|
||||||
|
def.defending as i64,
|
||||||
|
def.physical as i64,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base attributes with any training folded in, clamped to the model's domain.
|
||||||
|
///
|
||||||
|
/// A `None` slot boosts ALL SIX attributes — FIFA 17's rare "all" training card.
|
||||||
|
/// An out-of-range slot is ignored rather than panicking: the schema already
|
||||||
|
/// refuses one, so reaching this would mean the row was written around Core, and
|
||||||
|
/// dropping it degrades one attribute instead of failing every projection.
|
||||||
|
pub fn effective_attributes(def: &CardDefinition, effect: Option<&TrainingEffect>) -> [i64; 6] {
|
||||||
|
let mut out = base_attributes(def);
|
||||||
|
let Some(e) = effect else { return out };
|
||||||
|
match e.attribute_index {
|
||||||
|
Some(slot) => {
|
||||||
|
if let Some(v) = out.get_mut(slot as usize) {
|
||||||
|
*v = (*v + e.amount).clamp(0, ATTRIBUTE_MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
for v in out.iter_mut() {
|
||||||
|
*v = (*v + e.amount).clamp(0, ATTRIBUTE_MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same six values as a named object, for the projection envelope.
|
||||||
|
pub fn effective_attributes_json(
|
||||||
|
def: &CardDefinition,
|
||||||
|
effect: Option<&TrainingEffect>,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
let a = effective_attributes(def, effect);
|
||||||
|
serde_json::json!({
|
||||||
|
"pace": a[0],
|
||||||
|
"shooting": a[1],
|
||||||
|
"passing": a[2],
|
||||||
|
"dribbling": a[3],
|
||||||
|
"defending": a[4],
|
||||||
|
"physical": a[5],
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,24 +1,17 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
db::Pool,
|
db::Pool,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::card::OwnedCard,
|
models::card::{OwnedCard, OWNED_CARD_SELECT},
|
||||||
models::chemistry_style::ChemistryStyle,
|
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;
|
pub const MAX_TRAINING_BONUS: i64 = 3;
|
||||||
|
|
||||||
/// Cost in coins to change a player's position.
|
/// Cost in coins to change a player's position.
|
||||||
pub const POSITION_CHANGE_COST: i64 = 500;
|
pub const POSITION_CHANGE_COST: i64 = 500;
|
||||||
|
|
||||||
async fn fetch_owned(pool: &Pool, owned_card_id: &str, club_id: &str) -> AppResult<OwnedCard> {
|
async fn fetch_owned(pool: &Pool, owned_card_id: &str, club_id: &str) -> AppResult<OwnedCard> {
|
||||||
sqlx::query_as::<_, OwnedCard>(&format!(
|
sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"))
|
||||||
"{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"
|
|
||||||
))
|
|
||||||
.bind(owned_card_id)
|
.bind(owned_card_id)
|
||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
@@ -64,8 +57,8 @@ pub async fn change_position(
|
|||||||
new_position: &str,
|
new_position: &str,
|
||||||
) -> AppResult<OwnedCard> {
|
) -> AppResult<OwnedCard> {
|
||||||
let valid_positions = [
|
let valid_positions = [
|
||||||
"GK", "RB", "LB", "CB", "RWB", "LWB", "CDM", "CM", "CAM", "RM", "LM", "RW", "LW",
|
"GK", "RB", "LB", "CB", "RWB", "LWB", "CDM", "CM", "CAM", "RM", "LM", "RW", "LW", "CF",
|
||||||
"CF", "ST",
|
"ST",
|
||||||
];
|
];
|
||||||
if !valid_positions.contains(&new_position) {
|
if !valid_positions.contains(&new_position) {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(AppError::BadRequest(format!(
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//! Reproduction for the fresh-DB multi-connection warm-up write failure.
|
||||||
|
//! Forces several pooled connections to open concurrently on a brand-new DB and
|
||||||
|
//! captures the ACTUAL sqlx/SQLite error (not the service's generic string).
|
||||||
|
|
||||||
|
use openfut_core::db::{init_pool, run_migrations};
|
||||||
|
use openfut_core::services::economy;
|
||||||
|
|
||||||
|
async fn seed_club(pool: &sqlx::SqlitePool) {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO profiles (id, username, created_at, updated_at) VALUES ('p','p','t','t')",
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES ('c','p','c',100000,'t','t')")
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
|
||||||
|
async fn fresh_db_multiconn_concurrent_writes() {
|
||||||
|
let base = std::env::temp_dir().join(format!("ofut-cc-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
let iters = 100usize;
|
||||||
|
let mut failures = 0usize;
|
||||||
|
let mut first_err = String::new();
|
||||||
|
for i in 0..iters {
|
||||||
|
let url = format!("sqlite://{}/db{i}.db", base.display());
|
||||||
|
let pool = init_pool(&url, 5).await.expect("init_pool");
|
||||||
|
run_migrations(&pool).await.expect("migrations");
|
||||||
|
seed_club(&pool).await;
|
||||||
|
// Fire concurrent credits to force several connections to warm up at once
|
||||||
|
// on the brand-new DB, then a write — the harness's failing shape.
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for _ in 0..8 {
|
||||||
|
let p = pool.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
economy::grant_reward(&p, "c", 1).await
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for h in handles {
|
||||||
|
match h.await.unwrap() {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
failures += 1;
|
||||||
|
if first_err.is_empty() {
|
||||||
|
first_err = format!("{e:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Serialization correctness: 8 concurrent +1 credits, no lost update.
|
||||||
|
let bal = economy::balance(&pool, "c").await.unwrap();
|
||||||
|
assert_eq!(bal, 100_008, "iter {i}: lost update under concurrency");
|
||||||
|
pool.close().await;
|
||||||
|
}
|
||||||
|
std::fs::remove_dir_all(&base).ok();
|
||||||
|
assert_eq!(
|
||||||
|
failures,
|
||||||
|
0,
|
||||||
|
"{failures}/{} iterations had a write failure; first error: {first_err}",
|
||||||
|
iters * 8
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -102,3 +102,106 @@ async fn preflight_passes_when_owned_card_definition_is_loaded() {
|
|||||||
.await
|
.await
|
||||||
.expect("preflight passes when the owned card's definition is loaded");
|
.expect("preflight passes when the owned card's definition is loaded");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The LOAD-BEARING backwards-compatibility property: `source_rating` was added
|
||||||
|
/// to `CardDefinition` long after packs shipped, and `CardDefinition` has no
|
||||||
|
/// `#[serde(default)]`. Every already-emitted pack omits the key, so a pack
|
||||||
|
/// without it MUST still parse — and land as `None`, never as a fabricated 0
|
||||||
|
/// that a tier rule would read as bronze.
|
||||||
|
#[test]
|
||||||
|
fn content_pack_without_source_rating_still_parses() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let pack = dir.path().join("legacy-pack.json");
|
||||||
|
std::fs::write(
|
||||||
|
&pack,
|
||||||
|
r#"[{"id":"legacy_1","name":"Legacy Player","overall":84,"position":"ST",
|
||||||
|
"nation":"Nation","league":"League","club":"Club","pace":80,
|
||||||
|
"shooting":85,"passing":70,"dribbling":82,"defending":40,
|
||||||
|
"physical":75,"rarity":"gold","image_path":null}]"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut db = CardDb {
|
||||||
|
cards: Default::default(),
|
||||||
|
};
|
||||||
|
assert_eq!(db.load_pack(&pack).expect("legacy pack must load"), 1);
|
||||||
|
let def = db.get("legacy_1").expect("definition merged");
|
||||||
|
assert_eq!(def.overall, 84);
|
||||||
|
assert!(
|
||||||
|
def.source_rating.is_none(),
|
||||||
|
"a missing key is None, not a substituted 0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pack that DOES carry `source_rating` must round-trip through `CardDb` and
|
||||||
|
/// surface on `/collection` as `card.source_rating` — that envelope field is the
|
||||||
|
/// only authoritative staff/manager tier source a game host has. `overall` stays
|
||||||
|
/// 0 for the non-player because it feeds pricing and squad projection.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn collection_surfaces_source_rating_for_a_non_player() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let pack = dir.path().join("staff-pack.json");
|
||||||
|
std::fs::write(
|
||||||
|
&pack,
|
||||||
|
r#"[{"id":"fifa17_3000083","name":"Manager","overall":0,"position":"",
|
||||||
|
"nation":"","league":"","club":"","pace":0,"shooting":0,"passing":0,
|
||||||
|
"dribbling":0,"defending":0,"physical":0,"rarity":"bronze",
|
||||||
|
"image_path":null,"source_rating":88}]"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let pool = fresh_pool().await;
|
||||||
|
let cfg = openfut_core::config::Config {
|
||||||
|
listen_addr: "127.0.0.1:0".into(),
|
||||||
|
database_url: "sqlite::memory:".into(),
|
||||||
|
data_dir: "data".into(),
|
||||||
|
max_connections: 1,
|
||||||
|
dev_content_games: Vec::new(),
|
||||||
|
content_packs: vec![pack.clone()],
|
||||||
|
};
|
||||||
|
let app = openfut_core::app::build(pool.clone(), cfg.clone())
|
||||||
|
.await
|
||||||
|
.expect("app build with the staff pack");
|
||||||
|
create_profile(&app).await;
|
||||||
|
let club: String = sqlx::query_scalar("SELECT id FROM clubs LIMIT 1")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
insert_owned(&pool, "oc-manager", &club, "fifa17_3000083").await;
|
||||||
|
|
||||||
|
// Rebuild so preflight sees the owned row, then read the envelope.
|
||||||
|
let app = openfut_core::app::build(pool.clone(), cfg)
|
||||||
|
.await
|
||||||
|
.expect("preflight passes: the pack carries the definition");
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/collection")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let coll: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
let entry = coll["collection"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|c| c["owned_card_id"] == serde_json::json!("oc-manager"))
|
||||||
|
.expect("the owned manager must project");
|
||||||
|
assert_eq!(entry["card"]["source_rating"], serde_json::json!(88));
|
||||||
|
assert_eq!(
|
||||||
|
entry["card"]["overall"],
|
||||||
|
serde_json::json!(0),
|
||||||
|
"overall stays 0 for a non-player: it feeds pricing and projection"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
entry["effective_overall"],
|
||||||
|
serde_json::json!(0),
|
||||||
|
"the tier source must NOT leak into the projected overall"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
//! the Core-level half of the migration mutation battery: each hostile input is
|
//! 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.
|
//! 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::card_db::CardDb;
|
||||||
use openfut_core::services::import::{
|
use openfut_core::services::import::{
|
||||||
apply_profile_import, ImportClub, ImportExtension, ImportOwnedCard, ImportProfile, ImportSlot,
|
apply_profile_import, ImportClub, ImportEntitlement, ImportExtension, ImportOwnedCard,
|
||||||
ImportSquad, ProfileImportRequest,
|
ImportProfile, ImportSlot, ImportSquad, ProfileImportRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
async fn fresh_pool() -> sqlx::SqlitePool {
|
async fn fresh_pool() -> sqlx::SqlitePool {
|
||||||
@@ -34,6 +35,8 @@ fn owned(ids: &[String]) -> Vec<ImportOwnedCard> {
|
|||||||
.map(|(i, id)| ImportOwnedCard {
|
.map(|(i, id)| ImportOwnedCard {
|
||||||
owned_item_id: format!("oc-{i}"),
|
owned_item_id: format!("oc-{i}"),
|
||||||
card_id: id.clone(),
|
card_id: id.clone(),
|
||||||
|
content_kind: ContentKind::Player,
|
||||||
|
quantity: None,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -79,6 +82,7 @@ fn request(
|
|||||||
},
|
},
|
||||||
owned,
|
owned,
|
||||||
squad,
|
squad,
|
||||||
|
entitlements: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +132,33 @@ async fn imports_profile_club_owned_and_squad_in_one_shot() {
|
|||||||
assert_eq!(stored_import_fp, "fp-happy");
|
assert_eq!(stored_import_fp, "fp-happy");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn imports_entitlements_seeds_unopened_packs() {
|
||||||
|
let pool = fresh_pool().await;
|
||||||
|
let db = CardDb::load("data").unwrap();
|
||||||
|
let ids = valid_ids(2);
|
||||||
|
let ow = owned(&ids);
|
||||||
|
let mut req = request("g_ent", "fp-ent", ow, None);
|
||||||
|
req.entitlements = vec![
|
||||||
|
ImportEntitlement {
|
||||||
|
definition_id: "70".into(),
|
||||||
|
},
|
||||||
|
ImportEntitlement {
|
||||||
|
definition_id: "70".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
apply_profile_import(&pool, &db, &req)
|
||||||
|
.await
|
||||||
|
.expect("import");
|
||||||
|
// Two unconsumed entitlements seeded into packs (opened = 0).
|
||||||
|
assert_eq!(count(&pool, "packs").await, 2);
|
||||||
|
let unopened: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM packs WHERE opened = 0")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(unopened, 2);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn rerun_same_fingerprint_is_idempotent_noop() {
|
async fn rerun_same_fingerprint_is_idempotent_noop() {
|
||||||
let pool = fresh_pool().await;
|
let pool = fresh_pool().await;
|
||||||
@@ -180,6 +211,8 @@ async fn missing_definition_fails_preflight_with_no_writes() {
|
|||||||
ow.push(ImportOwnedCard {
|
ow.push(ImportOwnedCard {
|
||||||
owned_item_id: "oc-bad".into(),
|
owned_item_id: "oc-bad".into(),
|
||||||
card_id: "fifa17_definitely_absent_999999".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))
|
let err = apply_profile_import(&pool, &db, &request("g_miss", "fp", ow, None))
|
||||||
.await
|
.await
|
||||||
@@ -254,3 +287,193 @@ async fn empty_owned_fails() {
|
|||||||
.expect_err("empty owned must fail");
|
.expect_err("empty owned must fail");
|
||||||
assert!(format!("{err:#}").contains("zero owned cards"), "{err:#}");
|
assert!(format!("{err:#}").contains("zero owned cards"), "{err:#}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A profile import is once-only, so a taxonomy fix cannot arrive by
|
||||||
|
/// re-importing: the same fingerprint no-ops and a different one is refused.
|
||||||
|
/// Every pre-taxonomy import therefore left coaches, kits and consumables
|
||||||
|
/// durably recorded as players — wrong in the ownership authority even where a
|
||||||
|
/// catalog-driven wire still looked right.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reclassify_corrects_already_imported_rows_and_is_idempotent() {
|
||||||
|
use openfut_core::services::import::{
|
||||||
|
reclassify_owned_content, ContentKindAssignment, ReclassifyRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
let pool = fresh_pool().await;
|
||||||
|
let db = CardDb::load("data").unwrap();
|
||||||
|
let ids = valid_ids(3);
|
||||||
|
// Imported before the taxonomy existed: everything landed as `player`.
|
||||||
|
let ow = owned(&ids);
|
||||||
|
let req = request("g_reclass", "fp-reclass", ow, None);
|
||||||
|
apply_profile_import(&pool, &db, &req)
|
||||||
|
.await
|
||||||
|
.expect("import");
|
||||||
|
|
||||||
|
let kind_of = |card: String| {
|
||||||
|
let pool = pool.clone();
|
||||||
|
async move {
|
||||||
|
sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT content_kind FROM owned_cards WHERE card_id = ?",
|
||||||
|
)
|
||||||
|
.bind(card)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert_eq!(kind_of(ids[0].clone()).await, "player");
|
||||||
|
|
||||||
|
let rc = ReclassifyRequest {
|
||||||
|
game_id: "g_reclass".into(),
|
||||||
|
dry_run: false,
|
||||||
|
assignments: vec![
|
||||||
|
ContentKindAssignment {
|
||||||
|
card_id: ids[0].clone(),
|
||||||
|
content_kind: ContentKind::Staff,
|
||||||
|
},
|
||||||
|
ContentKindAssignment {
|
||||||
|
card_id: ids[1].clone(),
|
||||||
|
content_kind: ContentKind::Consumable,
|
||||||
|
},
|
||||||
|
ContentKindAssignment {
|
||||||
|
card_id: "fifa17_definition_nobody_owns".into(),
|
||||||
|
content_kind: ContentKind::Kit,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let out = reclassify_owned_content(&pool, &rc)
|
||||||
|
.await
|
||||||
|
.expect("reclassify");
|
||||||
|
assert_eq!(out.updated, 2);
|
||||||
|
assert_eq!(out.unchanged, 0);
|
||||||
|
assert_eq!(
|
||||||
|
out.unmatched_definitions,
|
||||||
|
vec!["fifa17_definition_nobody_owns".to_string()],
|
||||||
|
"an assignment nobody owns is reported, never invented"
|
||||||
|
);
|
||||||
|
assert_eq!(kind_of(ids[0].clone()).await, "staff");
|
||||||
|
assert_eq!(kind_of(ids[1].clone()).await, "consumable");
|
||||||
|
// Untouched definitions keep their kind.
|
||||||
|
assert_eq!(kind_of(ids[2].clone()).await, "player");
|
||||||
|
|
||||||
|
// Rerunning converges: nothing left to change.
|
||||||
|
let again = reclassify_owned_content(&pool, &rc).await.expect("rerun");
|
||||||
|
assert_eq!(again.updated, 0);
|
||||||
|
assert_eq!(again.unchanged, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reclassification is scoped to one game, so a shared database cannot have
|
||||||
|
/// another game's identically-named definition rewritten underneath it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reclassify_never_crosses_a_game_boundary() {
|
||||||
|
use openfut_core::services::import::{
|
||||||
|
reclassify_owned_content, ContentKindAssignment, ReclassifyRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
let pool = fresh_pool().await;
|
||||||
|
let db = CardDb::load("data").unwrap();
|
||||||
|
let ids = valid_ids(2);
|
||||||
|
apply_profile_import(&pool, &db, &request("g_a", "fp-a", owned(&ids), None))
|
||||||
|
.await
|
||||||
|
.expect("import a");
|
||||||
|
// Same definitions, but owned-item ids are globally unique.
|
||||||
|
let mut b_owned = owned(&ids);
|
||||||
|
for o in &mut b_owned {
|
||||||
|
o.owned_item_id = format!("b-{}", o.owned_item_id);
|
||||||
|
}
|
||||||
|
apply_profile_import(&pool, &db, &request("g_b", "fp-b", b_owned, None))
|
||||||
|
.await
|
||||||
|
.expect("import b");
|
||||||
|
|
||||||
|
let out = reclassify_owned_content(
|
||||||
|
&pool,
|
||||||
|
&ReclassifyRequest {
|
||||||
|
game_id: "g_a".into(),
|
||||||
|
dry_run: false,
|
||||||
|
assignments: vec![ContentKindAssignment {
|
||||||
|
card_id: ids[0].clone(),
|
||||||
|
content_kind: ContentKind::Kit,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("reclassify");
|
||||||
|
assert_eq!(out.updated, 1, "only game A's copy");
|
||||||
|
|
||||||
|
let kinds: Vec<String> = sqlx::query_scalar(
|
||||||
|
"SELECT o.content_kind FROM owned_cards o \
|
||||||
|
JOIN clubs c ON c.id = o.club_id JOIN profiles p ON p.id = c.profile_id \
|
||||||
|
WHERE p.game_id = 'g_b' AND o.card_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&ids[0])
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(kinds, vec!["player".to_string()], "game B untouched");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dry run must report the SAME counts a real run would, and leave the
|
||||||
|
/// database byte-for-byte unchanged. It runs the real UPDATEs and rolls back, so
|
||||||
|
/// the numbers are measured rather than predicted — which is the only reason an
|
||||||
|
/// operator can trust them before touching a frozen production database.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reclassify_dry_run_reports_the_real_counts_and_commits_nothing() {
|
||||||
|
use openfut_core::services::import::{
|
||||||
|
reclassify_owned_content, ContentKindAssignment, ReclassifyRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
let pool = fresh_pool().await;
|
||||||
|
let db = CardDb::load("data").unwrap();
|
||||||
|
let ids = valid_ids(3);
|
||||||
|
apply_profile_import(&pool, &db, &request("g_dry", "fp-dry", owned(&ids), None))
|
||||||
|
.await
|
||||||
|
.expect("import");
|
||||||
|
|
||||||
|
let kinds = || {
|
||||||
|
let pool = pool.clone();
|
||||||
|
async move {
|
||||||
|
sqlx::query_scalar::<_, String>("SELECT content_kind FROM owned_cards ORDER BY card_id")
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let before = kinds().await;
|
||||||
|
|
||||||
|
let mut rc = ReclassifyRequest {
|
||||||
|
game_id: "g_dry".into(),
|
||||||
|
dry_run: true,
|
||||||
|
assignments: vec![
|
||||||
|
ContentKindAssignment {
|
||||||
|
card_id: ids[0].clone(),
|
||||||
|
content_kind: ContentKind::Staff,
|
||||||
|
},
|
||||||
|
ContentKindAssignment {
|
||||||
|
card_id: ids[1].clone(),
|
||||||
|
content_kind: ContentKind::Consumable,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let dry = reclassify_owned_content(&pool, &rc).await.expect("dry run");
|
||||||
|
assert!(dry.dry_run);
|
||||||
|
assert_eq!(dry.updated, 2);
|
||||||
|
assert_eq!(
|
||||||
|
dry.updated_by_kind,
|
||||||
|
[("consumable".to_string(), 1), ("staff".to_string(), 1)]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
"the per-kind shape is what an operator sanity-checks"
|
||||||
|
);
|
||||||
|
assert_eq!(kinds().await, before, "a dry run commits NOTHING");
|
||||||
|
|
||||||
|
// The real run then reports exactly what the dry run promised.
|
||||||
|
rc.dry_run = false;
|
||||||
|
let real = reclassify_owned_content(&pool, &rc)
|
||||||
|
.await
|
||||||
|
.expect("real run");
|
||||||
|
assert!(!real.dry_run);
|
||||||
|
assert_eq!(real.updated, dry.updated);
|
||||||
|
assert_eq!(real.updated_by_kind, dry.updated_by_kind);
|
||||||
|
assert_ne!(kinds().await, before, "the real run DID commit");
|
||||||
|
}
|
||||||
|
|||||||
+1582
-38
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,365 @@
|
|||||||
|
//! Owned-content model migrations (0025 content_kind/quantity, 0026
|
||||||
|
//! club_active_items, 0027 consumable_applications, 0028 contract_matches).
|
||||||
|
//!
|
||||||
|
//! 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 and no tracked contract (the band is a pure widening, never a
|
||||||
|
//! rewrite and never a backfill of someone else's default);
|
||||||
|
//! * every existing kit designation lands in `club_active_items` under its
|
||||||
|
//! generalised slot token, and the old table + trigger are gone.
|
||||||
|
//!
|
||||||
|
//! 0028 additionally must NOT be a table rebuild: `owned_cards` carries 0026's
|
||||||
|
//! `clear_club_active_item_before_transfer` trigger, and a DROP/recreate would
|
||||||
|
//! take it along silently. The trigger assertions below therefore run AFTER the
|
||||||
|
//! whole band, not just after 0026.
|
||||||
|
//!
|
||||||
|
//! 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);
|
||||||
|
|
||||||
|
// 0028: the column exists and every row that pre-dates it reads back NULL.
|
||||||
|
// NULL is not zero — it means Core tracks no contract for the instance, so a
|
||||||
|
// backfill here would have invented one game's pack-fresh number for all of
|
||||||
|
// them.
|
||||||
|
let contract = sqlx::query_scalar::<_, Option<i64>>(
|
||||||
|
"SELECT contract_matches FROM owned_cards WHERE id = 'spare'",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("0028 must have added contract_matches");
|
||||||
|
assert_eq!(contract, None, "a pre-existing row tracks no contract");
|
||||||
|
assert!(
|
||||||
|
sqlx::query("UPDATE owned_cards SET contract_matches = -1 WHERE id = 'spare'")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"contract_matches CHECK must reject a negative count"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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, ©).unwrap_or_else(|e| panic!("copy {source}: {e}"));
|
||||||
|
let pool = pool_for(©).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, contracted) = sqlx::query_as::<_, (i64, 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), \
|
||||||
|
SUM(CASE WHEN contract_matches 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_eq!(contracted, 0, "no pre-existing row gains a contract count");
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user