Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f70cf4415c | |||
| eab522a1eb |
@@ -0,0 +1,12 @@
|
|||||||
|
target/
|
||||||
|
**/target/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
openfut.db
|
||||||
+1
-1
@@ -90,4 +90,4 @@ migrations/ SQLite migration SQL files
|
|||||||
- **No copyrighted assets.** All card data in `data/` must be original.
|
- **No copyrighted assets.** All card data in `data/` must be original.
|
||||||
- **No real EA services.** Do not hardcode or reverse-engineer EA endpoints.
|
- **No real EA services.** Do not hardcode or reverse-engineer EA endpoints.
|
||||||
- **Game-independent core.** `openfut-core` must stay game-agnostic;
|
- **Game-independent core.** `openfut-core` must stay game-agnostic;
|
||||||
FIFA-specific logic belongs in `openfut-bridge`.
|
FIFA-specific logic belongs in the emulation layer (`fifa17-recon/`).
|
||||||
|
|||||||
+1
-2
@@ -5,7 +5,7 @@ edition = "2021"
|
|||||||
authors = ["OpenFUT Contributors"]
|
authors = ["OpenFUT Contributors"]
|
||||||
description = "Offline Ultimate Team backend — game-independent core"
|
description = "Offline Ultimate Team backend — game-independent core"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/openfut/openfut-core"
|
repository = "https://git.aleshym.co/funman300/OpenFUT-Core.git"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "openfut_core"
|
name = "openfut_core"
|
||||||
@@ -37,4 +37,3 @@ 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"
|
|
||||||
|
|||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# ---- OpenFUT Core: offline FUT backend (Axum + bundled SQLite) ----
|
||||||
|
# Multi-stage: build with the Rust toolchain, ship a slim Debian runtime.
|
||||||
|
# rustls + bundled SQLite mean no OpenSSL/system-sqlite at runtime.
|
||||||
|
|
||||||
|
FROM rust:1-bookworm AS builder
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Cache dependency compilation: copy manifests first, build a stub, then the
|
||||||
|
# real sources. sqlx migrations are compiled in via sqlx::migrate!, so the
|
||||||
|
# migrations/ dir must be present at build time.
|
||||||
|
COPY Cargo.toml Cargo.lock* ./
|
||||||
|
RUN mkdir -p src \
|
||||||
|
&& echo 'fn main() {}' > src/main.rs \
|
||||||
|
&& echo '' > src/lib.rs \
|
||||||
|
&& cargo build --release --bin openfut-core 2>/dev/null || true
|
||||||
|
RUN rm -rf src
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
COPY migrations ./migrations
|
||||||
|
# Touch so cargo rebuilds against the real sources rather than the stub.
|
||||||
|
RUN touch src/main.rs src/lib.rs \
|
||||||
|
&& cargo build --release --bin openfut-core
|
||||||
|
|
||||||
|
# ---- Runtime ----
|
||||||
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
# curl is used by the compose/Docker healthcheck to hit /health.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Run unprivileged.
|
||||||
|
RUN useradd --system --uid 10001 --create-home --home-dir /app openfut
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /build/target/release/openfut-core /usr/local/bin/openfut-core
|
||||||
|
# data/ is read at runtime from DATA_DIR (moddable JSON content) — bundle it.
|
||||||
|
COPY --chown=openfut:openfut data ./data
|
||||||
|
|
||||||
|
# Persist the SQLite database on a named volume.
|
||||||
|
RUN mkdir -p /app/db && chown openfut:openfut /app/db
|
||||||
|
|
||||||
|
USER openfut
|
||||||
|
|
||||||
|
ENV LISTEN_ADDR=0.0.0.0:8080 \
|
||||||
|
DATABASE_URL=sqlite:///app/db/openfut.db \
|
||||||
|
DATA_DIR=/app/data \
|
||||||
|
RUST_LOG=openfut_core=info,tower_http=info
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
VOLUME ["/app/db"]
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=15s --timeout=4s --start-period=10s --retries=5 \
|
||||||
|
CMD curl -fsS http://127.0.0.1:8080/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["openfut-core"]
|
||||||
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
**Offline Ultimate Team backend — game-independent.**
|
**Offline Ultimate Team backend — game-independent.**
|
||||||
|
|
||||||
OpenFUT Core is the heart of the OpenFUT project: a fully offline, single-player FUT-style backend written in Rust. It is deliberately decoupled from any specific game, though it is designed to power a FIFA 23 offline experience.
|
OpenFUT Core is the heart of the OpenFUT project: a fully offline, single-player FUT-style backend
|
||||||
|
written in Rust. It is deliberately decoupled from any specific game; it is designed to power the
|
||||||
|
offline FUT economy behind the project's FIFA 17 emulation layer (and, eventually, a FIFA 23 port).
|
||||||
|
The emulation layer and Core are **not yet wired together** — see the OpenFUT Vault
|
||||||
|
(`../OpenFUT-Vault/`) for canonical project state.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,546 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"id": "fifa17_101490",
|
|
||||||
"name": "Conor Casey",
|
|
||||||
"overall": 64,
|
|
||||||
"position": "ST",
|
|
||||||
"nation": "United States",
|
|
||||||
"league": "MLS",
|
|
||||||
"club": "Columbus Crew SC",
|
|
||||||
"pace": 43,
|
|
||||||
"shooting": 65,
|
|
||||||
"passing": 52,
|
|
||||||
"dribbling": 60,
|
|
||||||
"defending": 33,
|
|
||||||
"physical": 72,
|
|
||||||
"rarity": "bronze",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_101880",
|
|
||||||
"name": "Rob Green",
|
|
||||||
"overall": 74,
|
|
||||||
"position": "GK",
|
|
||||||
"nation": "England",
|
|
||||||
"league": "EFL Championship",
|
|
||||||
"club": "Leeds United",
|
|
||||||
"pace": 78,
|
|
||||||
"shooting": 70,
|
|
||||||
"passing": 62,
|
|
||||||
"dribbling": 77,
|
|
||||||
"defending": 47,
|
|
||||||
"physical": 71,
|
|
||||||
"rarity": "silver",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_102356",
|
|
||||||
"name": "Markus Feulner",
|
|
||||||
"overall": 74,
|
|
||||||
"position": "CM",
|
|
||||||
"nation": "Germany",
|
|
||||||
"league": "Bundesliga",
|
|
||||||
"club": "Augsburg",
|
|
||||||
"pace": 58,
|
|
||||||
"shooting": 70,
|
|
||||||
"passing": 75,
|
|
||||||
"dribbling": 71,
|
|
||||||
"defending": 66,
|
|
||||||
"physical": 71,
|
|
||||||
"rarity": "silver",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_102593",
|
|
||||||
"name": "Craig Woodman",
|
|
||||||
"overall": 64,
|
|
||||||
"position": "LB",
|
|
||||||
"nation": "England",
|
|
||||||
"league": "EFL League Two",
|
|
||||||
"club": "Exeter City",
|
|
||||||
"pace": 66,
|
|
||||||
"shooting": 45,
|
|
||||||
"passing": 58,
|
|
||||||
"dribbling": 60,
|
|
||||||
"defending": 62,
|
|
||||||
"physical": 65,
|
|
||||||
"rarity": "bronze",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_105046",
|
|
||||||
"name": "Anders Østli",
|
|
||||||
"overall": 64,
|
|
||||||
"position": "CB",
|
|
||||||
"nation": "Norway",
|
|
||||||
"league": "Tippeligaen",
|
|
||||||
"club": "Sarpsborg 08 FF",
|
|
||||||
"pace": 54,
|
|
||||||
"shooting": 46,
|
|
||||||
"passing": 54,
|
|
||||||
"dribbling": 52,
|
|
||||||
"defending": 62,
|
|
||||||
"physical": 75,
|
|
||||||
"rarity": "bronze",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_107298",
|
|
||||||
"name": "Yohann Pelé",
|
|
||||||
"overall": 74,
|
|
||||||
"position": "GK",
|
|
||||||
"nation": "France",
|
|
||||||
"league": "Ligue 1",
|
|
||||||
"club": "O. de Marseille",
|
|
||||||
"pace": 75,
|
|
||||||
"shooting": 74,
|
|
||||||
"passing": 72,
|
|
||||||
"dribbling": 70,
|
|
||||||
"defending": 49,
|
|
||||||
"physical": 76,
|
|
||||||
"rarity": "silver",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_107713",
|
|
||||||
"name": "Tom Starke",
|
|
||||||
"overall": 74,
|
|
||||||
"position": "GK",
|
|
||||||
"nation": "Germany",
|
|
||||||
"league": "Bundesliga",
|
|
||||||
"club": "Bayern",
|
|
||||||
"pace": 76,
|
|
||||||
"shooting": 73,
|
|
||||||
"passing": 59,
|
|
||||||
"dribbling": 72,
|
|
||||||
"defending": 39,
|
|
||||||
"physical": 76,
|
|
||||||
"rarity": "silver",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_110020",
|
|
||||||
"name": "Sergio Pelegrín",
|
|
||||||
"overall": 74,
|
|
||||||
"position": "CB",
|
|
||||||
"nation": "Spain",
|
|
||||||
"league": "LaLiga 1 I 2 I 3",
|
|
||||||
"club": "Elche CF",
|
|
||||||
"pace": 45,
|
|
||||||
"shooting": 32,
|
|
||||||
"passing": 52,
|
|
||||||
"dribbling": 49,
|
|
||||||
"defending": 75,
|
|
||||||
"physical": 76,
|
|
||||||
"rarity": "silver",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_110026",
|
|
||||||
"name": "Cani",
|
|
||||||
"overall": 74,
|
|
||||||
"position": "LM",
|
|
||||||
"nation": "Spain",
|
|
||||||
"league": "LaLiga 1 I 2 I 3",
|
|
||||||
"club": "Real Zaragoza",
|
|
||||||
"pace": 67,
|
|
||||||
"shooting": 72,
|
|
||||||
"passing": 73,
|
|
||||||
"dribbling": 78,
|
|
||||||
"defending": 45,
|
|
||||||
"physical": 61,
|
|
||||||
"rarity": "silver",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_11811",
|
|
||||||
"name": "Paul Green",
|
|
||||||
"overall": 64,
|
|
||||||
"position": "CM",
|
|
||||||
"nation": "Republic of Ireland",
|
|
||||||
"league": "EFL League One",
|
|
||||||
"club": "Oldham Athletic",
|
|
||||||
"pace": 65,
|
|
||||||
"shooting": 58,
|
|
||||||
"passing": 62,
|
|
||||||
"dribbling": 63,
|
|
||||||
"defending": 62,
|
|
||||||
"physical": 68,
|
|
||||||
"rarity": "bronze",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_139720",
|
|
||||||
"name": "Vincent Kompany",
|
|
||||||
"overall": 86,
|
|
||||||
"position": "CB",
|
|
||||||
"nation": "Belgium",
|
|
||||||
"league": "Premier League",
|
|
||||||
"club": "Manchester City",
|
|
||||||
"pace": 69,
|
|
||||||
"shooting": 54,
|
|
||||||
"passing": 62,
|
|
||||||
"dribbling": 65,
|
|
||||||
"defending": 86,
|
|
||||||
"physical": 81,
|
|
||||||
"rarity": "gold",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_146562",
|
|
||||||
"name": "Santi Cazorla",
|
|
||||||
"overall": 86,
|
|
||||||
"position": "CAM",
|
|
||||||
"nation": "Spain",
|
|
||||||
"league": "Premier League",
|
|
||||||
"club": "Arsenal",
|
|
||||||
"pace": 71,
|
|
||||||
"shooting": 78,
|
|
||||||
"passing": 85,
|
|
||||||
"dribbling": 86,
|
|
||||||
"defending": 57,
|
|
||||||
"physical": 64,
|
|
||||||
"rarity": "gold",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_153079",
|
|
||||||
"name": "Sergio Agüero",
|
|
||||||
"overall": 89,
|
|
||||||
"position": "ST",
|
|
||||||
"nation": "Argentina",
|
|
||||||
"league": "Premier League",
|
|
||||||
"club": "Manchester City",
|
|
||||||
"pace": 89,
|
|
||||||
"shooting": 88,
|
|
||||||
"passing": 75,
|
|
||||||
"dribbling": 89,
|
|
||||||
"defending": 23,
|
|
||||||
"physical": 70,
|
|
||||||
"rarity": "gold",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_158023",
|
|
||||||
"name": "Lionel Messi",
|
|
||||||
"overall": 93,
|
|
||||||
"position": "RW",
|
|
||||||
"nation": "Argentina",
|
|
||||||
"league": "LaLiga Santander",
|
|
||||||
"club": "FC Barcelona",
|
|
||||||
"pace": 89,
|
|
||||||
"shooting": 90,
|
|
||||||
"passing": 86,
|
|
||||||
"dribbling": 96,
|
|
||||||
"defending": 26,
|
|
||||||
"physical": 61,
|
|
||||||
"rarity": "gold",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_162895",
|
|
||||||
"name": "Cesc Fàbregas",
|
|
||||||
"overall": 86,
|
|
||||||
"position": "CM",
|
|
||||||
"nation": "Spain",
|
|
||||||
"league": "Premier League",
|
|
||||||
"club": "Chelsea",
|
|
||||||
"pace": 63,
|
|
||||||
"shooting": 77,
|
|
||||||
"passing": 89,
|
|
||||||
"dribbling": 81,
|
|
||||||
"defending": 61,
|
|
||||||
"physical": 64,
|
|
||||||
"rarity": "gold",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_163705",
|
|
||||||
"name": "Steve Mandanda",
|
|
||||||
"overall": 85,
|
|
||||||
"position": "GK",
|
|
||||||
"nation": "France",
|
|
||||||
"league": "Premier League",
|
|
||||||
"club": "Crystal Palace",
|
|
||||||
"pace": 86,
|
|
||||||
"shooting": 80,
|
|
||||||
"passing": 79,
|
|
||||||
"dribbling": 85,
|
|
||||||
"defending": 49,
|
|
||||||
"physical": 81,
|
|
||||||
"rarity": "gold",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_165229",
|
|
||||||
"name": "Laurent Koscielny",
|
|
||||||
"overall": 85,
|
|
||||||
"position": "CB",
|
|
||||||
"nation": "France",
|
|
||||||
"league": "Premier League",
|
|
||||||
"club": "Arsenal",
|
|
||||||
"pace": 78,
|
|
||||||
"shooting": 40,
|
|
||||||
"passing": 62,
|
|
||||||
"dribbling": 65,
|
|
||||||
"defending": 85,
|
|
||||||
"physical": 78,
|
|
||||||
"rarity": "gold",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_167948",
|
|
||||||
"name": "Hugo Lloris",
|
|
||||||
"overall": 88,
|
|
||||||
"position": "GK",
|
|
||||||
"nation": "France",
|
|
||||||
"league": "Premier League",
|
|
||||||
"club": "Spurs",
|
|
||||||
"pace": 87,
|
|
||||||
"shooting": 87,
|
|
||||||
"passing": 68,
|
|
||||||
"dribbling": 90,
|
|
||||||
"defending": 64,
|
|
||||||
"physical": 82,
|
|
||||||
"rarity": "gold",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_168542",
|
|
||||||
"name": "David Silva",
|
|
||||||
"overall": 87,
|
|
||||||
"position": "CAM",
|
|
||||||
"nation": "Spain",
|
|
||||||
"league": "Premier League",
|
|
||||||
"club": "Manchester City",
|
|
||||||
"pace": 68,
|
|
||||||
"shooting": 72,
|
|
||||||
"passing": 87,
|
|
||||||
"dribbling": 87,
|
|
||||||
"defending": 32,
|
|
||||||
"physical": 58,
|
|
||||||
"rarity": "gold",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_176580",
|
|
||||||
"name": "Luis Suárez",
|
|
||||||
"overall": 92,
|
|
||||||
"position": "ST",
|
|
||||||
"nation": "Uruguay",
|
|
||||||
"league": "LaLiga Santander",
|
|
||||||
"club": "FC Barcelona",
|
|
||||||
"pace": 82,
|
|
||||||
"shooting": 90,
|
|
||||||
"passing": 79,
|
|
||||||
"dribbling": 87,
|
|
||||||
"defending": 42,
|
|
||||||
"physical": 79,
|
|
||||||
"rarity": "gold",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_176635",
|
|
||||||
"name": "Mesut Özil",
|
|
||||||
"overall": 89,
|
|
||||||
"position": "CAM",
|
|
||||||
"nation": "Germany",
|
|
||||||
"league": "Premier League",
|
|
||||||
"club": "Arsenal",
|
|
||||||
"pace": 72,
|
|
||||||
"shooting": 74,
|
|
||||||
"passing": 86,
|
|
||||||
"dribbling": 86,
|
|
||||||
"defending": 24,
|
|
||||||
"physical": 58,
|
|
||||||
"rarity": "gold",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_177388",
|
|
||||||
"name": "Dimitri Payet",
|
|
||||||
"overall": 86,
|
|
||||||
"position": "LM",
|
|
||||||
"nation": "France",
|
|
||||||
"league": "Premier League",
|
|
||||||
"club": "West Ham",
|
|
||||||
"pace": 77,
|
|
||||||
"shooting": 78,
|
|
||||||
"passing": 87,
|
|
||||||
"dribbling": 87,
|
|
||||||
"defending": 42,
|
|
||||||
"physical": 70,
|
|
||||||
"rarity": "gold",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_183277",
|
|
||||||
"name": "Eden Hazard",
|
|
||||||
"overall": 88,
|
|
||||||
"position": "LM",
|
|
||||||
"nation": "Belgium",
|
|
||||||
"league": "Premier League",
|
|
||||||
"club": "Chelsea",
|
|
||||||
"pace": 90,
|
|
||||||
"shooting": 81,
|
|
||||||
"passing": 82,
|
|
||||||
"dribbling": 91,
|
|
||||||
"defending": 32,
|
|
||||||
"physical": 64,
|
|
||||||
"rarity": "gold",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_184941",
|
|
||||||
"name": "Alexis Sánchez",
|
|
||||||
"overall": 87,
|
|
||||||
"position": "LW",
|
|
||||||
"nation": "Chile",
|
|
||||||
"league": "Premier League",
|
|
||||||
"club": "Arsenal",
|
|
||||||
"pace": 86,
|
|
||||||
"shooting": 82,
|
|
||||||
"passing": 79,
|
|
||||||
"dribbling": 88,
|
|
||||||
"defending": 39,
|
|
||||||
"physical": 74,
|
|
||||||
"rarity": "gold",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_190871",
|
|
||||||
"name": "Neymar",
|
|
||||||
"overall": 92,
|
|
||||||
"position": "LW",
|
|
||||||
"nation": "Brazil",
|
|
||||||
"league": "LaLiga Santander",
|
|
||||||
"club": "FC Barcelona",
|
|
||||||
"pace": 91,
|
|
||||||
"shooting": 84,
|
|
||||||
"passing": 78,
|
|
||||||
"dribbling": 95,
|
|
||||||
"defending": 30,
|
|
||||||
"physical": 56,
|
|
||||||
"rarity": "gold",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_192119",
|
|
||||||
"name": "Thibaut Courtois",
|
|
||||||
"overall": 89,
|
|
||||||
"position": "GK",
|
|
||||||
"nation": "Belgium",
|
|
||||||
"league": "Premier League",
|
|
||||||
"club": "Chelsea",
|
|
||||||
"pace": 84,
|
|
||||||
"shooting": 91,
|
|
||||||
"passing": 69,
|
|
||||||
"dribbling": 89,
|
|
||||||
"defending": 48,
|
|
||||||
"physical": 86,
|
|
||||||
"rarity": "gold",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_192985",
|
|
||||||
"name": "Kevin De Bruyne",
|
|
||||||
"overall": 88,
|
|
||||||
"position": "CAM",
|
|
||||||
"nation": "Belgium",
|
|
||||||
"league": "Premier League",
|
|
||||||
"club": "Manchester City",
|
|
||||||
"pace": 77,
|
|
||||||
"shooting": 83,
|
|
||||||
"passing": 86,
|
|
||||||
"dribbling": 84,
|
|
||||||
"defending": 40,
|
|
||||||
"physical": 75,
|
|
||||||
"rarity": "gold",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_193080",
|
|
||||||
"name": "David De Gea",
|
|
||||||
"overall": 90,
|
|
||||||
"position": "GK",
|
|
||||||
"nation": "Spain",
|
|
||||||
"league": "Premier League",
|
|
||||||
"club": "Manchester Utd",
|
|
||||||
"pace": 88,
|
|
||||||
"shooting": 85,
|
|
||||||
"passing": 87,
|
|
||||||
"dribbling": 90,
|
|
||||||
"defending": 56,
|
|
||||||
"physical": 85,
|
|
||||||
"rarity": "gold",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_195864",
|
|
||||||
"name": "Paul Pogba",
|
|
||||||
"overall": 88,
|
|
||||||
"position": "CM",
|
|
||||||
"nation": "France",
|
|
||||||
"league": "Premier League",
|
|
||||||
"club": "Manchester Utd",
|
|
||||||
"pace": 77,
|
|
||||||
"shooting": 80,
|
|
||||||
"passing": 83,
|
|
||||||
"dribbling": 87,
|
|
||||||
"defending": 72,
|
|
||||||
"physical": 87,
|
|
||||||
"rarity": "gold",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_20801",
|
|
||||||
"name": "Cristiano Ronaldo",
|
|
||||||
"overall": 94,
|
|
||||||
"position": "LW",
|
|
||||||
"nation": "Portugal",
|
|
||||||
"league": "LaLiga Santander",
|
|
||||||
"club": "Real Madrid",
|
|
||||||
"pace": 92,
|
|
||||||
"shooting": 92,
|
|
||||||
"passing": 81,
|
|
||||||
"dribbling": 91,
|
|
||||||
"defending": 33,
|
|
||||||
"physical": 80,
|
|
||||||
"rarity": "gold",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_41236",
|
|
||||||
"name": "Zlatan Ibrahimović",
|
|
||||||
"overall": 90,
|
|
||||||
"position": "ST",
|
|
||||||
"nation": "Sweden",
|
|
||||||
"league": "Premier League",
|
|
||||||
"club": "Manchester Utd",
|
|
||||||
"pace": 72,
|
|
||||||
"shooting": 90,
|
|
||||||
"passing": 81,
|
|
||||||
"dribbling": 85,
|
|
||||||
"defending": 31,
|
|
||||||
"physical": 86,
|
|
||||||
"rarity": "gold",
|
|
||||||
"image_path": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fifa17_48940",
|
|
||||||
"name": "Petr Čech",
|
|
||||||
"overall": 88,
|
|
||||||
"position": "GK",
|
|
||||||
"nation": "Czech Republic",
|
|
||||||
"league": "Premier League",
|
|
||||||
"club": "Arsenal",
|
|
||||||
"pace": 83,
|
|
||||||
"shooting": 90,
|
|
||||||
"passing": 77,
|
|
||||||
"dribbling": 85,
|
|
||||||
"defending": 48,
|
|
||||||
"physical": 85,
|
|
||||||
"rarity": "gold",
|
|
||||||
"image_path": null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
-- Distinguish NPC-generated listings from player-posted ones so that the
|
|
||||||
-- periodic NPC refresh does not accidentally wipe player listings.
|
|
||||||
ALTER TABLE market_listings ADD COLUMN is_npc INTEGER NOT NULL DEFAULT 0;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_market_npc ON market_listings(is_npc, sold);
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
-- Track when the player last claimed their rivals weekly reward to enforce a
|
|
||||||
-- 24-hour cooldown between claims.
|
|
||||||
ALTER TABLE seasons ADD COLUMN rivals_last_claimed_at TEXT;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
-- Multi-game support. Every profile (and therefore all of its downstream state,
|
|
||||||
-- which hangs off profiles(id) via profile_id / club_id foreign keys) is scoped to
|
|
||||||
-- a game. Bridges identify their game with the X-OpenFUT-Game request header.
|
|
||||||
--
|
|
||||||
-- Default 'fifa23' preserves the existing single-game behaviour: the current bridge
|
|
||||||
-- and the integration tests send no game header, so they keep operating on the same
|
|
||||||
-- (now fifa23-tagged) profile with zero behaviour change. The FIFA 17 bridge sends
|
|
||||||
-- X-OpenFUT-Game: fifa17 and therefore gets its own isolated profile/club/state.
|
|
||||||
--
|
|
||||||
-- Only profiles needs the column: get_active_profile becomes game-scoped, and because
|
|
||||||
-- all other tables reference a profile (directly via profile_id or via
|
|
||||||
-- club_id -> clubs.profile_id), scoping the active profile isolates the whole tree.
|
|
||||||
ALTER TABLE profiles ADD COLUMN game_id TEXT NOT NULL DEFAULT 'fifa23';
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_profiles_game ON profiles(game_id);
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
-- Generic, game-scoped OPAQUE extension storage.
|
|
||||||
--
|
|
||||||
-- Core persists, versions, associates (to a canonical entity + a server-computed
|
|
||||||
-- fingerprint), and enforces generic safety bounds on these bytes — but NEVER
|
|
||||||
-- interprets them. A game adapter owns the payload's schema and meaning. This is
|
|
||||||
-- how a game keeps wire-only round-trip state (e.g. FIFA 17 squad custom[]/
|
|
||||||
-- kicktakers/kitNumber) durable and atomic with its canonical entity without
|
|
||||||
-- leaking game-specific columns into generic Core.
|
|
||||||
--
|
|
||||||
-- Scope key: (game_id, entity_kind, entity_id, namespace). `namespace` is an
|
|
||||||
-- opaque adapter key (e.g. "fifa17.squad.v1"); `schema_version` is the adapter's
|
|
||||||
-- payload version (distinct from this table's storage schema).
|
|
||||||
CREATE TABLE IF NOT EXISTS game_entity_ext (
|
|
||||||
game_id TEXT NOT NULL,
|
|
||||||
entity_kind TEXT NOT NULL,
|
|
||||||
entity_id TEXT NOT NULL,
|
|
||||||
namespace TEXT NOT NULL,
|
|
||||||
schema_version INTEGER NOT NULL,
|
|
||||||
canonical_fingerprint TEXT NOT NULL,
|
|
||||||
payload TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (game_id, entity_kind, entity_id, namespace)
|
|
||||||
);
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
-- Generic provenance/rerun-identity token for a transactionally imported profile.
|
|
||||||
--
|
|
||||||
-- Set by the generic profile-import path (services::import). A NULL value means
|
|
||||||
-- the profile was created by normal gameplay / dev seeding, not an import, and
|
|
||||||
-- MUST NOT be silently clobbered by an import targeting the same game. A
|
|
||||||
-- matching token on a re-run is an idempotent no-op; a differing token against
|
|
||||||
-- an already-imported game fails until an explicit update mode exists.
|
|
||||||
--
|
|
||||||
-- Core never interprets the token's structure; the importer adapter chooses it.
|
|
||||||
ALTER TABLE profiles ADD COLUMN import_fingerprint TEXT;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
-- 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)
|
|
||||||
);
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
+1
-76
@@ -42,38 +42,7 @@ pub struct AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||||
let mut card_db = CardDb::load(&cfg.data_dir)?;
|
let card_db = Arc::new(CardDb::load(&cfg.data_dir)?);
|
||||||
for game in &cfg.dev_content_games {
|
|
||||||
card_db.load_game_dev(&cfg.data_dir, game)?;
|
|
||||||
}
|
|
||||||
for pack in &cfg.content_packs {
|
|
||||||
card_db.load_pack(pack)?;
|
|
||||||
}
|
|
||||||
let card_db = Arc::new(card_db);
|
|
||||||
|
|
||||||
// Content preflight: every owned card MUST reference a loaded CardDefinition.
|
|
||||||
// A real profile with owned players but missing definitions fails LOUDLY here
|
|
||||||
// rather than silently serving an empty /collection. Empty owned_cards (fresh
|
|
||||||
// DB, tests) passes. A SINGLE missing definition is caught, not only the
|
|
||||||
// zero-loaded case.
|
|
||||||
{
|
|
||||||
let referenced: Vec<String> =
|
|
||||||
sqlx::query_scalar("SELECT DISTINCT card_id FROM owned_cards")
|
|
||||||
.fetch_all(&pool)
|
|
||||||
.await?;
|
|
||||||
let missing: Vec<String> = referenced
|
|
||||||
.into_iter()
|
|
||||||
.filter(|id| card_db.get(id).is_none())
|
|
||||||
.collect();
|
|
||||||
if !missing.is_empty() {
|
|
||||||
let sample: Vec<&String> = missing.iter().take(5).collect();
|
|
||||||
anyhow::bail!(
|
|
||||||
"content preflight failed: {} owned card(s) reference CardDefinitionId(s) not loaded (e.g. {:?}). Load the production content pack via OPENFUT_CONTENT_PACKS.",
|
|
||||||
missing.len(),
|
|
||||||
sample
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let pack_defs = Arc::new(load_pack_definitions(&cfg.data_dir)?);
|
let pack_defs = Arc::new(load_pack_definitions(&cfg.data_dir)?);
|
||||||
let obj_defs = Arc::new(load_objective_definitions(&cfg.data_dir)?);
|
let obj_defs = Arc::new(load_objective_definitions(&cfg.data_dir)?);
|
||||||
let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?);
|
let sbc_defs = Arc::new(load_sbc_definitions(&cfg.data_dir)?);
|
||||||
@@ -169,42 +138,9 @@ 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))
|
|
||||||
.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))
|
||||||
.route("/economy/balance", get(routes::economy::get_balance))
|
|
||||||
.route(
|
|
||||||
"/economy/entitlements",
|
|
||||||
get(routes::economy::get_entitlements),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/economy/purchase-entitlement",
|
|
||||||
post(routes::economy::post_purchase_entitlement),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/economy/redeem-entitlement",
|
|
||||||
post(routes::economy::post_redeem_entitlement),
|
|
||||||
)
|
|
||||||
.route("/economy/sell-item", post(routes::economy::post_sell_item))
|
|
||||||
.route(
|
|
||||||
"/economy/grant-reward",
|
|
||||||
post(routes::economy::post_grant_reward),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/economy/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),
|
||||||
@@ -232,8 +168,6 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
.route("/packs/open/:pack_id", post(routes::packs::post_open_pack))
|
.route("/packs/open/:pack_id", post(routes::packs::post_open_pack))
|
||||||
.route("/squad", get(routes::squad::get_squad))
|
.route("/squad", get(routes::squad::get_squad))
|
||||||
.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/replace", put(routes::squad::put_squad_replace))
|
|
||||||
.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))
|
||||||
@@ -253,18 +187,9 @@ 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
-30
@@ -1,21 +1,12 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub listen_addr: String,
|
pub listen_addr: String,
|
||||||
pub database_url: String,
|
pub database_url: String,
|
||||||
pub data_dir: String,
|
pub data_dir: String,
|
||||||
|
#[allow(dead_code)]
|
||||||
pub max_connections: u32,
|
pub max_connections: u32,
|
||||||
/// Games whose opt-in development content pack (`data/games/<game>/dev/`) is
|
|
||||||
/// loaded IN ADDITION to the default `data/cards` catalog. Empty by default —
|
|
||||||
/// default/test content is never affected unless a game is named here.
|
|
||||||
pub dev_content_games: Vec<String>,
|
|
||||||
/// Explicit PRODUCTION content pack file paths (each a `CardDefinition[]`
|
|
||||||
/// JSON), loaded IN ADDITION to `data/cards` and any dev pack. This is the
|
|
||||||
/// production real-profile content path — deliberately NOT gated behind the
|
|
||||||
/// dev-only `dev_content_games`.
|
|
||||||
pub content_packs: Vec<PathBuf>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -29,26 +20,6 @@ impl Config {
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
.unwrap_or(5),
|
.unwrap_or(5),
|
||||||
dev_content_games: std::env::var("OPENFUT_DEV_CONTENT_GAMES")
|
|
||||||
.ok()
|
|
||||||
.map(|v| {
|
|
||||||
v.split(',')
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.map(String::from)
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default(),
|
|
||||||
content_packs: std::env::var("OPENFUT_CONTENT_PACKS")
|
|
||||||
.ok()
|
|
||||||
.map(|v| {
|
|
||||||
v.split(',')
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,24 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use sqlx::{
|
use sqlx::{
|
||||||
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
|
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
||||||
ConnectOptions, Connection, SqlitePool,
|
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) -> Result<Pool> {
|
||||||
info!("Connecting to database: {}", database_url);
|
info!("Connecting to database: {}", database_url);
|
||||||
// Per-connection options so EVERY pooled connection gets them: WAL for
|
let opts = SqliteConnectOptions::from_str(database_url)?.create_if_missing(true);
|
||||||
// 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(5)
|
||||||
.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
//! Request extractors shared across routes.
|
|
||||||
|
|
||||||
use axum::{
|
|
||||||
async_trait,
|
|
||||||
extract::FromRequestParts,
|
|
||||||
http::{request::Parts, HeaderName},
|
|
||||||
};
|
|
||||||
use std::convert::Infallible;
|
|
||||||
|
|
||||||
/// The game a request belongs to, read from the `X-OpenFUT-Game` header.
|
|
||||||
///
|
|
||||||
/// Multi-game support: each game bridge tags its requests with its own id
|
|
||||||
/// (e.g. `fifa17`, `fifa23`) so core can scope the active profile - and thus all
|
|
||||||
/// downstream club/card/squad/market state - to that game. Defaults to `fifa23`
|
|
||||||
/// when the header is absent, so the existing FIFA 23 bridge and the integration
|
|
||||||
/// tests (which send no header) keep operating on their game unchanged.
|
|
||||||
///
|
|
||||||
/// Extraction never fails: a missing or malformed header falls back to the default.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct GameId(pub String);
|
|
||||||
|
|
||||||
/// The game assumed when no `X-OpenFUT-Game` header is present.
|
|
||||||
pub const DEFAULT_GAME: &str = "fifa23";
|
|
||||||
|
|
||||||
static HEADER: HeaderName = HeaderName::from_static("x-openfut-game");
|
|
||||||
|
|
||||||
impl GameId {
|
|
||||||
pub fn as_str(&self) -> &str {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl<S> FromRequestParts<S> for GameId
|
|
||||||
where
|
|
||||||
S: Send + Sync,
|
|
||||||
{
|
|
||||||
type Rejection = Infallible;
|
|
||||||
|
|
||||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
|
||||||
let game = parts
|
|
||||||
.headers
|
|
||||||
.get(&HEADER)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.map(|s| s.trim())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.unwrap_or(DEFAULT_GAME)
|
|
||||||
.to_string();
|
|
||||||
Ok(GameId(game))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ pub mod app;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod extractors;
|
|
||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
pub mod modding;
|
pub mod modding;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
@@ -21,8 +20,6 @@ pub async fn build_app(pool: db::Pool, data_dir: &str) -> Result<Router> {
|
|||||||
database_url: "sqlite::memory:".into(),
|
database_url: "sqlite::memory:".into(),
|
||||||
data_dir: data_dir.to_string(),
|
data_dir: data_dir.to_string(),
|
||||||
max_connections: 1,
|
max_connections: 1,
|
||||||
dev_content_games: Vec::new(),
|
|
||||||
content_packs: Vec::new(),
|
|
||||||
};
|
};
|
||||||
app::build(pool, cfg).await
|
app::build(pool, cfg).await
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-44
@@ -1,4 +1,4 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::Result;
|
||||||
use openfut_core::{config, db, seed};
|
use openfut_core::{config, db, seed};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||||
@@ -12,54 +12,13 @@ async fn main() -> Result<()> {
|
|||||||
EnvFilter::try_from_default_env()
|
EnvFilter::try_from_default_env()
|
||||||
.unwrap_or_else(|_| "openfut_core=debug,tower_http=debug".into()),
|
.unwrap_or_else(|_| "openfut_core=debug,tower_http=debug".into()),
|
||||||
)
|
)
|
||||||
// Diagnostics on stderr so stdout carries only machine output (the
|
.with(tracing_subscriber::fmt::layer())
|
||||||
// `import`/`seed-dev` subcommands print a clean JSON result there).
|
|
||||||
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
|
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let cfg = config::Config::from_env()?;
|
let cfg = config::Config::from_env()?;
|
||||||
|
|
||||||
// Opt-in dev subcommand: `openfut-core seed-dev` seeds the FIFA 17 dev
|
|
||||||
// profile/club from the dev content pack, prints a coverage report, and
|
|
||||||
// exits. Normal server startup NEVER seeds dev inventory.
|
|
||||||
if std::env::args().nth(1).as_deref() == Some("seed-dev") {
|
|
||||||
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
|
|
||||||
db::run_migrations(&pool).await?;
|
|
||||||
let mut card_db = openfut_core::services::card_db::CardDb::load(&cfg.data_dir)?;
|
|
||||||
card_db.load_game_dev(&cfg.data_dir, seed::FIFA17_GAME)?;
|
|
||||||
let report = seed::seed_fifa17_dev(&pool, &card_db).await?;
|
|
||||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Opt-in generic import subcommand: `openfut-core import <request.json>`.
|
|
||||||
// Reads a GAME-AGNOSTIC ProfileImportRequest (the importer adapter translates
|
|
||||||
// FIFA17 source data into it), loads production content packs, runs preflight,
|
|
||||||
// and applies one all-or-nothing transaction. FIFA17 semantics live entirely
|
|
||||||
// in the adapter; Core only sees opaque ids + opaque extension bytes.
|
|
||||||
if std::env::args().nth(1).as_deref() == Some("import") {
|
|
||||||
let path = std::env::args()
|
|
||||||
.nth(2)
|
|
||||||
.context("usage: openfut-core import <request.json>")?;
|
|
||||||
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
|
|
||||||
db::run_migrations(&pool).await?;
|
|
||||||
let mut card_db = openfut_core::services::card_db::CardDb::load(&cfg.data_dir)?;
|
|
||||||
for pack in &cfg.content_packs {
|
|
||||||
card_db.load_pack(pack)?;
|
|
||||||
}
|
|
||||||
let raw = std::fs::read_to_string(&path)
|
|
||||||
.with_context(|| format!("read import request {path}"))?;
|
|
||||||
let req: openfut_core::services::import::ProfileImportRequest =
|
|
||||||
serde_json::from_str(&raw).context("parse import request JSON")?;
|
|
||||||
let outcome =
|
|
||||||
openfut_core::services::import::apply_profile_import(&pool, &card_db, &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).await?;
|
||||||
db::run_migrations(&pool).await?;
|
db::run_migrations(&pool).await?;
|
||||||
|
|
||||||
seed::maybe_seed(&pool).await?;
|
seed::maybe_seed(&pool).await?;
|
||||||
|
|||||||
+24
-1
@@ -1 +1,24 @@
|
|||||||
// Reserved for future modding loader utilities.
|
use anyhow::{Context, Result};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
/// Generic loader for JSON arrays from a directory.
|
||||||
|
pub fn load_json_dir<T: DeserializeOwned>(dir: &Path) -> Result<Vec<T>> {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
if !dir.exists() {
|
||||||
|
return Ok(items);
|
||||||
|
}
|
||||||
|
for entry in std::fs::read_dir(dir).with_context(|| format!("reading dir {dir:?}"))? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||||
|
let content =
|
||||||
|
std::fs::read_to_string(&path).with_context(|| format!("reading {path:?}"))?;
|
||||||
|
let batch: Vec<T> =
|
||||||
|
serde_json::from_str(&content).with_context(|| format!("parsing {path:?}"))?;
|
||||||
|
items.extend(batch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
//! Modding support: load JSON data files from the data/ directory.
|
//! Modding support: load JSON data files from the data/ directory.
|
||||||
//! All game content (cards, packs, objectives, SBCs) is data-driven.
|
//! All game content (cards, packs, objectives, SBCs) is data-driven.
|
||||||
|
|
||||||
|
pub mod loader;
|
||||||
|
|||||||
@@ -13,20 +13,6 @@ pub enum Rarity {
|
|||||||
Icon,
|
Icon,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Rarity {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Rarity::Bronze => "bronze",
|
|
||||||
Rarity::Silver => "silver",
|
|
||||||
Rarity::Gold => "gold",
|
|
||||||
Rarity::RareGold => "raregold",
|
|
||||||
Rarity::Totw => "totw",
|
|
||||||
Rarity::Hero => "hero",
|
|
||||||
Rarity::Icon => "icon",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Visual card quality tier (gold/silver/bronze).
|
/// Visual card quality tier (gold/silver/bronze).
|
||||||
///
|
///
|
||||||
/// Game-independent semantic dimension, kept distinct from `Rarity` (which also
|
/// Game-independent semantic dimension, kept distinct from `Rarity` (which also
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
//! Generic, game-scoped **opaque** extension state.
|
|
||||||
//!
|
|
||||||
//! Core stores and versions these bytes and associates them with a canonical
|
|
||||||
//! entity + a server-computed fingerprint, but never interprets them. A game
|
|
||||||
//! adapter owns the payload schema/meaning. This keeps game-only wire round-trip
|
|
||||||
//! state (e.g. a FIFA 17 squad's `custom[]`/`kicktakers`/`kitNumber`) durable and
|
|
||||||
//! atomic with its canonical entity without leaking game concepts into Core.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Generic safety bounds Core enforces without interpreting the payload.
|
|
||||||
pub const MAX_EXT_PAYLOAD_BYTES: usize = 64 * 1024;
|
|
||||||
pub const MAX_EXT_NAMESPACE_LEN: usize = 64;
|
|
||||||
|
|
||||||
/// An opaque extension payload a game adapter asks Core to persist atomically
|
|
||||||
/// alongside a canonical entity. `payload` is uninterpreted bytes-as-text.
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct OpaqueExtensionWrite {
|
|
||||||
/// Opaque adapter key, e.g. `"fifa17.squad.v1"`. Core treats it as a string.
|
|
||||||
pub namespace: String,
|
|
||||||
/// Adapter's payload schema version (distinct from the DB storage schema).
|
|
||||||
pub schema_version: i64,
|
|
||||||
/// Uninterpreted payload (the adapter's serialized game-only state).
|
|
||||||
pub payload: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OpaqueExtensionWrite {
|
|
||||||
/// Generic bounds check — namespace non-empty/length, payload size. Semantic
|
|
||||||
/// validation of the payload is the adapter's job; Core only guards size.
|
|
||||||
pub fn validate(&self) -> Result<(), String> {
|
|
||||||
if self.namespace.is_empty() || self.namespace.len() > MAX_EXT_NAMESPACE_LEN {
|
|
||||||
return Err(format!(
|
|
||||||
"namespace length {} out of bounds (1..={MAX_EXT_NAMESPACE_LEN})",
|
|
||||||
self.namespace.len()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if self.payload.len() > MAX_EXT_PAYLOAD_BYTES {
|
|
||||||
return Err(format!(
|
|
||||||
"extension payload {} bytes exceeds max {MAX_EXT_PAYLOAD_BYTES}",
|
|
||||||
self.payload.len()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A stored opaque extension row (read side). `canonical_fingerprint` is the
|
|
||||||
/// server-computed fingerprint of the canonical entity at write time; a reader
|
|
||||||
/// compares it against the entity's *current* fingerprint to detect staleness.
|
|
||||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
|
||||||
pub struct GameEntityExt {
|
|
||||||
pub game_id: String,
|
|
||||||
pub entity_kind: String,
|
|
||||||
pub entity_id: String,
|
|
||||||
pub namespace: String,
|
|
||||||
pub schema_version: i64,
|
|
||||||
pub canonical_fingerprint: String,
|
|
||||||
pub payload: String,
|
|
||||||
pub updated_at: String,
|
|
||||||
}
|
|
||||||
@@ -11,45 +11,6 @@ pub enum MatchOutcome {
|
|||||||
Loss,
|
Loss,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Canonical, game-independent economic result of a completed match. The game
|
|
||||||
/// adapter maps its own wire (FIFA17 `endReason`, score, …) onto this — Core
|
|
||||||
/// never sees a game-specific reason string.
|
|
||||||
///
|
|
||||||
/// * `Win` / `Draw` / `Loss` — a finished match; standard reward tiers.
|
|
||||||
/// * `Dnf` — did-not-finish (abandon/quit). Economically a loss, but tallied in
|
|
||||||
/// its own statistics bucket and never in `matches_lost`.
|
|
||||||
/// * `NoContest` — a voided match. Zero economic effect: no coins, XP, or
|
|
||||||
/// W/D/L/DNF change; recorded only for history + idempotency.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum MatchResultKind {
|
|
||||||
Win,
|
|
||||||
Draw,
|
|
||||||
Loss,
|
|
||||||
Dnf,
|
|
||||||
NoContest,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MatchResultKind {
|
|
||||||
/// The canonical lowercase token persisted in `matches.outcome` and
|
|
||||||
/// `match_completions.result`.
|
|
||||||
pub fn as_str(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
MatchResultKind::Win => "win",
|
|
||||||
MatchResultKind::Draw => "draw",
|
|
||||||
MatchResultKind::Loss => "loss",
|
|
||||||
MatchResultKind::Dnf => "dnf",
|
|
||||||
MatchResultKind::NoContest => "no_contest",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether this result applies any economic effect (coins / XP / statistics /
|
|
||||||
/// objectives / achievements). `NoContest` is the only non-economic result.
|
|
||||||
pub fn is_economic(self) -> bool {
|
|
||||||
!matches!(self, MatchResultKind::NoContest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct SubmitMatchRequest {
|
pub struct SubmitMatchRequest {
|
||||||
pub squad_id: String,
|
pub squad_id: String,
|
||||||
@@ -126,44 +87,3 @@ pub struct MatchRewardResult {
|
|||||||
/// Achievements unlocked as a result of this match.
|
/// Achievements unlocked as a result of this match.
|
||||||
pub achievements_unlocked: Vec<AchievementDefinition>,
|
pub achievements_unlocked: Vec<AchievementDefinition>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Outcome of [`crate::services::match_service::complete_match`].
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct MatchCompletionResult {
|
|
||||||
/// `true` when THIS call applied the economic effect; `false` on an
|
|
||||||
/// idempotent replay of an already-completed match (the persisted canonical
|
|
||||||
/// result is echoed unchanged).
|
|
||||||
pub applied: bool,
|
|
||||||
pub match_identity: String,
|
|
||||||
pub result: MatchResultKind,
|
|
||||||
pub coins_awarded: i64,
|
|
||||||
pub xp_awarded: i64,
|
|
||||||
/// Club balance after completion — echoed so the host can render the wire
|
|
||||||
/// reward body without a second round-trip.
|
|
||||||
pub coins_balance: i64,
|
|
||||||
/// Objectives completed by this match (empty on a replay).
|
|
||||||
pub objectives_updated: Vec<String>,
|
|
||||||
/// Level-ups gained from this match's XP (empty on a replay).
|
|
||||||
pub level_ups: Vec<LevelUpEvent>,
|
|
||||||
/// Achievements unlocked by this match (empty on a replay).
|
|
||||||
pub achievements_unlocked: Vec<AchievementDefinition>,
|
|
||||||
pub match_record: Match,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ pub mod club;
|
|||||||
pub mod draft;
|
pub mod draft;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod fut_champs;
|
pub mod fut_champs;
|
||||||
pub mod game_ext;
|
|
||||||
pub mod market;
|
pub mod market;
|
||||||
pub mod match_result;
|
pub mod match_result;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
|
|||||||
@@ -21,19 +21,6 @@ pub enum ObjectiveMetric {
|
|||||||
CoinsEarned,
|
CoinsEarned,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ObjectiveMetric {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
ObjectiveMetric::MatchesWon => "matcheswon",
|
|
||||||
ObjectiveMetric::MatchesPlayed => "matchesplayed",
|
|
||||||
ObjectiveMetric::GoalsScored => "goalsscored",
|
|
||||||
ObjectiveMetric::PacksOpened => "packsopened",
|
|
||||||
ObjectiveMetric::SbcsCompleted => "sbcscompleted",
|
|
||||||
ObjectiveMetric::CoinsEarned => "coinsearned",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Objective definition from data/objectives/*.json
|
/// Objective definition from data/objectives/*.json
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ObjectiveDefinition {
|
pub struct ObjectiveDefinition {
|
||||||
|
|||||||
@@ -8,22 +8,18 @@ pub struct Profile {
|
|||||||
pub username: String,
|
pub username: String,
|
||||||
pub level: i64,
|
pub level: i64,
|
||||||
pub xp: i64,
|
pub xp: i64,
|
||||||
/// The game this profile belongs to (e.g. "fifa17", "fifa23"). Scopes all of
|
|
||||||
/// this profile's downstream state so multiple games share one core + DB.
|
|
||||||
pub game_id: String,
|
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Profile {
|
impl Profile {
|
||||||
pub fn new(username: impl Into<String>, game_id: impl Into<String>) -> Self {
|
pub fn new(username: impl Into<String>) -> Self {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
Self {
|
Self {
|
||||||
id: Uuid::new_v4().to_string(),
|
id: Uuid::new_v4().to_string(),
|
||||||
username: username.into(),
|
username: username.into(),
|
||||||
level: 1,
|
level: 1,
|
||||||
xp: 0,
|
xp: 0,
|
||||||
game_id: game_id.into(),
|
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-14
@@ -33,17 +33,16 @@ 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)]
|
||||||
@@ -52,17 +51,6 @@ 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,
|
||||||
|
|||||||
@@ -96,7 +96,4 @@ pub struct SquadReplaced {
|
|||||||
pub slots_written: usize,
|
pub slots_written: usize,
|
||||||
pub evaluation: crate::services::squad_rules::SquadEvaluation,
|
pub evaluation: crate::services::squad_rules::SquadEvaluation,
|
||||||
pub client_disagreements: Vec<crate::services::squad_rules::EvaluationComparison>,
|
pub client_disagreements: Vec<crate::services::squad_rules::EvaluationComparison>,
|
||||||
/// Server-computed deterministic fingerprint of the committed canonical squad
|
|
||||||
/// (anchors any opaque game extension against stale projection).
|
|
||||||
pub canonical_fingerprint: String,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ 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,
|
||||||
@@ -26,7 +25,6 @@ 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,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
@@ -8,11 +7,8 @@ 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(
|
pub async fn get_achievements(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
State(state): State<AppState>,
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
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 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)
|
let _ = ach_svc::check_and_unlock(&state.pool, &state.achievement_defs, &profile.id, &club.id)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
+28
-88
@@ -1,11 +1,9 @@
|
|||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::AppState,
|
app::AppState,
|
||||||
error::{AppError, AppResult},
|
error::AppResult,
|
||||||
extractors::GameId,
|
|
||||||
models::{club::Club, profile::CreateProfileRequest},
|
models::{club::Club, profile::CreateProfileRequest},
|
||||||
seed,
|
seed,
|
||||||
services::{club as club_svc, profile as profile_svc},
|
services::{club as club_svc, profile as profile_svc},
|
||||||
@@ -13,12 +11,11 @@ use crate::{
|
|||||||
|
|
||||||
pub async fn post_auth_local(
|
pub async fn post_auth_local(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<CreateProfileRequest>,
|
Json(req): Json<CreateProfileRequest>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let username = req.username.unwrap_or_else(|| "Player 1".into());
|
let username = req.username.unwrap_or_else(|| "Player 1".into());
|
||||||
|
|
||||||
let profile = profile_svc::create_profile(&state.pool, &username, game.as_str()).await?;
|
let profile = profile_svc::create_profile(&state.pool, &username).await?;
|
||||||
|
|
||||||
let club = Club::new(&profile.id, "OpenFUT FC", 5000);
|
let club = Club::new(&profile.id, "OpenFUT FC", 5000);
|
||||||
club_svc::create_club(&state.pool, &club).await?;
|
club_svc::create_club(&state.pool, &club).await?;
|
||||||
@@ -34,108 +31,51 @@ 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(
|
pub async fn get_auth_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
State(state): State<AppState>,
|
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles")
|
||||||
game: GameId,
|
|
||||||
) -> AppResult<Json<Value>> {
|
|
||||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles WHERE game_id = ?")
|
|
||||||
.bind(game.as_str())
|
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(json!({ "has_profile": count > 0 })))
|
Ok(Json(json!({ "has_profile": count > 0 })))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct ResetRequest {
|
|
||||||
pub confirm: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /auth/reset — wipe all game data and start fresh.
|
/// POST /auth/reset — wipe all game data and start fresh.
|
||||||
/// Requires `{"confirm":"reset"}` in the request body as a safeguard against
|
/// Deletes every user-data table in dependency order. The schema tables
|
||||||
/// accidental or unauthenticated calls. Deletes every user-data table in
|
/// (migrations) are left intact; calling POST /auth/local afterwards
|
||||||
/// dependency order; schema (migrations) is preserved.
|
/// creates a new profile.
|
||||||
pub async fn post_auth_reset(
|
pub async fn post_auth_reset(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
State(state): State<AppState>,
|
// Delete in reverse-dependency order to satisfy FK constraints
|
||||||
game: GameId,
|
// (SQLite FK enforcement is opt-in, but we follow the order anyway)
|
||||||
Json(req): Json<ResetRequest>,
|
let tables = [
|
||||||
) -> AppResult<Json<Value>> {
|
"player_achievements",
|
||||||
if req.confirm.as_deref() != Some("reset") {
|
"notifications",
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
r#"include {"confirm":"reset"} in the request body to confirm data wipe"#.into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multi-game: reset ONLY this game's profile subtree so a FIFA 17 reset never
|
|
||||||
// wipes FIFA 23 (and vice versa). Resolve the game's profile + its clubs, then
|
|
||||||
// delete their dependent rows in reverse-dependency order.
|
|
||||||
let profile_id: Option<String> = sqlx::query_scalar(
|
|
||||||
"SELECT id FROM profiles WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
|
|
||||||
)
|
|
||||||
.bind(game.as_str())
|
|
||||||
.fetch_optional(&state.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let Some(profile_id) = profile_id else {
|
|
||||||
return Ok(Json(json!({
|
|
||||||
"reset": true,
|
|
||||||
"message": "nothing to reset for this game"
|
|
||||||
})));
|
|
||||||
};
|
|
||||||
|
|
||||||
let club_ids: Vec<String> = sqlx::query_scalar("SELECT id FROM clubs WHERE profile_id = ?")
|
|
||||||
.bind(&profile_id)
|
|
||||||
.fetch_all(&state.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
for club_id in &club_ids {
|
|
||||||
sqlx::query(
|
|
||||||
"DELETE FROM squad_players WHERE squad_id IN (SELECT id FROM squads WHERE club_id = ?)",
|
|
||||||
)
|
|
||||||
.bind(club_id)
|
|
||||||
.execute(&state.pool)
|
|
||||||
.await?;
|
|
||||||
for table in ["squads", "owned_cards", "packs", "market_history"] {
|
|
||||||
sqlx::query(&format!("DELETE FROM {table} WHERE club_id = ?"))
|
|
||||||
.bind(club_id)
|
|
||||||
.execute(&state.pool)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for table in [
|
|
||||||
"fut_champs_sessions",
|
"fut_champs_sessions",
|
||||||
"sbc_submissions",
|
"sbc_submissions",
|
||||||
"objective_progress",
|
"objective_progress",
|
||||||
"position_goals",
|
"position_goals",
|
||||||
"statistics",
|
"statistics",
|
||||||
"seasons",
|
"seasons",
|
||||||
"season_history",
|
|
||||||
"matches",
|
"matches",
|
||||||
|
"market_listings",
|
||||||
|
"squad_players",
|
||||||
|
"squads",
|
||||||
|
"owned_cards",
|
||||||
|
"packs",
|
||||||
|
"events",
|
||||||
"draft_sessions",
|
"draft_sessions",
|
||||||
"daily_checkins",
|
"settings",
|
||||||
] {
|
"clubs",
|
||||||
sqlx::query(&format!("DELETE FROM {table} WHERE profile_id = ?"))
|
"profiles",
|
||||||
.bind(&profile_id)
|
];
|
||||||
|
|
||||||
|
for table in &tables {
|
||||||
|
sqlx::query(&format!("DELETE FROM {table}"))
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query("DELETE FROM clubs WHERE profile_id = ?")
|
tracing::info!("Full game reset performed");
|
||||||
.bind(&profile_id)
|
|
||||||
.execute(&state.pool)
|
|
||||||
.await?;
|
|
||||||
sqlx::query("DELETE FROM profiles WHERE id = ?")
|
|
||||||
.bind(&profile_id)
|
|
||||||
.execute(&state.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// NOTE (follow-up): settings (global key/value), events (shared content),
|
|
||||||
// market_listings (keyed by seller_name, includes the shared NPC market),
|
|
||||||
// notifications and player_achievements are not yet game-scoped and are left
|
|
||||||
// intact. They need a game/profile key before a per-game reset can cover them.
|
|
||||||
tracing::info!(game = %game.as_str(), "Per-game reset performed");
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"reset": true,
|
"reset": true,
|
||||||
"message": "All progress for this game has been wiped. Call POST /auth/local to start a new club."
|
"message": "All progress has been wiped. Call POST /auth/local to start a new club."
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-12
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -11,7 +10,7 @@ use crate::{
|
|||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::card::OwnedCard,
|
models::card::OwnedCard,
|
||||||
services::{
|
services::{
|
||||||
club as club_svc, economy as economy_svc,
|
club as club_svc,
|
||||||
inventory::{self, OwnedItemQuery, OwnedItemView},
|
inventory::{self, OwnedItemQuery, OwnedItemView},
|
||||||
profile as profile_svc,
|
profile as profile_svc,
|
||||||
},
|
},
|
||||||
@@ -67,7 +66,7 @@ pub async fn get_cards(
|
|||||||
let rarity_ok = query
|
let rarity_ok = query
|
||||||
.rarity
|
.rarity
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|r| c.rarity.as_str().eq_ignore_ascii_case(r))
|
.map(|r| format!("{:?}", c.rarity).to_lowercase() == r.to_lowercase())
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
let pos_ok = query
|
let pos_ok = query
|
||||||
.position
|
.position
|
||||||
@@ -108,10 +107,9 @@ pub async fn get_cards(
|
|||||||
|
|
||||||
pub async fn get_collection(
|
pub async fn get_collection(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Query(query): Query<OwnedItemQuery>,
|
Query(query): Query<OwnedItemQuery>,
|
||||||
) -> 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).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>(
|
||||||
@@ -167,10 +165,9 @@ pub async fn get_collection(
|
|||||||
/// Quick-sell an owned card for instant coins. The card is removed from the collection.
|
/// Quick-sell an owned card for instant coins. The card is removed from the collection.
|
||||||
pub async fn delete_owned_card(
|
pub async fn delete_owned_card(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(owned_card_id): Path<String>,
|
Path(owned_card_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).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>(
|
||||||
@@ -190,11 +187,12 @@ pub async fn delete_owned_card(
|
|||||||
|
|
||||||
let coins = quick_sell_coins(card.overall);
|
let coins = quick_sell_coins(card.overall);
|
||||||
|
|
||||||
// Delegate to the economy authority rather than hand-rolling DELETE + add_coins:
|
sqlx::query("DELETE FROM owned_cards WHERE id = ?")
|
||||||
// that pair ran on the pool with NO transaction (a failed credit left the card
|
.bind(&owned_card_id)
|
||||||
// destroyed for nothing) and it skipped `squad_players`, whose FK onto
|
.execute(&state.pool)
|
||||||
// `owned_cards(id)` made quick-selling a squadded card fail with SQLite 787.
|
.await?;
|
||||||
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,
|
||||||
|
|||||||
+9
-53
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::AppState,
|
app::AppState,
|
||||||
error::AppResult,
|
error::AppResult,
|
||||||
@@ -11,8 +10,8 @@ use axum::{extract::State, Json};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
pub async fn get_club(State(state): State<AppState>, game: GameId) -> AppResult<Json<Club>> {
|
pub async fn get_club(State(state): State<AppState>) -> 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).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?;
|
||||||
Ok(Json(club))
|
Ok(Json(club))
|
||||||
}
|
}
|
||||||
@@ -25,10 +24,9 @@ pub struct UpdateClubRequest {
|
|||||||
|
|
||||||
pub async fn put_club(
|
pub async fn put_club(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<UpdateClubRequest>,
|
Json(req): Json<UpdateClubRequest>,
|
||||||
) -> 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).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 = club_svc::update_club(
|
let updated = club_svc::update_club(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
@@ -40,11 +38,8 @@ pub async fn put_club(
|
|||||||
Ok(Json(json!({ "club": updated })))
|
Ok(Json(json!({ "club": updated })))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_checkin_status(
|
pub async fn get_checkin_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
State(state): State<AppState>,
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
game: GameId,
|
|
||||||
) -> AppResult<Json<Value>> {
|
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
|
||||||
let status = checkin_svc::get_status(&state.pool, &profile.id).await?;
|
let status = checkin_svc::get_status(&state.pool, &profile.id).await?;
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"available": status.available,
|
"available": status.available,
|
||||||
@@ -55,8 +50,8 @@ pub async fn get_checkin_status(
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn post_checkin(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn post_checkin(State(state): State<AppState>) -> 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).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 r = checkin_svc::claim(&state.pool, &profile.id, &club.id).await?;
|
let r = checkin_svc::claim(&state.pool, &profile.id, &club.id).await?;
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -67,8 +62,8 @@ pub async fn post_checkin(State(state): State<AppState>, game: GameId) -> AppRes
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_milestones(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_milestones(State(state): State<AppState>) -> 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).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 stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
|
let stats = stats_svc::get_or_create(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
@@ -124,42 +119,3 @@ 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 })))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct SetManagerRequest {
|
|
||||||
/// The owned card to assign as manager, or `null`/absent to clear it.
|
|
||||||
pub owned_card_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Assign (or, with a null/absent `owned_card_id`, clear) the active squad's
|
|
||||||
/// manager. Fail-closed: the card must be owned by this club and the club must
|
|
||||||
/// have a squad. Returns the resulting assignment.
|
|
||||||
pub async fn put_squad_manager(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<SetManagerRequest>,
|
|
||||||
) -> AppResult<Json<Value>> {
|
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
|
||||||
match req.owned_card_id {
|
|
||||||
Some(owned_card_id) => {
|
|
||||||
club_svc::set_squad_manager(&state.pool, &club.id, &owned_card_id).await?
|
|
||||||
}
|
|
||||||
None => club_svc::clear_squad_manager(&state.pool, &club.id).await?,
|
|
||||||
}
|
|
||||||
let manager = club_svc::get_squad_manager(&state.pool, &club.id).await?;
|
|
||||||
Ok(Json(json!({ "manager": manager })))
|
|
||||||
}
|
|
||||||
|
|||||||
+6
-13
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
use rand::{Rng, SeedableRng};
|
use rand::{Rng, SeedableRng};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@@ -10,8 +9,8 @@ use crate::{
|
|||||||
services::{club as club_svc, profile as profile_svc, season as season_svc},
|
services::{club as club_svc, profile as profile_svc, season as season_svc},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn get_division(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_division(State(state): State<AppState>) -> 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).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?;
|
||||||
|
|
||||||
@@ -37,20 +36,14 @@ pub async fn get_division(State(state): State<AppState>, game: GameId) -> AppRes
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_division_history(
|
pub async fn get_division_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
State(state): State<AppState>,
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
game: GameId,
|
|
||||||
) -> AppResult<Json<Value>> {
|
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
|
||||||
let history = season_svc::get_history(&state.pool, &profile.id).await?;
|
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(
|
pub async fn get_division_leaderboard(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
State(state): State<AppState>,
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
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 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?;
|
||||||
|
|
||||||
|
|||||||
+4
-9
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -40,10 +39,9 @@ pub async fn get_draft_squad(
|
|||||||
/// until all 11 slots are filled.
|
/// until all 11 slots are filled.
|
||||||
pub async fn post_draft_start(
|
pub async fn post_draft_start(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Query(query): Query<DraftQuery>,
|
Query(query): Query<DraftQuery>,
|
||||||
) -> 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).await?;
|
||||||
let difficulty = query.difficulty.as_deref().unwrap_or("professional");
|
let difficulty = query.difficulty.as_deref().unwrap_or("professional");
|
||||||
let session =
|
let session =
|
||||||
draft_svc::start_draft(&state.pool, &state.card_db, &profile.id, difficulty).await?;
|
draft_svc::start_draft(&state.pool, &state.card_db, &profile.id, difficulty).await?;
|
||||||
@@ -53,10 +51,9 @@ pub async fn post_draft_start(
|
|||||||
/// Get the current state of a draft session.
|
/// Get the current state of a draft session.
|
||||||
pub async fn get_draft_session(
|
pub async fn get_draft_session(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
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).await?;
|
||||||
let session =
|
let session =
|
||||||
draft_svc::get_draft(&state.pool, &state.card_db, &profile.id, &session_id).await?;
|
draft_svc::get_draft(&state.pool, &state.card_db, &profile.id, &session_id).await?;
|
||||||
Ok(Json(session))
|
Ok(Json(session))
|
||||||
@@ -74,11 +71,10 @@ pub struct PickRequest {
|
|||||||
/// and rewards (coins + optional pack) are granted automatically.
|
/// and rewards (coins + optional pack) are granted automatically.
|
||||||
pub async fn post_draft_pick(
|
pub async fn post_draft_pick(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(session_id): Path<String>,
|
Path(session_id): Path<String>,
|
||||||
Json(req): Json<PickRequest>,
|
Json(req): Json<PickRequest>,
|
||||||
) -> 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).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 session = draft_svc::pick_card(
|
let session = draft_svc::pick_card(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
@@ -95,10 +91,9 @@ pub async fn post_draft_pick(
|
|||||||
/// Abandon an active draft session. No rewards are granted.
|
/// Abandon an active draft session. No rewards are granted.
|
||||||
pub async fn post_draft_abandon(
|
pub async fn post_draft_abandon(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
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).await?;
|
||||||
let result = draft_svc::abandon_draft(&state.pool, &profile.id, &session_id).await?;
|
let result = draft_svc::abandon_draft(&state.pool, &profile.id, &session_id).await?;
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
//! Generic economy HTTP boundary.
|
|
||||||
//!
|
|
||||||
//! Exposes [`crate::services::economy`] over the same game-scoped active-profile
|
|
||||||
//! resolution every other Core route uses ([`GameId`] header → active profile →
|
|
||||||
//! club). The caller (a game host) never supplies a club id; Core maps the game
|
|
||||||
//! to its authoritative club, so there is no cross-club economy access. Every
|
|
||||||
//! op is a single durable SQLite transaction in the service layer.
|
|
||||||
//!
|
|
||||||
//! This surface is deliberately game-neutral: no currency names, pack ids, or
|
|
||||||
//! wire semantics — those live in the game host/adapter.
|
|
||||||
|
|
||||||
use axum::{extract::State, Json};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
app::AppState,
|
|
||||||
error::AppResult,
|
|
||||||
extractors::GameId,
|
|
||||||
services::{club as club_svc, economy, economy::GrantedItem, profile as profile_svc},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Resolve the game-scoped active profile's club id.
|
|
||||||
async fn resolve_club(state: &AppState, game: &GameId) -> AppResult<String> {
|
|
||||||
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?;
|
|
||||||
Ok(club.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct BalanceResponse {
|
|
||||||
pub balance: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `GET /economy/balance` — the club's currency balance.
|
|
||||||
pub async fn get_balance(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
game: GameId,
|
|
||||||
) -> AppResult<Json<BalanceResponse>> {
|
|
||||||
let club = resolve_club(&state, &game).await?;
|
|
||||||
let balance = economy::balance(&state.pool, &club).await?;
|
|
||||||
Ok(Json(BalanceResponse { balance }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `GET /economy/entitlements` — the club's unconsumed entitlements.
|
|
||||||
pub async fn get_entitlements(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
game: GameId,
|
|
||||||
) -> AppResult<Json<Vec<economy::Entitlement>>> {
|
|
||||||
let club = resolve_club(&state, &game).await?;
|
|
||||||
Ok(Json(
|
|
||||||
economy::list_unopened_entitlements(&state.pool, &club).await?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct PurchaseEntitlementRequest {
|
|
||||||
pub cost: i64,
|
|
||||||
pub definition_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `POST /economy/purchase-entitlement` — atomic debit + grant.
|
|
||||||
pub async fn post_purchase_entitlement(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<PurchaseEntitlementRequest>,
|
|
||||||
) -> AppResult<Json<economy::PurchaseReceipt>> {
|
|
||||||
let club = resolve_club(&state, &game).await?;
|
|
||||||
Ok(Json(
|
|
||||||
economy::purchase_entitlement(&state.pool, &club, req.cost, &req.definition_id).await?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct RedeemEntitlementRequest {
|
|
||||||
pub entitlement_id: String,
|
|
||||||
pub items: Vec<GrantedItem>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct RedeemEntitlementResponse {
|
|
||||||
pub definition_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `POST /economy/redeem-entitlement` — atomic consume-once + add items.
|
|
||||||
pub async fn post_redeem_entitlement(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<RedeemEntitlementRequest>,
|
|
||||||
) -> AppResult<Json<RedeemEntitlementResponse>> {
|
|
||||||
let club = resolve_club(&state, &game).await?;
|
|
||||||
let definition_id =
|
|
||||||
economy::redeem_entitlement(&state.pool, &club, &req.entitlement_id, &req.items).await?;
|
|
||||||
Ok(Json(RedeemEntitlementResponse { definition_id }))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct SellItemRequest {
|
|
||||||
pub item_id: String,
|
|
||||||
pub price: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `POST /economy/sell-item` — atomic remove + credit.
|
|
||||||
pub async fn post_sell_item(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<SellItemRequest>,
|
|
||||||
) -> AppResult<Json<BalanceResponse>> {
|
|
||||||
let club = resolve_club(&state, &game).await?;
|
|
||||||
let balance = economy::sell_item(&state.pool, &club, &req.item_id, req.price).await?;
|
|
||||||
Ok(Json(BalanceResponse { balance }))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct GrantRewardRequest {
|
|
||||||
pub amount: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `POST /economy/grant-reward` — atomic credit.
|
|
||||||
pub async fn post_grant_reward(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<GrantRewardRequest>,
|
|
||||||
) -> AppResult<Json<BalanceResponse>> {
|
|
||||||
let club = resolve_club(&state, &game).await?;
|
|
||||||
let balance = economy::grant_reward(&state.pool, &club, req.amount).await?;
|
|
||||||
Ok(Json(BalanceResponse { balance }))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct PurchaseItemRequest {
|
|
||||||
pub cost: i64,
|
|
||||||
pub item_id: String,
|
|
||||||
pub card_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `POST /economy/purchase-item` — atomic debit + mint item.
|
|
||||||
pub async fn post_purchase_item(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<PurchaseItemRequest>,
|
|
||||||
) -> AppResult<Json<BalanceResponse>> {
|
|
||||||
let club = resolve_club(&state, &game).await?;
|
|
||||||
let balance =
|
|
||||||
economy::purchase_item(&state.pool, &club, req.cost, &req.item_id, &req.card_id).await?;
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
+10
-22
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -15,8 +14,8 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// GET /fut-champs — current active session, or null if none.
|
/// GET /fut-champs — current active session, or null if none.
|
||||||
pub async fn get_fut_champs(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_fut_champs(State(state): State<AppState>) -> 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).await?;
|
||||||
let session = champs_svc::get_active_session(&state.pool, &profile.id).await?;
|
let session = champs_svc::get_active_session(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -26,11 +25,8 @@ 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(
|
pub async fn post_start_fut_champs(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
State(state): State<AppState>,
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
game: GameId,
|
|
||||||
) -> AppResult<Json<Value>> {
|
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
|
||||||
let session = champs_svc::start_session(&state.pool, &profile.id).await?;
|
let session = champs_svc::start_session(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -48,11 +44,10 @@ pub struct ChampsMatchRequest {
|
|||||||
/// POST /fut-champs/:session_id/result — record a match in this session.
|
/// POST /fut-champs/:session_id/result — record a match in this session.
|
||||||
pub async fn post_champs_result(
|
pub async fn post_champs_result(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(session_id): Path<String>,
|
Path(session_id): Path<String>,
|
||||||
Json(req): Json<ChampsMatchRequest>,
|
Json(req): Json<ChampsMatchRequest>,
|
||||||
) -> 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).await?;
|
||||||
|
|
||||||
let session = champs_svc::record_match(
|
let session = champs_svc::record_match(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
@@ -82,10 +77,9 @@ pub async fn post_champs_result(
|
|||||||
/// POST /fut-champs/:session_id/claim — claim end-of-week rewards.
|
/// POST /fut-champs/:session_id/claim — claim end-of-week rewards.
|
||||||
pub async fn post_claim_champs_rewards(
|
pub async fn post_claim_champs_rewards(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
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).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 = champs_svc::claim_rewards(
|
let result = champs_svc::claim_rewards(
|
||||||
@@ -101,11 +95,8 @@ 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(
|
pub async fn get_champs_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
State(state): State<AppState>,
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
game: GameId,
|
|
||||||
) -> AppResult<Json<Value>> {
|
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
|
||||||
let history = champs_svc::get_history(&state.pool, &profile.id).await?;
|
let history = champs_svc::get_history(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -115,11 +106,8 @@ pub async fn get_champs_history(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 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(
|
pub async fn post_claim_rivals_reward(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
State(state): State<AppState>,
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
// Ensure a season row exists
|
// Ensure a season row exists
|
||||||
|
|||||||
+7
-17
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -13,11 +12,8 @@ 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(
|
pub async fn get_trade_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
State(state): State<AppState>,
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
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 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() })))
|
||||||
@@ -55,10 +51,9 @@ pub async fn get_market(
|
|||||||
|
|
||||||
pub async fn post_market_buy(
|
pub async fn post_market_buy(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<BuyListingRequest>,
|
Json(req): Json<BuyListingRequest>,
|
||||||
) -> 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).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 card = market_svc::buy_listing(&state.pool, &state.card_db, &club.id, &req).await?;
|
let card = market_svc::buy_listing(&state.pool, &state.card_db, &club.id, &req).await?;
|
||||||
@@ -69,10 +64,9 @@ pub async fn post_market_buy(
|
|||||||
|
|
||||||
pub async fn post_market_sell(
|
pub async fn post_market_sell(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<SellCardRequest>,
|
Json(req): Json<SellCardRequest>,
|
||||||
) -> 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).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 new_balance = market_svc::sell_card(&state.pool, &state.card_db, &club.id, &req).await?;
|
let new_balance = market_svc::sell_card(&state.pool, &state.card_db, &club.id, &req).await?;
|
||||||
@@ -88,11 +82,8 @@ 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(
|
pub async fn get_my_listings(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
State(state): State<AppState>,
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
let listings =
|
let listings =
|
||||||
market_svc::get_listings_by_seller(&state.pool, &state.card_db, &club.id).await?;
|
market_svc::get_listings_by_seller(&state.pool, &state.card_db, &club.id).await?;
|
||||||
@@ -104,10 +95,9 @@ pub async fn get_my_listings(
|
|||||||
/// Cancel a player-posted listing and return the card to the collection.
|
/// Cancel a player-posted listing and return the card to the collection.
|
||||||
pub async fn delete_market_listing(
|
pub async fn delete_market_listing(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(listing_id): Path<String>,
|
Path(listing_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).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?;
|
||||||
market_svc::cancel_listing(&state.pool, &club.id, &listing_id).await?;
|
market_svc::cancel_listing(&state.pool, &club.id, &listing_id).await?;
|
||||||
Ok(Json(json!({ "cancelled": listing_id })))
|
Ok(Json(json!({ "cancelled": listing_id })))
|
||||||
|
|||||||
+3
-33
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -9,9 +8,7 @@ use serde_json::{json, Value};
|
|||||||
use crate::{
|
use crate::{
|
||||||
app::AppState,
|
app::AppState,
|
||||||
error::AppResult,
|
error::AppResult,
|
||||||
models::match_result::{
|
models::match_result::{Match, MatchRewardResult, SubmitMatchRequest},
|
||||||
CompleteMatchRequest, Match, MatchCompletionResult, MatchRewardResult, SubmitMatchRequest,
|
|
||||||
},
|
|
||||||
services::{club as club_svc, match_service, profile as profile_svc},
|
services::{club as club_svc, match_service, profile as profile_svc},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -23,10 +20,9 @@ pub struct MatchHistoryQuery {
|
|||||||
|
|
||||||
pub async fn get_matches(
|
pub async fn get_matches(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Query(query): Query<MatchHistoryQuery>,
|
Query(query): Query<MatchHistoryQuery>,
|
||||||
) -> 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).await?;
|
||||||
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
||||||
|
|
||||||
let matches = if let Some(mode) = &query.mode {
|
let matches = if let Some(mode) = &query.mode {
|
||||||
@@ -67,10 +63,9 @@ pub async fn get_opponent(
|
|||||||
|
|
||||||
pub async fn post_match_result(
|
pub async fn post_match_result(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<SubmitMatchRequest>,
|
Json(req): Json<SubmitMatchRequest>,
|
||||||
) -> AppResult<Json<MatchRewardResult>> {
|
) -> AppResult<Json<MatchRewardResult>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).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 = match_service::process_match(
|
let result = match_service::process_match(
|
||||||
@@ -85,28 +80,3 @@ pub async fn post_match_result(
|
|||||||
|
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /matches/complete` — the authoritative, atomic, exactly-once match
|
|
||||||
/// economy entry point (the game host routes a finished match here). Idempotent
|
|
||||||
/// on `(profile, match_identity)`: a replay returns the persisted canonical
|
|
||||||
/// result with `applied = false` and grants nothing twice.
|
|
||||||
pub async fn post_match_complete(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<CompleteMatchRequest>,
|
|
||||||
) -> AppResult<Json<MatchCompletionResult>> {
|
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
|
||||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
|
||||||
|
|
||||||
let result = match_service::complete_match(
|
|
||||||
&state.pool,
|
|
||||||
&profile.id,
|
|
||||||
&club.id,
|
|
||||||
&req,
|
|
||||||
&state.obj_defs,
|
|
||||||
&state.achievement_defs,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Json(result))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ pub mod cards;
|
|||||||
pub mod club;
|
pub mod club;
|
||||||
pub mod division;
|
pub mod division;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
pub mod economy;
|
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod fut_champs;
|
pub mod fut_champs;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -19,11 +18,8 @@ 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(
|
pub async fn get_notifications(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
State(state): State<AppState>,
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
// ── Persistent notifications ─────────────────────────────────────────────
|
// ── Persistent notifications ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -12,8 +11,8 @@ use crate::{
|
|||||||
services::{club as club_svc, objective as obj_svc, profile as profile_svc},
|
services::{club as club_svc, objective as obj_svc, profile as profile_svc},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn get_objectives(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_objectives(State(state): State<AppState>) -> 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).await?;
|
||||||
let objectives =
|
let objectives =
|
||||||
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
|
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
|
||||||
Ok(Json(json!({ "objectives": objectives })))
|
Ok(Json(json!({ "objectives": objectives })))
|
||||||
@@ -21,10 +20,9 @@ pub async fn get_objectives(State(state): State<AppState>, game: GameId) -> AppR
|
|||||||
|
|
||||||
pub async fn get_objective(
|
pub async fn get_objective(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(objective_id): Path<String>,
|
Path(objective_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).await?;
|
||||||
let all =
|
let all =
|
||||||
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
|
obj_svc::get_objectives_with_progress(&state.pool, &profile.id, &state.obj_defs).await?;
|
||||||
let obj = all
|
let obj = all
|
||||||
@@ -36,10 +34,9 @@ pub async fn get_objective(
|
|||||||
|
|
||||||
pub async fn post_claim_objective_by_id(
|
pub async fn post_claim_objective_by_id(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(objective_id): Path<String>,
|
Path(objective_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).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 reward = obj_svc::claim_objective(
|
let reward = obj_svc::claim_objective(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
@@ -59,10 +56,9 @@ pub struct ClaimRequest {
|
|||||||
|
|
||||||
pub async fn post_claim_objective(
|
pub async fn post_claim_objective(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<ClaimRequest>,
|
Json(req): Json<ClaimRequest>,
|
||||||
) -> 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).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 reward = obj_svc::claim_objective(
|
let reward = obj_svc::claim_objective(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
|
|||||||
+6
-12
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -20,10 +19,9 @@ pub struct BuyPackRequest {
|
|||||||
|
|
||||||
pub async fn post_buy_pack(
|
pub async fn post_buy_pack(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<BuyPackRequest>,
|
Json(req): Json<BuyPackRequest>,
|
||||||
) -> 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).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 pack = pack_svc::buy_pack(
|
let pack = pack_svc::buy_pack(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
@@ -56,8 +54,8 @@ pub async fn get_pack_store(State(state): State<AppState>) -> AppResult<Json<Val
|
|||||||
Ok(Json(json!({ "packs": store })))
|
Ok(Json(json!({ "packs": store })))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_packs(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_packs(State(state): State<AppState>) -> 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).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 packs = pack_svc::get_unopened_packs(&state.pool, &club.id).await?;
|
let packs = pack_svc::get_unopened_packs(&state.pool, &club.id).await?;
|
||||||
|
|
||||||
@@ -79,11 +77,8 @@ 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(
|
pub async fn get_pack_history(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||||
State(state): State<AppState>,
|
let profile = profile_svc::get_active_profile(&state.pool).await?;
|
||||||
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 club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
let opened = sqlx::query_as::<_, crate::models::pack::Pack>(
|
let opened = sqlx::query_as::<_, crate::models::pack::Pack>(
|
||||||
@@ -127,10 +122,9 @@ pub async fn get_pack_history(
|
|||||||
|
|
||||||
pub async fn post_open_pack(
|
pub async fn post_open_pack(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(pack_id): Path<String>,
|
Path(pack_id): Path<String>,
|
||||||
) -> AppResult<Json<PackOpenResult>> {
|
) -> AppResult<Json<PackOpenResult>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).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 = pack_svc::open_pack(
|
let result = pack_svc::open_pack(
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::AppState,
|
app::AppState,
|
||||||
error::AppResult,
|
error::AppResult,
|
||||||
@@ -8,8 +7,8 @@ use crate::{
|
|||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
pub async fn get_profile(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_profile(State(state): State<AppState>) -> 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).await?;
|
||||||
let computed_level = level_for_xp(profile.xp);
|
let computed_level = level_for_xp(profile.xp);
|
||||||
|
|
||||||
let next_level = computed_level + 1;
|
let next_level = computed_level + 1;
|
||||||
|
|||||||
+17
-55
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -8,7 +7,7 @@ use serde_json::{json, Value};
|
|||||||
use crate::{
|
use crate::{
|
||||||
app::AppState,
|
app::AppState,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::sbc::{SaveSbcSquadRequest, SbcResult, SubmitSbcRequest},
|
models::sbc::{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,70 +27,33 @@ 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,
|
|
||||||
Json(req): Json<SubmitSbcRequest>,
|
Json(req): Json<SubmitSbcRequest>,
|
||||||
) -> AppResult<Json<SbcResult>> {
|
) -> AppResult<Json<SbcResult>> {
|
||||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
let profile = profile_svc::get_active_profile(&state.pool).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 = sbc_svc::submit_sbc(
|
let result = sbc_svc::submit_sbc(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
sbc_svc::SbcSubmissionContext {
|
&state.card_db,
|
||||||
card_db: &state.card_db,
|
&state.sbc_defs,
|
||||||
sbc_defs: &state.sbc_defs,
|
&state.obj_defs,
|
||||||
objective_defs: &state.obj_defs,
|
&profile.id,
|
||||||
achievement_defs: &state.achievement_defs,
|
&club.id,
|
||||||
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))
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-153
@@ -1,25 +1,18 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, State},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::AppState,
|
app::AppState,
|
||||||
error::{AppError, AppResult},
|
error::AppResult,
|
||||||
models::game_ext::OpaqueExtensionWrite,
|
models::squad::SaveSquadRequest,
|
||||||
models::squad::{SaveSquadRequest, SlotAssignment, SquadReplacement},
|
services::{club as club_svc, profile as profile_svc, squad as squad_svc},
|
||||||
services::{
|
|
||||||
club as club_svc, profile as profile_svc, squad as squad_svc,
|
|
||||||
squad::SquadExtState,
|
|
||||||
squad_rules::{ClientReportedEvaluation, DefaultSquadRules},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn get_squad(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_squad(State(state): State<AppState>) -> 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).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 (squad, players) = squad_svc::get_squad(&state.pool, &club.id).await?;
|
let (squad, players) = squad_svc::get_squad(&state.pool, &club.id).await?;
|
||||||
@@ -28,8 +21,8 @@ pub async fn get_squad(State(state): State<AppState>, game: GameId) -> AppResult
|
|||||||
Ok(Json(squad_response(&squad, &players, chemistry)))
|
Ok(Json(squad_response(&squad, &players, chemistry)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_squads(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_squads(State(state): State<AppState>) -> 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).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 squads = squad_svc::list_squads(&state.pool, &club.id).await?;
|
let squads = squad_svc::list_squads(&state.pool, &club.id).await?;
|
||||||
Ok(Json(json!({ "squads": squads })))
|
Ok(Json(json!({ "squads": squads })))
|
||||||
@@ -37,10 +30,9 @@ pub async fn get_squads(State(state): State<AppState>, game: GameId) -> AppResul
|
|||||||
|
|
||||||
pub async fn get_squad_by_id(
|
pub async fn get_squad_by_id(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(squad_id): Path<String>,
|
Path(squad_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).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 (squad, players) = squad_svc::get_squad_by_id(&state.pool, &club.id, &squad_id).await?;
|
let (squad, players) = squad_svc::get_squad_by_id(&state.pool, &club.id, &squad_id).await?;
|
||||||
@@ -51,14 +43,13 @@ pub async fn get_squad_by_id(
|
|||||||
|
|
||||||
pub async fn post_squad(
|
pub async fn post_squad(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<SaveSquadRequest>,
|
Json(req): Json<SaveSquadRequest>,
|
||||||
) -> 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).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?;
|
||||||
|
|
||||||
if !req.players.is_empty() {
|
if !req.players.is_empty() {
|
||||||
squad_svc::validate_formation(&state.pool, &state.card_db, &club.id, &req.players).await?;
|
squad_svc::validate_formation(&state.pool, &state.card_db, &req.players).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let squad = squad_svc::save_squad(&state.pool, &state.card_db, &club.id, &req).await?;
|
let squad = squad_svc::save_squad(&state.pool, &state.card_db, &club.id, &req).await?;
|
||||||
@@ -67,10 +58,9 @@ pub async fn post_squad(
|
|||||||
|
|
||||||
pub async fn delete_squad(
|
pub async fn delete_squad(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(squad_id): Path<String>,
|
Path(squad_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).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?;
|
||||||
squad_svc::delete_squad(&state.pool, &club.id, &squad_id).await?;
|
squad_svc::delete_squad(&state.pool, &club.id, &squad_id).await?;
|
||||||
Ok(Json(json!({ "deleted": squad_id })))
|
Ok(Json(json!({ "deleted": squad_id })))
|
||||||
@@ -104,134 +94,3 @@ fn squad_response(
|
|||||||
"chemistry": chemistry,
|
"chemistry": chemistry,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────── Game-extension-aware squad transport (host composition) ─────────
|
|
||||||
//
|
|
||||||
// These two routes expose the already-existing extension services
|
|
||||||
// (`read_squad_with_ext` / `replace_squad_with_extension`) over HTTP so a game
|
|
||||||
// host can read/write the canonical squad AND its opaque game extension in one
|
|
||||||
// Core round-trip. They add no domain logic — Core still owns validation,
|
|
||||||
// ownership, the atomic transaction, the server fingerprint, and staleness; it
|
|
||||||
// never interprets the extension payload.
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct ExtQuery {
|
|
||||||
/// Opaque adapter namespace, e.g. `"fifa17.squad"`.
|
|
||||||
pub namespace: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `GET /squad/ext?namespace=…` — the active squad, its players, and its opaque
|
|
||||||
/// extension with an explicit Fresh/Stale/Missing verdict. Never projects a
|
|
||||||
/// stale blob; the caller decides policy.
|
|
||||||
pub async fn get_squad_ext(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
game: GameId,
|
|
||||||
Query(q): Query<ExtQuery>,
|
|
||||||
) -> 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, players, state_ext) =
|
|
||||||
squad_svc::read_squad_with_ext(&state.pool, game.as_str(), &club.id, &q.namespace).await?;
|
|
||||||
|
|
||||||
let extension = match state_ext {
|
|
||||||
SquadExtState::Fresh(row) => json!({
|
|
||||||
"state": "fresh",
|
|
||||||
"schema_version": row.schema_version,
|
|
||||||
"payload": row.payload,
|
|
||||||
"stored_fingerprint": row.canonical_fingerprint,
|
|
||||||
}),
|
|
||||||
SquadExtState::Stale {
|
|
||||||
stored,
|
|
||||||
current_fingerprint,
|
|
||||||
} => json!({
|
|
||||||
"state": "stale",
|
|
||||||
"schema_version": stored.schema_version,
|
|
||||||
"payload": stored.payload,
|
|
||||||
"stored_fingerprint": stored.canonical_fingerprint,
|
|
||||||
"current_fingerprint": current_fingerprint,
|
|
||||||
}),
|
|
||||||
SquadExtState::Missing => json!({ "state": "missing" }),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"squad": squad,
|
|
||||||
"players": players,
|
|
||||||
"extension": extension,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct SlotReq {
|
|
||||||
pub owned_card_id: String,
|
|
||||||
pub slot: i64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub is_captain: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub is_on_bench: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct ReplaceReq {
|
|
||||||
#[serde(default)]
|
|
||||||
pub name: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub formation: Option<String>,
|
|
||||||
pub slots: Vec<SlotReq>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub client_reported: ClientReportedEvaluation,
|
|
||||||
pub extension: OpaqueExtensionWrite,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `PUT /squad/replace` — full-replacement of the active squad's canonical slots
|
|
||||||
/// plus its opaque game extension, in ONE Core transaction. Resolves the active
|
|
||||||
/// squad in place (creates one if none exists). Ownership, duplicate, and size
|
|
||||||
/// validation happen inside the service before any write.
|
|
||||||
pub async fn put_squad_replace(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
game: GameId,
|
|
||||||
Json(req): Json<ReplaceReq>,
|
|
||||||
) -> 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?;
|
|
||||||
|
|
||||||
// Replace the club's active squad in place; if there is none yet, create it.
|
|
||||||
let squad_id = match squad_svc::get_squad(&state.pool, &club.id).await {
|
|
||||||
Ok((s, _)) => Some(s.id),
|
|
||||||
Err(AppError::NotFound(_)) => None,
|
|
||||||
Err(e) => return Err(e),
|
|
||||||
};
|
|
||||||
|
|
||||||
let replacement = SquadReplacement {
|
|
||||||
name: req.name,
|
|
||||||
formation: req.formation,
|
|
||||||
slots: req
|
|
||||||
.slots
|
|
||||||
.into_iter()
|
|
||||||
.map(|s| SlotAssignment {
|
|
||||||
owned_card_id: s.owned_card_id,
|
|
||||||
slot: s.slot,
|
|
||||||
is_captain: s.is_captain,
|
|
||||||
is_on_bench: s.is_on_bench,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let out = squad_svc::replace_squad_with_extension(
|
|
||||||
&state.pool,
|
|
||||||
&state.card_db,
|
|
||||||
&DefaultSquadRules,
|
|
||||||
game.as_str(),
|
|
||||||
&club.id,
|
|
||||||
squad_id.as_deref(),
|
|
||||||
&replacement,
|
|
||||||
&req.client_reported,
|
|
||||||
&req.extension,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"squad_id": out.squad.id,
|
|
||||||
"canonical_fingerprint": out.canonical_fingerprint,
|
|
||||||
"slots_written": out.slots_written,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -13,8 +12,8 @@ use crate::{
|
|||||||
services::{profile as profile_svc, statistics as stats_svc},
|
services::{profile as profile_svc, statistics as stats_svc},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn get_statistics(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
pub async fn get_statistics(State(state): State<AppState>) -> 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).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 pos_goals = stats_svc::get_position_goals(&state.pool, &profile.id).await?;
|
let pos_goals = stats_svc::get_position_goals(&state.pool, &profile.id).await?;
|
||||||
|
|
||||||
@@ -37,10 +36,9 @@ pub struct HistoryQuery {
|
|||||||
|
|
||||||
pub async fn get_statistics_history(
|
pub async fn get_statistics_history(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Query(query): Query<HistoryQuery>,
|
Query(query): Query<HistoryQuery>,
|
||||||
) -> 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).await?;
|
||||||
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
||||||
|
|
||||||
let matches = sqlx::query_as::<_, Match>(
|
let matches = sqlx::query_as::<_, Match>(
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use crate::extractors::GameId;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
Json,
|
Json,
|
||||||
@@ -28,11 +27,10 @@ pub struct ApplyChemStyleRequest {
|
|||||||
/// POST /collection/:owned_card_id/chemistry-style
|
/// POST /collection/:owned_card_id/chemistry-style
|
||||||
pub async fn post_apply_chemistry_style(
|
pub async fn post_apply_chemistry_style(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(owned_card_id): Path<String>,
|
Path(owned_card_id): Path<String>,
|
||||||
Json(req): Json<ApplyChemStyleRequest>,
|
Json(req): Json<ApplyChemStyleRequest>,
|
||||||
) -> 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).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::apply_chemistry_style(
|
let updated = upgrade_svc::apply_chemistry_style(
|
||||||
@@ -65,11 +63,10 @@ pub struct ChangePositionRequest {
|
|||||||
/// POST /collection/:owned_card_id/position — costs 500 coins.
|
/// POST /collection/:owned_card_id/position — costs 500 coins.
|
||||||
pub async fn post_change_position(
|
pub async fn post_change_position(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(owned_card_id): Path<String>,
|
Path(owned_card_id): Path<String>,
|
||||||
Json(req): Json<ChangePositionRequest>,
|
Json(req): Json<ChangePositionRequest>,
|
||||||
) -> 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).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 =
|
let updated =
|
||||||
@@ -95,11 +92,10 @@ pub struct ApplyTrainingRequest {
|
|||||||
/// POST /collection/:owned_card_id/training — applies a training boost (up to +3 OVR total).
|
/// POST /collection/:owned_card_id/training — applies a training boost (up to +3 OVR total).
|
||||||
pub async fn post_apply_training(
|
pub async fn post_apply_training(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
|
||||||
Path(owned_card_id): Path<String>,
|
Path(owned_card_id): Path<String>,
|
||||||
Json(req): Json<ApplyTrainingRequest>,
|
Json(req): Json<ApplyTrainingRequest>,
|
||||||
) -> 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).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 =
|
let updated =
|
||||||
|
|||||||
+1
-186
@@ -1,23 +1,6 @@
|
|||||||
use crate::{
|
use crate::{db::Pool, error::AppResult, models::pack::PackDefinition, services::pack as pack_svc};
|
||||||
db::Pool,
|
|
||||||
error::AppResult,
|
|
||||||
models::{card::Quality, club::Club, pack::PackDefinition},
|
|
||||||
services::{card_db::CardDb, club as club_svc, pack as pack_svc, profile as profile_svc},
|
|
||||||
};
|
|
||||||
use serde::Serialize;
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
/// The game whose dev content + inventory this seeds.
|
|
||||||
pub const FIFA17_GAME: &str = "fifa17";
|
|
||||||
/// Deterministic owned-instance id prefix, so re-running the seed is idempotent
|
|
||||||
/// (INSERT OR IGNORE on a stable id) rather than minting duplicate ownership.
|
|
||||||
const DEV_OWNED_PREFIX: &str = "fdev-";
|
|
||||||
/// Fixed grant timestamp — the seed is deterministic, not wall-clock dependent.
|
|
||||||
const DEV_ACQUIRED_AT: &str = "2026-08-11T00:00:00Z";
|
|
||||||
/// The client's My Squad page size (evidence: request `count=11`).
|
|
||||||
const MY_SQUAD_PAGE: usize = 11;
|
|
||||||
|
|
||||||
/// Seeds the market with NPC listings if empty.
|
/// Seeds the market with NPC listings if empty.
|
||||||
pub async fn maybe_seed(_pool: &Pool) -> AppResult<()> {
|
pub async fn maybe_seed(_pool: &Pool) -> AppResult<()> {
|
||||||
// Any one-time startup seeds go here.
|
// Any one-time startup seeds go here.
|
||||||
@@ -46,171 +29,3 @@ pub async fn grant_starter_pack(
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────────── FIFA 17 dev seed ─────────────────────────────
|
|
||||||
|
|
||||||
/// Coverage of the seeded FIFA 17 development inventory. Game-independent: it
|
|
||||||
/// counts quality tiers, positions and distinct entities, and whether the Gold
|
|
||||||
/// filter spans more than one page — everything the retail `/club` UI must
|
|
||||||
/// exercise. It carries NO FIFA wire ids (those are the adapter/host's runtime
|
|
||||||
/// concern; the seed never allocates them).
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct DevSeedReport {
|
|
||||||
pub game_id: String,
|
|
||||||
/// True if the fifa17 club already owned dev cards (no new grants made).
|
|
||||||
pub already_seeded: bool,
|
|
||||||
pub definitions_available: usize,
|
|
||||||
pub owned_total: usize,
|
|
||||||
pub unique_definitions: usize,
|
|
||||||
pub gold: usize,
|
|
||||||
pub silver: usize,
|
|
||||||
pub bronze: usize,
|
|
||||||
pub positions: BTreeMap<String, usize>,
|
|
||||||
pub distinct_nations: usize,
|
|
||||||
pub distinct_leagues: usize,
|
|
||||||
pub distinct_clubs: usize,
|
|
||||||
pub max_same_club: usize,
|
|
||||||
/// Gold owned items exceed one page → the client must request a 2nd page.
|
|
||||||
pub gold_over_one_page: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opt-in development seed: create (if absent) a `game_id=fifa17` profile + club
|
|
||||||
/// and grant Core-owned instances of every dev-pack `CardDefinition` (ids
|
|
||||||
/// `fifa17_*`), plus one deliberate duplicate of a single definition (to exercise
|
|
||||||
/// two-copies-of-one-card identity later).
|
|
||||||
///
|
|
||||||
/// **Ownership only — no FIFA wire ids.** The FIFA 17 integer item id is minted
|
|
||||||
/// lazily by `Fifa17IdentityResolver` at request time, never here. This keeps the
|
|
||||||
/// boundary clean: Core owns "this profile owns this card"; the adapter owns
|
|
||||||
/// "this owned item is wire id N".
|
|
||||||
///
|
|
||||||
/// Idempotent: owned ids are deterministic (`fdev-<card_id>`), inserted with
|
|
||||||
/// `INSERT OR IGNORE`, so re-running grants nothing new. The default profile
|
|
||||||
/// (`fifa23`/no-header) and any existing synthetic inventory are never touched.
|
|
||||||
pub async fn seed_fifa17_dev(pool: &Pool, card_db: &CardDb) -> AppResult<DevSeedReport> {
|
|
||||||
// The dev definitions are exactly the game-namespaced ids in the catalog.
|
|
||||||
let mut defs: Vec<&crate::models::card::CardDefinition> = card_db
|
|
||||||
.cards
|
|
||||||
.values()
|
|
||||||
.filter(|c| c.id.starts_with("fifa17_"))
|
|
||||||
.collect();
|
|
||||||
defs.sort_by(|a, b| a.id.cmp(&b.id));
|
|
||||||
|
|
||||||
// Ensure the fifa17-scoped profile + club exist (single-profile-per-game).
|
|
||||||
let profile = match profile_svc::get_active_profile(pool, FIFA17_GAME).await {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(_) => profile_svc::create_profile(pool, "OpenFUT Dev (FIFA17)", FIFA17_GAME).await?,
|
|
||||||
};
|
|
||||||
let club = match club_svc::get_club_by_profile(pool, &profile.id).await {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(_) => {
|
|
||||||
let c = Club::new(&profile.id, "OpenFUT Dev FC", 100_000);
|
|
||||||
club_svc::create_club(pool, &c).await?;
|
|
||||||
c
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let prior: i64 = sqlx::query_scalar(
|
|
||||||
"SELECT COUNT(*) FROM owned_cards WHERE club_id = ? AND card_id LIKE 'fifa17_%'",
|
|
||||||
)
|
|
||||||
.bind(&club.id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
let already_seeded = prior > 0;
|
|
||||||
|
|
||||||
// Grant one instance per definition; INSERT OR IGNORE keeps reruns idempotent.
|
|
||||||
for def in &defs {
|
|
||||||
grant_owned(
|
|
||||||
pool,
|
|
||||||
&format!("{DEV_OWNED_PREFIX}{}", def.id),
|
|
||||||
&club.id,
|
|
||||||
&def.id,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
// One deliberate duplicate of the first (lexicographic) definition → two
|
|
||||||
// owned copies of one card sharing a definition but distinct owned ids.
|
|
||||||
if let Some(first) = defs.first() {
|
|
||||||
grant_owned(
|
|
||||||
pool,
|
|
||||||
&format!("{DEV_OWNED_PREFIX}{}-b", first.id),
|
|
||||||
&club.id,
|
|
||||||
&first.id,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let report = dev_coverage(pool, card_db, &club.id, already_seeded, defs.len()).await?;
|
|
||||||
info!(
|
|
||||||
"seeded fifa17 dev inventory: {} owned ({} gold) over club {}",
|
|
||||||
report.owned_total, report.gold, club.id
|
|
||||||
);
|
|
||||||
Ok(report)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn grant_owned(pool: &Pool, owned_id: &str, club_id: &str, card_id: &str) -> AppResult<()> {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT OR IGNORE INTO owned_cards \
|
|
||||||
(id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \
|
|
||||||
VALUES (?, ?, ?, 0, NULL, ?)",
|
|
||||||
)
|
|
||||||
.bind(owned_id)
|
|
||||||
.bind(club_id)
|
|
||||||
.bind(card_id)
|
|
||||||
.bind(DEV_ACQUIRED_AT)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the coverage report from the club's owned dev cards joined to `card_db`.
|
|
||||||
async fn dev_coverage(
|
|
||||||
pool: &Pool,
|
|
||||||
card_db: &CardDb,
|
|
||||||
club_id: &str,
|
|
||||||
already_seeded: bool,
|
|
||||||
definitions_available: usize,
|
|
||||||
) -> AppResult<DevSeedReport> {
|
|
||||||
let card_ids: Vec<String> = sqlx::query_scalar(
|
|
||||||
"SELECT card_id FROM owned_cards WHERE club_id = ? AND card_id LIKE 'fifa17_%'",
|
|
||||||
)
|
|
||||||
.bind(club_id)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
let (mut gold, mut silver, mut bronze) = (0usize, 0usize, 0usize);
|
|
||||||
let mut positions: BTreeMap<String, usize> = BTreeMap::new();
|
|
||||||
let mut nations = std::collections::BTreeSet::new();
|
|
||||||
let mut leagues = std::collections::BTreeSet::new();
|
|
||||||
let mut club_counts: BTreeMap<String, usize> = BTreeMap::new();
|
|
||||||
let mut unique = std::collections::BTreeSet::new();
|
|
||||||
for card_id in &card_ids {
|
|
||||||
unique.insert(card_id.clone());
|
|
||||||
if let Some(def) = card_db.get(card_id) {
|
|
||||||
match Quality::from_overall(def.overall) {
|
|
||||||
Quality::Gold => gold += 1,
|
|
||||||
Quality::Silver => silver += 1,
|
|
||||||
Quality::Bronze => bronze += 1,
|
|
||||||
}
|
|
||||||
*positions.entry(def.position.clone()).or_default() += 1;
|
|
||||||
nations.insert(def.nation.clone());
|
|
||||||
leagues.insert(def.league.clone());
|
|
||||||
*club_counts.entry(def.club.clone()).or_default() += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(DevSeedReport {
|
|
||||||
game_id: FIFA17_GAME.to_string(),
|
|
||||||
already_seeded,
|
|
||||||
definitions_available,
|
|
||||||
owned_total: card_ids.len(),
|
|
||||||
unique_definitions: unique.len(),
|
|
||||||
gold,
|
|
||||||
silver,
|
|
||||||
bronze,
|
|
||||||
positions,
|
|
||||||
distinct_nations: nations.len(),
|
|
||||||
distinct_leagues: leagues.len(),
|
|
||||||
distinct_clubs: club_counts.len(),
|
|
||||||
max_same_club: club_counts.values().copied().max().unwrap_or(0),
|
|
||||||
gold_over_one_page: gold > MY_SQUAD_PAGE,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-169
@@ -1,12 +1,10 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
db::Pool,
|
db::Pool,
|
||||||
error::{AppError, AppResult},
|
error::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;
|
||||||
|
|
||||||
@@ -187,172 +185,6 @@ pub async fn check_and_unlock(
|
|||||||
Ok(newly_unlocked)
|
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(newly_unlocked)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return all achievement definitions annotated with unlock status.
|
/// Return all achievement definitions annotated with unlock status.
|
||||||
pub async fn list_with_status(
|
pub async fn list_with_status(
|
||||||
pool: &Pool,
|
pool: &Pool,
|
||||||
|
|||||||
+11
-41
@@ -38,51 +38,21 @@ impl CardDb {
|
|||||||
Ok(Self { cards })
|
Ok(Self { cards })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merge a game's **opt-in development content pack** from
|
|
||||||
/// `{data_dir}/games/{game}/dev/cards.json` (a single `CardDefinition[]`).
|
|
||||||
/// This is NOT read by [`CardDb::load`]; it is loaded only when a game is
|
|
||||||
/// explicitly named in `Config::dev_content_games`, so default content stays
|
|
||||||
/// untouched. Returns the number of definitions merged. A missing file is an
|
|
||||||
/// error (opt-in means the pack is expected to exist).
|
|
||||||
pub fn load_game_dev(&mut self, data_dir: &str, game: &str) -> Result<usize> {
|
|
||||||
let path = Path::new(data_dir)
|
|
||||||
.join("games")
|
|
||||||
.join(game)
|
|
||||||
.join("dev")
|
|
||||||
.join("cards.json");
|
|
||||||
let content = std::fs::read_to_string(&path)
|
|
||||||
.with_context(|| format!("reading dev content pack {path:?}"))?;
|
|
||||||
let batch: Vec<CardDefinition> =
|
|
||||||
serde_json::from_str(&content).with_context(|| format!("parsing {path:?}"))?;
|
|
||||||
let n = batch.len();
|
|
||||||
for card in batch {
|
|
||||||
self.cards.insert(card.id.clone(), card);
|
|
||||||
}
|
|
||||||
tracing::info!("Loaded {} dev card definitions for game '{}'", n, game);
|
|
||||||
Ok(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Merge an explicit PRODUCTION content pack file (a single
|
|
||||||
/// `CardDefinition[]`). Unlike [`CardDb::load_game_dev`] this takes a direct
|
|
||||||
/// path (the real-profile import emits one) and is the production content
|
|
||||||
/// path — not gated behind dev content. Returns the number merged.
|
|
||||||
pub fn load_pack(&mut self, path: &Path) -> Result<usize> {
|
|
||||||
let content = std::fs::read_to_string(path)
|
|
||||||
.with_context(|| format!("reading content pack {path:?}"))?;
|
|
||||||
let batch: Vec<CardDefinition> =
|
|
||||||
serde_json::from_str(&content).with_context(|| format!("parsing {path:?}"))?;
|
|
||||||
let n = batch.len();
|
|
||||||
for card in batch {
|
|
||||||
self.cards.insert(card.id.clone(), card);
|
|
||||||
}
|
|
||||||
tracing::info!("Loaded {} production card definitions from {:?}", n, path);
|
|
||||||
Ok(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(&self, id: &str) -> Option<&CardDefinition> {
|
pub fn get(&self, id: &str) -> Option<&CardDefinition> {
|
||||||
self.cards.get(id)
|
self.cards.get(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn by_rarity(&self, rarity: &str) -> Vec<&CardDefinition> {
|
||||||
|
self.cards
|
||||||
|
.values()
|
||||||
|
.filter(|c| {
|
||||||
|
let r = format!("{:?}", c.rarity).to_lowercase();
|
||||||
|
r == rarity || rarity == "any"
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn all(&self) -> Vec<&CardDefinition> {
|
pub fn all(&self) -> Vec<&CardDefinition> {
|
||||||
self.cards.values().collect()
|
self.cards.values().collect()
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-26
@@ -48,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).rem_euclid(7) as usize;
|
let idx = ((next_streak - 1) % 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 },
|
||||||
@@ -86,17 +86,19 @@ pub async fn claim(pool: &Pool, profile_id: &str, club_id: &str) -> AppResult<Ch
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|(s, last_at)| compute_next_streak(*s, last_at))
|
.map(|(s, last_at)| compute_next_streak(*s, last_at))
|
||||||
.unwrap_or(1);
|
.unwrap_or(1);
|
||||||
let idx = (last_streak - 1).rem_euclid(7) as usize;
|
let idx = ((last_streak - 1) % 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 };
|
||||||
|
|
||||||
// Atomically claim today's check-in: the INSERT lands only if no row exists for
|
club::add_coins(pool, club_id, coins).await?;
|
||||||
// today, so two concurrent claims cannot both pay out (was a check-then-act race).
|
if let Some(def) = pack_def {
|
||||||
|
let _ = pack::grant_pack(pool, club_id, def).await;
|
||||||
|
}
|
||||||
|
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
let inserted = sqlx::query(
|
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) \
|
||||||
SELECT ?, ?, ?, ?, ?, ?, ? \
|
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
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)
|
||||||
@@ -105,26 +107,8 @@ pub async fn claim(pool: &Pool, profile_id: &str, club_id: &str) -> AppResult<Ch
|
|||||||
.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,
|
||||||
|
|||||||
+13
-273
@@ -1,7 +1,7 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
db::Pool,
|
db::Pool,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::{card::OwnedCard, club::Club},
|
models::club::Club,
|
||||||
};
|
};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|
||||||
@@ -86,284 +86,24 @@ 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 {
|
let balance = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
|
||||||
return Err(AppError::BadRequest(format!(
|
.bind(club_id)
|
||||||
"cannot spend a negative amount: {amount}"
|
.fetch_one(pool)
|
||||||
)));
|
.await?;
|
||||||
}
|
|
||||||
|
|
||||||
let now = Utc::now();
|
if balance < amount {
|
||||||
// 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 = ?")
|
|
||||||
.bind(club_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound(format!("club not found: {club_id}")))?;
|
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(AppError::BadRequest(format!(
|
||||||
"insufficient coins: have {balance}, need {amount}"
|
"insufficient coins: have {balance}, need {amount}"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_balance = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
|
let now = Utc::now();
|
||||||
|
sqlx::query("UPDATE clubs SET coins = coins - ?, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(amount)
|
||||||
|
.bind(now)
|
||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
.fetch_one(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(new_balance)
|
|
||||||
}
|
Ok(balance - amount)
|
||||||
|
|
||||||
// ─────────────────────── 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.
|
|
||||||
|
|
||||||
const OWNED_SELECT: &str = "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, \
|
|
||||||
acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards";
|
|
||||||
|
|
||||||
/// 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_SELECT} WHERE id = (SELECT owned_card_id FROM squad_managers WHERE squad_id = ?) \
|
|
||||||
AND club_id = ?"
|
|
||||||
))
|
|
||||||
.bind(squad_id)
|
|
||||||
.bind(club_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Assign `owned_card_id` as the manager of `club_id`'s active squad, replacing
|
|
||||||
/// any existing assignment. Fail-closed: both the squad and the owned card MUST
|
|
||||||
/// belong to `club_id`, so a client can neither manage another club's squad nor
|
|
||||||
/// assign a card it does not own. One manager per squad (the PK REPLACE), so a
|
|
||||||
/// re-assignment never accumulates duplicate rows.
|
|
||||||
pub async fn set_squad_manager(pool: &Pool, club_id: &str, owned_card_id: &str) -> AppResult<()> {
|
|
||||||
let squad_id = active_squad_id(pool, club_id)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound("club has no squad to assign a manager to".into()))?;
|
|
||||||
set_squad_manager_for_squad(pool, club_id, &squad_id, owned_card_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Squad-scoped variant of [`set_squad_manager`].
|
|
||||||
pub async fn set_squad_manager_for_squad(
|
|
||||||
pool: &Pool,
|
|
||||||
club_id: &str,
|
|
||||||
squad_id: &str,
|
|
||||||
owned_card_id: &str,
|
|
||||||
) -> AppResult<()> {
|
|
||||||
let squad_ok =
|
|
||||||
sqlx::query_scalar::<_, String>("SELECT id FROM squads WHERE id = ? AND club_id = ?")
|
|
||||||
.bind(squad_id)
|
|
||||||
.bind(club_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
if squad_ok.is_none() {
|
|
||||||
return Err(AppError::NotFound(format!("squad '{squad_id}' not found")));
|
|
||||||
}
|
|
||||||
let card_ok =
|
|
||||||
sqlx::query_scalar::<_, String>("SELECT id FROM owned_cards WHERE id = ? AND club_id = ?")
|
|
||||||
.bind(owned_card_id)
|
|
||||||
.bind(club_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
if card_ok.is_none() {
|
|
||||||
return Err(AppError::NotFound(format!(
|
|
||||||
"owned card '{owned_card_id}' not found"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let now = Utc::now().to_rfc3339();
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT OR REPLACE INTO squad_managers (squad_id, owned_card_id, updated_at) \
|
|
||||||
VALUES (?, ?, ?)",
|
|
||||||
)
|
|
||||||
.bind(squad_id)
|
|
||||||
.bind(owned_card_id)
|
|
||||||
.bind(&now)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove the manager assignment from `club_id`'s active squad (idempotent — a
|
|
||||||
/// club with no squad or no manager is a successful no-op).
|
|
||||||
pub async fn clear_squad_manager(pool: &Pool, club_id: &str) -> AppResult<()> {
|
|
||||||
sqlx::query(
|
|
||||||
"DELETE FROM squad_managers WHERE squad_id IN \
|
|
||||||
(SELECT id FROM squads WHERE club_id = ?)",
|
|
||||||
)
|
|
||||||
.bind(club_id)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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:
|
|
||||||
/// club-a owns `mgr` + `mgr2` + `player`, club-b owns `foreign`.
|
|
||||||
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) in [
|
|
||||||
("mgr", "club-a"),
|
|
||||||
("mgr2", "club-a"),
|
|
||||||
("player", "club-a"),
|
|
||||||
("foreign", "club-b"),
|
|
||||||
] {
|
|
||||||
sqlx::query("INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) VALUES (?, ?, ?, 0, ?)")
|
|
||||||
.bind(id).bind(club).bind("def-mgr").bind(TS)
|
|
||||||
.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()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-22
@@ -88,18 +88,13 @@ pub async fn start_draft(
|
|||||||
let first_position = &pick_order[0];
|
let first_position = &pick_order[0];
|
||||||
let candidates = pick_candidates(card_db, first_position, min_overall, CANDIDATES_PER_SLOT);
|
let candidates = pick_candidates(card_db, first_position, min_overall, CANDIDATES_PER_SLOT);
|
||||||
|
|
||||||
let pick_order_json = serde_json::to_string(&pick_order)
|
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
|
|
||||||
let candidates_json = serde_json::to_string(&candidates)
|
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
|
|
||||||
|
|
||||||
let session = DraftSession {
|
let session = DraftSession {
|
||||||
id: Uuid::new_v4().to_string(),
|
id: Uuid::new_v4().to_string(),
|
||||||
profile_id: profile_id.to_string(),
|
profile_id: profile_id.to_string(),
|
||||||
difficulty: difficulty.to_string(),
|
difficulty: difficulty.to_string(),
|
||||||
pick_order: pick_order_json,
|
pick_order: serde_json::to_string(&pick_order).unwrap(),
|
||||||
picks: "[]".to_string(),
|
picks: "[]".to_string(),
|
||||||
current_candidates: Some(candidates_json),
|
current_candidates: Some(serde_json::to_string(&candidates).unwrap()),
|
||||||
status: "active".to_string(),
|
status: "active".to_string(),
|
||||||
reward_coins: 0,
|
reward_coins: 0,
|
||||||
reward_pack_id: None,
|
reward_pack_id: None,
|
||||||
@@ -197,23 +192,17 @@ pub async fn pick_card(
|
|||||||
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).map_err(|e| {
|
Some(serde_json::to_string(&next_candidates).unwrap()),
|
||||||
AppError::Internal(anyhow::anyhow!("serialization failed: {e}"))
|
"active".to_string(),
|
||||||
})?;
|
0,
|
||||||
(
|
None,
|
||||||
Some(candidates_json),
|
0,
|
||||||
"active".to_string(),
|
None,
|
||||||
0,
|
)
|
||||||
None,
|
|
||||||
0,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let picks_json = serde_json::to_string(&picks)
|
let picks_json = serde_json::to_string(&picks).unwrap();
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("serialization failed: {e}")))?;
|
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE draft_sessions SET picks = ?, current_candidates = ?, status = ?, \
|
"UPDATE draft_sessions SET picks = ?, current_candidates = ?, status = ?, \
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -243,30 +243,16 @@ pub async fn claim_rivals_reward(
|
|||||||
pack_defs: &[PackDefinition],
|
pack_defs: &[PackDefinition],
|
||||||
) -> AppResult<serde_json::Value> {
|
) -> AppResult<serde_json::Value> {
|
||||||
// Fetch current season row (must exist)
|
// Fetch current season row (must exist)
|
||||||
let row: Option<(i64, i64, i64, Option<String>)> = sqlx::query_as(
|
let row: Option<(i64, i64, i64)> = sqlx::query_as(
|
||||||
"SELECT division, rivals_week_claimed, rivals_total_points, rivals_last_claimed_at \
|
"SELECT division, rivals_week_claimed, rivals_total_points FROM seasons WHERE profile_id = ?",
|
||||||
FROM seasons WHERE profile_id = ?",
|
|
||||||
)
|
)
|
||||||
.bind(profile_id)
|
.bind(profile_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let (division, week_claimed, total_pts, last_claimed_at) =
|
let (division, week_claimed, total_pts) =
|
||||||
row.ok_or_else(|| AppError::NotFound("no season found — play a match first".into()))?;
|
row.ok_or_else(|| AppError::NotFound("no season found — play a match first".into()))?;
|
||||||
|
|
||||||
// Enforce 24-hour cooldown between weekly reward claims
|
|
||||||
if let Some(ref last_claimed) = last_claimed_at {
|
|
||||||
if let Ok(last_time) = chrono::DateTime::parse_from_rfc3339(last_claimed) {
|
|
||||||
let elapsed = chrono::Utc::now() - last_time.with_timezone(&chrono::Utc);
|
|
||||||
if elapsed < chrono::Duration::hours(24) {
|
|
||||||
let hours_remaining = 24 - elapsed.num_hours();
|
|
||||||
return Err(AppError::Conflict(format!(
|
|
||||||
"rivals weekly reward already claimed; try again in ~{hours_remaining}h"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let next_week = week_claimed + 1;
|
let next_week = week_claimed + 1;
|
||||||
let coins = rivals_weekly_coins(division);
|
let coins = rivals_weekly_coins(division);
|
||||||
let new_balance = club_svc::add_coins(pool, club_id, coins).await?;
|
let new_balance = club_svc::add_coins(pool, club_id, coins).await?;
|
||||||
@@ -289,13 +275,11 @@ pub async fn claim_rivals_reward(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100, \
|
"UPDATE seasons SET rivals_week_claimed = ?, rivals_total_points = rivals_total_points + 100 \
|
||||||
rivals_last_claimed_at = ? WHERE profile_id = ?",
|
WHERE profile_id = ?",
|
||||||
)
|
)
|
||||||
.bind(next_week)
|
.bind(next_week)
|
||||||
.bind(&now)
|
|
||||||
.bind(profile_id)
|
.bind(profile_id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
//! Generic read/write for [`crate::models::game_ext`] opaque state.
|
|
||||||
//!
|
|
||||||
//! Core never interprets the payload. Writes happen INSIDE the owning entity's
|
|
||||||
//! transaction (see `squad::replace_squad_with_extension`) so the canonical
|
|
||||||
//! entity and its opaque extension commit atomically — there is deliberately no
|
|
||||||
//! standalone "write extension" entry point that could desync the two.
|
|
||||||
|
|
||||||
use crate::db::Pool;
|
|
||||||
use crate::error::AppResult;
|
|
||||||
use crate::models::game_ext::GameEntityExt;
|
|
||||||
|
|
||||||
/// Fetch the stored opaque extension for a scoped entity, or `None`. The caller
|
|
||||||
/// compares `canonical_fingerprint` against the entity's *current* fingerprint to
|
|
||||||
/// decide freshness — this layer does not know how to fingerprint any entity.
|
|
||||||
pub async fn get_ext(
|
|
||||||
pool: &Pool,
|
|
||||||
game_id: &str,
|
|
||||||
entity_kind: &str,
|
|
||||||
entity_id: &str,
|
|
||||||
namespace: &str,
|
|
||||||
) -> AppResult<Option<GameEntityExt>> {
|
|
||||||
let row = sqlx::query_as::<_, GameEntityExt>(
|
|
||||||
"SELECT game_id, entity_kind, entity_id, namespace, schema_version, \
|
|
||||||
canonical_fingerprint, payload, updated_at FROM game_entity_ext \
|
|
||||||
WHERE game_id = ? AND entity_kind = ? AND entity_id = ? AND namespace = ?",
|
|
||||||
)
|
|
||||||
.bind(game_id)
|
|
||||||
.bind(entity_kind)
|
|
||||||
.bind(entity_id)
|
|
||||||
.bind(namespace)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row)
|
|
||||||
}
|
|
||||||
@@ -1,341 +0,0 @@
|
|||||||
//! Generic, game-agnostic transactional profile import.
|
|
||||||
//!
|
|
||||||
//! Core installs a profile + club + owned cards + canonical squad + one opaque
|
|
||||||
//! game extension in a SINGLE all-or-nothing SQLite transaction, stamped with a
|
|
||||||
//! generic `source_fingerprint` provenance token. Core NEVER interprets FIFA17
|
|
||||||
//! wire ids, resourceIds, `nextItemId`, or the extension payload — the
|
|
||||||
//! `openfut-import-fifa17` adapter reads the Python profile, chooses every
|
|
||||||
//! `CardDefinitionId` and every opaque `OwnedItemId`, builds the squad
|
|
||||||
//! extension bytes, and hands Core this generic request.
|
|
||||||
//!
|
|
||||||
//! Invariants enforced here:
|
|
||||||
//! - Definition preflight: every incoming `card_id` MUST already resolve in the
|
|
||||||
//! loaded production content, so the transaction never creates ownership
|
|
||||||
//! pointing at absent content.
|
|
||||||
//! - Squad all-or-nothing: every active-squad `owned_item_id` MUST be among the
|
|
||||||
//! imported ownership set before the transaction begins.
|
|
||||||
//! - Rerun identity: identical `source_fingerprint` against an already-imported
|
|
||||||
//! game is an idempotent no-op; a differing token fails; a pre-existing
|
|
||||||
//! non-imported profile is never clobbered.
|
|
||||||
//! - The whole thing commits together or not at all.
|
|
||||||
|
|
||||||
use crate::db::Pool;
|
|
||||||
use crate::models::game_ext::{MAX_EXT_NAMESPACE_LEN, MAX_EXT_PAYLOAD_BYTES};
|
|
||||||
use crate::services::card_db::CardDb;
|
|
||||||
use crate::services::squad::squad_fingerprint;
|
|
||||||
use anyhow::{bail, Context, Result};
|
|
||||||
use chrono::Utc;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct ImportProfile {
|
|
||||||
pub username: String,
|
|
||||||
pub game_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct ImportClub {
|
|
||||||
pub name: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub coins: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct ImportOwnedCard {
|
|
||||||
/// Opaque, stable Core OwnedItemId chosen by the adapter. Core never parses
|
|
||||||
/// why it is stable — it is a primary key, nothing more.
|
|
||||||
pub owned_item_id: String,
|
|
||||||
/// CardDefinitionId that MUST resolve in loaded production content.
|
|
||||||
pub card_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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)]
|
|
||||||
pub struct ImportSlot {
|
|
||||||
pub owned_item_id: String,
|
|
||||||
pub position_index: i64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub is_captain: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub is_on_bench: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct ImportExtension {
|
|
||||||
/// Opaque adapter key, e.g. "fifa17.squad.v1".
|
|
||||||
pub namespace: String,
|
|
||||||
/// Adapter payload version (distinct from DB storage schema).
|
|
||||||
pub schema_version: i64,
|
|
||||||
/// Uninterpreted bytes-as-text. Core enforces only generic size bounds.
|
|
||||||
pub payload: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct ImportSquad {
|
|
||||||
pub formation: String,
|
|
||||||
#[serde(default = "default_squad_name")]
|
|
||||||
pub name: String,
|
|
||||||
pub slots: Vec<ImportSlot>,
|
|
||||||
pub extension: ImportExtension,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_squad_name() -> String {
|
|
||||||
"My Squad".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct ProfileImportRequest {
|
|
||||||
/// Generic provenance/rerun-identity token. Core stores it verbatim.
|
|
||||||
pub source_fingerprint: String,
|
|
||||||
pub profile: ImportProfile,
|
|
||||||
pub club: ImportClub,
|
|
||||||
pub owned: Vec<ImportOwnedCard>,
|
|
||||||
#[serde(default)]
|
|
||||||
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)]
|
|
||||||
#[serde(tag = "outcome", rename_all = "snake_case")]
|
|
||||||
pub enum ImportOutcome {
|
|
||||||
/// A fresh import committed.
|
|
||||||
Imported { owned: usize, squad_slots: usize },
|
|
||||||
/// The same fingerprint was already imported for this game — no-op.
|
|
||||||
AlreadyImported,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply a generic transactional profile import. See module docs for invariants.
|
|
||||||
pub async fn apply_profile_import(
|
|
||||||
pool: &Pool,
|
|
||||||
card_db: &CardDb,
|
|
||||||
req: &ProfileImportRequest,
|
|
||||||
) -> Result<ImportOutcome> {
|
|
||||||
// ── 0. generic input validation (no writes) ──
|
|
||||||
if req.source_fingerprint.trim().is_empty() {
|
|
||||||
bail!("source_fingerprint must be non-empty");
|
|
||||||
}
|
|
||||||
if req.owned.is_empty() {
|
|
||||||
bail!("import request has zero owned cards; refusing to import an empty profile");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 1. rerun identity / single-profile-per-game ──
|
|
||||||
let existing: Option<(String, Option<String>)> = sqlx::query_as(
|
|
||||||
"SELECT id, import_fingerprint FROM profiles \
|
|
||||||
WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
|
|
||||||
)
|
|
||||||
.bind(&req.profile.game_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
if let Some((_id, fp)) = existing {
|
|
||||||
match fp {
|
|
||||||
Some(fp) if fp == req.source_fingerprint => return Ok(ImportOutcome::AlreadyImported),
|
|
||||||
Some(fp) => bail!(
|
|
||||||
"game '{}' already imported from a different source (stored fingerprint {fp}, \
|
|
||||||
incoming {}); refusing to overwrite without an explicit update mode",
|
|
||||||
req.profile.game_id,
|
|
||||||
req.source_fingerprint
|
|
||||||
),
|
|
||||||
None => bail!(
|
|
||||||
"game '{}' already has a non-imported profile; refusing to clobber it",
|
|
||||||
req.profile.game_id
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 2. definition preflight: every card_id MUST resolve in loaded content ──
|
|
||||||
let mut missing: Vec<&str> = req
|
|
||||||
.owned
|
|
||||||
.iter()
|
|
||||||
.filter(|o| card_db.get(&o.card_id).is_none())
|
|
||||||
.map(|o| o.card_id.as_str())
|
|
||||||
.collect();
|
|
||||||
if !missing.is_empty() {
|
|
||||||
missing.sort_unstable();
|
|
||||||
missing.dedup();
|
|
||||||
let sample = &missing[..missing.len().min(5)];
|
|
||||||
bail!(
|
|
||||||
"definition preflight failed: {} owned card(s) reference CardDefinitionId(s) not in \
|
|
||||||
loaded content (e.g. {sample:?}); refusing to create ownership pointing at absent content",
|
|
||||||
missing.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 3. owned-item-id uniqueness ──
|
|
||||||
let mut owned_ids: HashSet<&str> = HashSet::with_capacity(req.owned.len());
|
|
||||||
for o in &req.owned {
|
|
||||||
if !owned_ids.insert(o.owned_item_id.as_str()) {
|
|
||||||
bail!(
|
|
||||||
"duplicate OwnedItemId in import request: {}",
|
|
||||||
o.owned_item_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 4. squad all-or-nothing + generic extension bounds (no writes) ──
|
|
||||||
if let Some(sq) = &req.squad {
|
|
||||||
let ns_len = sq.extension.namespace.len();
|
|
||||||
if ns_len == 0 || ns_len > MAX_EXT_NAMESPACE_LEN {
|
|
||||||
bail!(
|
|
||||||
"extension namespace length {ns_len} out of bounds (1..={MAX_EXT_NAMESPACE_LEN})"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if sq.extension.payload.len() > MAX_EXT_PAYLOAD_BYTES {
|
|
||||||
bail!(
|
|
||||||
"extension payload {} bytes exceeds MAX_EXT_PAYLOAD_BYTES ({MAX_EXT_PAYLOAD_BYTES})",
|
|
||||||
sq.extension.payload.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for slot in &sq.slots {
|
|
||||||
if !owned_ids.contains(slot.owned_item_id.as_str()) {
|
|
||||||
bail!(
|
|
||||||
"active squad references OwnedItemId {} not present in imported ownership set; \
|
|
||||||
squad import is all-or-nothing",
|
|
||||||
slot.owned_item_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 5. single transaction: everything commits together or not at all ──
|
|
||||||
let now = Utc::now().to_rfc3339();
|
|
||||||
let profile_id = Uuid::new_v4().to_string();
|
|
||||||
let club_id = Uuid::new_v4().to_string();
|
|
||||||
let mut tx = pool.begin().await?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at, import_fingerprint) \
|
|
||||||
VALUES (?, ?, 1, 0, ?, ?, ?, ?)",
|
|
||||||
)
|
|
||||||
.bind(&profile_id)
|
|
||||||
.bind(&req.profile.username)
|
|
||||||
.bind(&req.profile.game_id)
|
|
||||||
.bind(&now)
|
|
||||||
.bind(&now)
|
|
||||||
.bind(&req.source_fingerprint)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("insert profile")?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO clubs (id, profile_id, name, coins, level, created_at, updated_at) \
|
|
||||||
VALUES (?, ?, ?, ?, 1, ?, ?)",
|
|
||||||
)
|
|
||||||
.bind(&club_id)
|
|
||||||
.bind(&profile_id)
|
|
||||||
.bind(&req.club.name)
|
|
||||||
.bind(req.club.coins)
|
|
||||||
.bind(&now)
|
|
||||||
.bind(&now)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("insert club")?;
|
|
||||||
|
|
||||||
for o in &req.owned {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \
|
|
||||||
VALUES (?, ?, ?, 0, NULL, ?)",
|
|
||||||
)
|
|
||||||
.bind(&o.owned_item_id)
|
|
||||||
.bind(&club_id)
|
|
||||||
.bind(&o.card_id)
|
|
||||||
.bind(&now)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.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;
|
|
||||||
if let Some(sq) = &req.squad {
|
|
||||||
let squad_id = Uuid::new_v4().to_string();
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) \
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)",
|
|
||||||
)
|
|
||||||
.bind(&squad_id)
|
|
||||||
.bind(&club_id)
|
|
||||||
.bind(&sq.name)
|
|
||||||
.bind(&sq.formation)
|
|
||||||
.bind(&now)
|
|
||||||
.bind(&now)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("insert squad")?;
|
|
||||||
|
|
||||||
for slot in &sq.slots {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO squad_players (id, squad_id, owned_card_id, position_index, is_captain, is_on_bench) \
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)",
|
|
||||||
)
|
|
||||||
.bind(Uuid::new_v4().to_string())
|
|
||||||
.bind(&squad_id)
|
|
||||||
.bind(&slot.owned_item_id)
|
|
||||||
.bind(slot.position_index)
|
|
||||||
.bind(slot.is_captain)
|
|
||||||
.bind(slot.is_on_bench)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("insert squad_player")?;
|
|
||||||
}
|
|
||||||
squad_slots = sq.slots.len();
|
|
||||||
|
|
||||||
// Core computes the canonical fingerprint over the COMMITTED squad — never
|
|
||||||
// an adapter-supplied value — and persists the opaque extension atomically
|
|
||||||
// in the same tx, exactly as the live squad-write path does.
|
|
||||||
let canonical_fingerprint = squad_fingerprint(
|
|
||||||
&squad_id,
|
|
||||||
&sq.formation,
|
|
||||||
sq.slots.iter().map(|s| {
|
|
||||||
(
|
|
||||||
s.position_index,
|
|
||||||
s.owned_item_id.as_str(),
|
|
||||||
s.is_captain,
|
|
||||||
s.is_on_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(&req.profile.game_id)
|
|
||||||
.bind(&squad_id)
|
|
||||||
.bind(&sq.extension.namespace)
|
|
||||||
.bind(sq.extension.schema_version)
|
|
||||||
.bind(&canonical_fingerprint)
|
|
||||||
.bind(&sq.extension.payload)
|
|
||||||
.bind(&now)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.context("insert game_entity_ext")?;
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.commit().await?;
|
|
||||||
Ok(ImportOutcome::Imported {
|
|
||||||
owned: req.owned.len(),
|
|
||||||
squad_slots,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Executable → Regular
+10
-35
@@ -43,12 +43,11 @@ pub async fn refresh_npc_listings(
|
|||||||
card_db: &CardDb,
|
card_db: &CardDb,
|
||||||
event_defs: &[EventDefinition],
|
event_defs: &[EventDefinition],
|
||||||
) -> AppResult<usize> {
|
) -> AppResult<usize> {
|
||||||
// Clean up expired listings and previous NPC listings.
|
// Clean up expired and unsold listings
|
||||||
// Player-posted listings (is_npc = 0) are intentionally preserved.
|
|
||||||
sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')")
|
sqlx::query("DELETE FROM market_listings WHERE expires_at < datetime('now')")
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::query("DELETE FROM market_listings WHERE sold = 0 AND is_npc = 1")
|
sqlx::query("DELETE FROM market_listings WHERE sold = 0")
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -108,8 +107,8 @@ pub async fn refresh_npc_listings(
|
|||||||
for listing in &listings_to_insert {
|
for listing in &listings_to_insert {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO market_listings \
|
"INSERT INTO market_listings \
|
||||||
(id, card_id, seller_name, price, listed_at, expires_at, sold, is_npc) \
|
(id, card_id, seller_name, price, listed_at, expires_at, sold) \
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 0, 1)",
|
VALUES (?, ?, ?, ?, ?, ?, 0)",
|
||||||
)
|
)
|
||||||
.bind(&listing.id)
|
.bind(&listing.id)
|
||||||
.bind(&listing.card_id)
|
.bind(&listing.card_id)
|
||||||
@@ -141,25 +140,12 @@ 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()))?;
|
||||||
|
|
||||||
// Atomically claim the listing (flip sold 0->1) before charging, so two concurrent
|
club::spend_coins(pool, club_id, listing.price).await?;
|
||||||
// buyers cannot both mint the same card. If the debit then fails, release the claim.
|
|
||||||
let claimed = sqlx::query("UPDATE market_listings SET sold = 1 WHERE id = ? AND sold = 0")
|
sqlx::query("UPDATE market_listings SET sold = 1 WHERE id = ?")
|
||||||
.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(
|
||||||
@@ -204,10 +190,6 @@ pub async fn sell_card(
|
|||||||
club_id: &str,
|
club_id: &str,
|
||||||
req: &SellCardRequest,
|
req: &SellCardRequest,
|
||||||
) -> AppResult<i64> {
|
) -> AppResult<i64> {
|
||||||
if req.price < 0 {
|
|
||||||
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>(
|
||||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
|
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
|
||||||
chemistry_style, position_override, training_bonus \
|
chemistry_style, position_override, training_bonus \
|
||||||
@@ -219,17 +201,10 @@ pub async fn sell_card(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("owned card not found".into()))?;
|
.ok_or_else(|| AppError::NotFound("owned card not found".into()))?;
|
||||||
|
|
||||||
// Atomically claim the card: guard the DELETE with the owner + rows_affected so two
|
sqlx::query("DELETE FROM owned_cards WHERE id = ?")
|
||||||
// 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?;
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
db::Pool,
|
db::Pool,
|
||||||
error::{AppError, AppResult},
|
error::AppResult,
|
||||||
models::{
|
models::{
|
||||||
achievement::AchievementDefinition,
|
achievement::AchievementDefinition,
|
||||||
card::OwnedCard,
|
card::OwnedCard,
|
||||||
match_result::{
|
match_result::{Match, MatchRewardResult, SubmitMatchRequest},
|
||||||
CompleteMatchRequest, Match, MatchCompletionResult, MatchResultKind, MatchRewardResult,
|
|
||||||
SubmitMatchRequest,
|
|
||||||
},
|
|
||||||
objective::ObjectiveDefinition,
|
objective::ObjectiveDefinition,
|
||||||
profile::{coins_for_level, level_for_xp, pack_for_level, LevelUpEvent},
|
|
||||||
},
|
},
|
||||||
services::{
|
services::{
|
||||||
achievement, card_db::CardDb, club, notification, objective, profile, season as season_svc,
|
achievement, card_db::CardDb, club, notification, objective, profile, season as season_svc,
|
||||||
@@ -17,8 +13,6 @@ use crate::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
use rand::{seq::SliceRandom, Rng};
|
use rand::{seq::SliceRandom, Rng};
|
||||||
use sqlx::{Sqlite, Transaction};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
const FORMATIONS: &[&str] = &["4-3-3", "4-4-2", "4-2-3-1", "4-1-2-1-2", "3-5-2", "5-3-2"];
|
const FORMATIONS: &[&str] = &["4-3-3", "4-4-2", "4-2-3-1", "4-1-2-1-2", "3-5-2", "5-3-2"];
|
||||||
|
|
||||||
@@ -135,12 +129,6 @@ pub async fn process_match(
|
|||||||
obj_defs: &[ObjectiveDefinition],
|
obj_defs: &[ObjectiveDefinition],
|
||||||
ach_defs: &[AchievementDefinition],
|
ach_defs: &[AchievementDefinition],
|
||||||
) -> AppResult<MatchRewardResult> {
|
) -> AppResult<MatchRewardResult> {
|
||||||
if req.goals_for < 0 || req.goals_against < 0 || req.goals_for > 99 || req.goals_against > 99 {
|
|
||||||
return Err(crate::error::AppError::BadRequest(
|
|
||||||
"goals_for and goals_against must each be between 0 and 99".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let outcome = if req.goals_for > req.goals_against {
|
let outcome = if req.goals_for > req.goals_against {
|
||||||
"win"
|
"win"
|
||||||
} else if req.goals_for == req.goals_against {
|
} else if req.goals_for == req.goals_against {
|
||||||
@@ -238,17 +226,12 @@ pub async fn process_match(
|
|||||||
objectives_updated.append(&mut c);
|
objectives_updated.append(&mut c);
|
||||||
|
|
||||||
for obj_id in &objectives_updated {
|
for obj_id in &objectives_updated {
|
||||||
let display_name = obj_defs
|
let title = "Objective complete!";
|
||||||
.iter()
|
|
||||||
.find(|d| &d.id == obj_id)
|
|
||||||
.map(|d| d.title.as_str())
|
|
||||||
.unwrap_or(obj_id.as_str());
|
|
||||||
let body = format!(
|
let body = format!(
|
||||||
"\"{}\" is now complete. Claim your reward in Objectives.",
|
"\"{}\" is now complete. Claim your reward in Objectives.",
|
||||||
display_name
|
obj_id
|
||||||
);
|
);
|
||||||
let _ =
|
let _ = notification::create(pool, "objective_complete", title, &body).await;
|
||||||
notification::create(pool, "objective_complete", "Objective complete!", &body).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrement loan matches remaining for squad starters; collect expired loan IDs.
|
// Decrement loan matches remaining for squad starters; collect expired loan IDs.
|
||||||
@@ -345,836 +328,3 @@ async fn process_loan_expiry(pool: &Pool, club_id: &str, squad_id: &str) -> AppR
|
|||||||
|
|
||||||
Ok(expired)
|
Ok(expired)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────── Atomic match completion ────────────────────────
|
|
||||||
|
|
||||||
/// Coins + XP for a canonical result. A DNF earns the loss tier — an abandon is
|
|
||||||
/// economically a loss — while a no-contest grants nothing. WIN/DRAW/LOSS keep
|
|
||||||
/// the existing amounts.
|
|
||||||
fn rewards_for(result: MatchResultKind) -> (i64, i64) {
|
|
||||||
match result {
|
|
||||||
MatchResultKind::Win => (COINS_WIN, XP_WIN),
|
|
||||||
MatchResultKind::Draw => (COINS_DRAW, XP_DRAW),
|
|
||||||
MatchResultKind::Loss => (COINS_LOSS, XP_LOSS),
|
|
||||||
MatchResultKind::Dnf => (COINS_LOSS, XP_LOSS),
|
|
||||||
MatchResultKind::NoContest => (0, 0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a persisted canonical result token back into [`MatchResultKind`].
|
|
||||||
fn parse_result(s: &str) -> AppResult<MatchResultKind> {
|
|
||||||
Ok(match s {
|
|
||||||
"win" => MatchResultKind::Win,
|
|
||||||
"draw" => MatchResultKind::Draw,
|
|
||||||
"loss" => MatchResultKind::Loss,
|
|
||||||
"dnf" => MatchResultKind::Dnf,
|
|
||||||
"no_contest" => MatchResultKind::NoContest,
|
|
||||||
other => {
|
|
||||||
return Err(AppError::Internal(anyhow::anyhow!(
|
|
||||||
"unknown persisted match result {other:?}"
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fault-injection points for [`complete_match`]. Every point is AFTER a durable
|
|
||||||
/// write, so a fault MUST roll the entire match back — integrity comes from the
|
|
||||||
/// SQLite transaction, never from compensating cleanup. Only constructed in
|
|
||||||
/// tests.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
enum FaultPoint {
|
|
||||||
AfterCompletionRow,
|
|
||||||
AfterHistory,
|
|
||||||
AfterCoins,
|
|
||||||
AfterXp,
|
|
||||||
AfterStatistics,
|
|
||||||
AfterObjectives,
|
|
||||||
BeforeCommit,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn inject_fault(actual: Option<FaultPoint>, point: FaultPoint) -> AppResult<()> {
|
|
||||||
if actual == Some(point) {
|
|
||||||
return Err(AppError::Internal(anyhow::anyhow!(
|
|
||||||
"injected match-completion fault at {point:?}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Complete a match exactly once, atomically. This is the authoritative
|
|
||||||
/// economic entry point for a finished match: it validates the request, refuses
|
|
||||||
/// a duplicate via the durable `(profile_id, match_identity)` guard, and — for
|
|
||||||
/// an economic result — persists history, grants coins, grants XP + level-ups,
|
|
||||||
/// updates W/D/L/DNF statistics, advances objectives, and unlocks achievements,
|
|
||||||
/// all in one transaction that either commits together or rolls back whole.
|
|
||||||
///
|
|
||||||
/// A replay (sequential, restart, concurrent, or a conflicting re-report of the
|
|
||||||
/// same match) is an idempotent no-op that echoes the persisted canonical
|
|
||||||
/// result with `applied = false`.
|
|
||||||
pub async fn complete_match(
|
|
||||||
pool: &Pool,
|
|
||||||
profile_id: &str,
|
|
||||||
club_id: &str,
|
|
||||||
req: &CompleteMatchRequest,
|
|
||||||
obj_defs: &[ObjectiveDefinition],
|
|
||||||
ach_defs: &[AchievementDefinition],
|
|
||||||
) -> AppResult<MatchCompletionResult> {
|
|
||||||
complete_match_inner(pool, profile_id, club_id, req, obj_defs, ach_defs, None).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
async fn complete_match_inner(
|
|
||||||
pool: &Pool,
|
|
||||||
profile_id: &str,
|
|
||||||
club_id: &str,
|
|
||||||
req: &CompleteMatchRequest,
|
|
||||||
obj_defs: &[ObjectiveDefinition],
|
|
||||||
ach_defs: &[AchievementDefinition],
|
|
||||||
fault: Option<FaultPoint>,
|
|
||||||
) -> AppResult<MatchCompletionResult> {
|
|
||||||
if req.goals_for < 0 || req.goals_against < 0 || req.goals_for > 99 || req.goals_against > 99 {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"goals_for and goals_against must each be between 0 and 99".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if req.match_identity.trim().is_empty() {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"match_identity must not be empty".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = req.result;
|
|
||||||
let (coins, xp) = rewards_for(result);
|
|
||||||
let outcome = result.as_str();
|
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
|
||||||
let match_id = Uuid::new_v4().to_string();
|
|
||||||
let completion_id = Uuid::new_v4().to_string();
|
|
||||||
|
|
||||||
let mut tx = pool.begin().await?;
|
|
||||||
|
|
||||||
// 1. Durable match-history row FIRST. This is also the transaction's first
|
|
||||||
// write, so it takes SQLite's single writer lock and serializes
|
|
||||||
// overlapping completions. The `match_completions` guard below carries a
|
|
||||||
// FK to this row, so it must exist before the guard is written.
|
|
||||||
let match_record = Match {
|
|
||||||
id: match_id.clone(),
|
|
||||||
profile_id: profile_id.to_string(),
|
|
||||||
squad_id: req.squad_id.clone(),
|
|
||||||
opponent_name: req.opponent_name.clone(),
|
|
||||||
goals_for: req.goals_for,
|
|
||||||
goals_against: req.goals_against,
|
|
||||||
outcome: outcome.to_string(),
|
|
||||||
coins_awarded: coins,
|
|
||||||
xp_awarded: xp,
|
|
||||||
mode: req.mode.clone(),
|
|
||||||
played_at: now.clone(),
|
|
||||||
};
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO matches (id, profile_id, squad_id, opponent_name, goals_for, goals_against, outcome, coins_awarded, xp_awarded, mode, played_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
)
|
|
||||||
.bind(&match_record.id)
|
|
||||||
.bind(&match_record.profile_id)
|
|
||||||
.bind(&match_record.squad_id)
|
|
||||||
.bind(&match_record.opponent_name)
|
|
||||||
.bind(match_record.goals_for)
|
|
||||||
.bind(match_record.goals_against)
|
|
||||||
.bind(&match_record.outcome)
|
|
||||||
.bind(match_record.coins_awarded)
|
|
||||||
.bind(match_record.xp_awarded)
|
|
||||||
.bind(&match_record.mode)
|
|
||||||
.bind(&match_record.played_at)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
inject_fault(fault, FaultPoint::AfterHistory)?;
|
|
||||||
|
|
||||||
// 2. Economic-idempotency guard. Enforces the durable
|
|
||||||
// (profile_id, match_identity) uniqueness: a duplicate — sequential,
|
|
||||||
// concurrent, restart, or a conflicting re-report with a different result
|
|
||||||
// — collides here and rolls the whole match (including the history row
|
|
||||||
// just written) back before any reward is granted.
|
|
||||||
let guard = sqlx::query(
|
|
||||||
"INSERT INTO match_completions \
|
|
||||||
(id, profile_id, match_identity, result, coins_awarded, xp_awarded, match_id, completed_at) \
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
)
|
|
||||||
.bind(&completion_id)
|
|
||||||
.bind(profile_id)
|
|
||||||
.bind(&req.match_identity)
|
|
||||||
.bind(outcome)
|
|
||||||
.bind(coins)
|
|
||||||
.bind(xp)
|
|
||||||
.bind(&match_id)
|
|
||||||
.bind(&now)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match guard {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
|
||||||
// Already economically completed: roll back this attempt and echo
|
|
||||||
// the canonical persisted result. The first completion wins.
|
|
||||||
tx.rollback().await?;
|
|
||||||
return already_completed(pool, profile_id, club_id, &req.match_identity).await;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tx.rollback().await?;
|
|
||||||
return Err(e.into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
inject_fault(fault, FaultPoint::AfterCompletionRow)?;
|
|
||||||
|
|
||||||
let mut objectives_updated = Vec::new();
|
|
||||||
let mut level_ups = Vec::new();
|
|
||||||
let mut achievements_unlocked = Vec::new();
|
|
||||||
|
|
||||||
// A no-contest is recorded (history + idempotency) but has ZERO economic
|
|
||||||
// effect: no coins, XP, statistics, objectives, or achievements.
|
|
||||||
if result.is_economic() {
|
|
||||||
// 3. Coins.
|
|
||||||
if coins > 0 {
|
|
||||||
let credited =
|
|
||||||
sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?")
|
|
||||||
.bind(coins)
|
|
||||||
.bind(&now)
|
|
||||||
.bind(club_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
if credited.rows_affected() != 1 {
|
|
||||||
return Err(AppError::NotFound("club not found".into()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
inject_fault(fault, FaultPoint::AfterCoins)?;
|
|
||||||
|
|
||||||
// 4. XP + level-ups (level rewards credited in-transaction).
|
|
||||||
level_ups = grant_xp_with_levelup_tx(&mut tx, profile_id, club_id, xp, &now).await?;
|
|
||||||
inject_fault(fault, FaultPoint::AfterXp)?;
|
|
||||||
|
|
||||||
// 5. W/D/L/DNF statistics.
|
|
||||||
statistics::record_match_tx(
|
|
||||||
&mut tx,
|
|
||||||
profile_id,
|
|
||||||
outcome,
|
|
||||||
req.goals_for,
|
|
||||||
req.goals_against,
|
|
||||||
coins,
|
|
||||||
&now,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
if let Some(positions) = &req.goal_positions {
|
|
||||||
statistics::record_position_goals_tx(&mut tx, profile_id, positions).await?;
|
|
||||||
}
|
|
||||||
inject_fault(fault, FaultPoint::AfterStatistics)?;
|
|
||||||
|
|
||||||
// 6. Objectives.
|
|
||||||
objectives_updated.append(
|
|
||||||
&mut objective::increment_metric_tx(
|
|
||||||
&mut tx,
|
|
||||||
profile_id,
|
|
||||||
obj_defs,
|
|
||||||
"matchesplayed",
|
|
||||||
1,
|
|
||||||
&now,
|
|
||||||
)
|
|
||||||
.await?,
|
|
||||||
);
|
|
||||||
if result == MatchResultKind::Win {
|
|
||||||
objectives_updated.append(
|
|
||||||
&mut objective::increment_metric_tx(
|
|
||||||
&mut tx,
|
|
||||||
profile_id,
|
|
||||||
obj_defs,
|
|
||||||
"matcheswon",
|
|
||||||
1,
|
|
||||||
&now,
|
|
||||||
)
|
|
||||||
.await?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
objectives_updated.append(
|
|
||||||
&mut objective::increment_metric_tx(
|
|
||||||
&mut tx,
|
|
||||||
profile_id,
|
|
||||||
obj_defs,
|
|
||||||
"goalsscored",
|
|
||||||
req.goals_for,
|
|
||||||
&now,
|
|
||||||
)
|
|
||||||
.await?,
|
|
||||||
);
|
|
||||||
objectives_updated.append(
|
|
||||||
&mut objective::increment_metric_tx(
|
|
||||||
&mut tx,
|
|
||||||
profile_id,
|
|
||||||
obj_defs,
|
|
||||||
"coinsearned",
|
|
||||||
coins,
|
|
||||||
&now,
|
|
||||||
)
|
|
||||||
.await?,
|
|
||||||
);
|
|
||||||
inject_fault(fault, FaultPoint::AfterObjectives)?;
|
|
||||||
|
|
||||||
// 7. Achievements.
|
|
||||||
achievements_unlocked =
|
|
||||||
achievement::check_and_unlock_tx(&mut tx, ach_defs, profile_id, club_id, &now).await?;
|
|
||||||
}
|
|
||||||
inject_fault(fault, FaultPoint::BeforeCommit)?;
|
|
||||||
|
|
||||||
// 8. Echo the post-completion balance, then commit everything atomically.
|
|
||||||
let coins_balance: i64 = sqlx::query_scalar("SELECT coins FROM clubs WHERE id = ?")
|
|
||||||
.bind(club_id)
|
|
||||||
.fetch_one(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
tx.commit().await?;
|
|
||||||
|
|
||||||
Ok(MatchCompletionResult {
|
|
||||||
applied: true,
|
|
||||||
match_identity: req.match_identity.clone(),
|
|
||||||
result,
|
|
||||||
coins_awarded: coins,
|
|
||||||
xp_awarded: xp,
|
|
||||||
coins_balance,
|
|
||||||
objectives_updated,
|
|
||||||
level_ups,
|
|
||||||
achievements_unlocked,
|
|
||||||
match_record,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the idempotent echo for an already-completed match. Reads the persisted
|
|
||||||
/// canonical result (never re-derives it) so a WIN-then-LOSS re-report returns
|
|
||||||
/// the WIN that actually landed.
|
|
||||||
async fn already_completed(
|
|
||||||
pool: &Pool,
|
|
||||||
profile_id: &str,
|
|
||||||
club_id: &str,
|
|
||||||
match_identity: &str,
|
|
||||||
) -> AppResult<MatchCompletionResult> {
|
|
||||||
let (match_id, result_str, coins, xp): (String, String, i64, i64) = sqlx::query_as(
|
|
||||||
"SELECT match_id, result, coins_awarded, xp_awarded FROM match_completions \
|
|
||||||
WHERE profile_id = ? AND match_identity = ?",
|
|
||||||
)
|
|
||||||
.bind(profile_id)
|
|
||||||
.bind(match_identity)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
AppError::Internal(anyhow::anyhow!(
|
|
||||||
"match_completions row missing after unique violation"
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let result = parse_result(&result_str)?;
|
|
||||||
let match_record = sqlx::query_as::<_, Match>(
|
|
||||||
"SELECT id, profile_id, squad_id, opponent_name, goals_for, goals_against, outcome, coins_awarded, xp_awarded, mode, played_at FROM matches WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(&match_id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
let coins_balance: i64 = sqlx::query_scalar("SELECT coins FROM clubs WHERE id = ?")
|
|
||||||
.bind(club_id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(MatchCompletionResult {
|
|
||||||
applied: false,
|
|
||||||
match_identity: match_identity.to_string(),
|
|
||||||
result,
|
|
||||||
coins_awarded: coins,
|
|
||||||
xp_awarded: xp,
|
|
||||||
coins_balance,
|
|
||||||
objectives_updated: vec![],
|
|
||||||
level_ups: vec![],
|
|
||||||
achievements_unlocked: vec![],
|
|
||||||
match_record,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Transaction-scoped XP grant with level-ups. Mirrors
|
|
||||||
/// [`crate::services::profile::add_xp_with_levelup`] but runs entirely inside the
|
|
||||||
/// caller's transaction (level-up coins + packs credited in-tx), so it commits or
|
|
||||||
/// rolls back with the rest of the match. Notifications are intentionally not
|
|
||||||
/// emitted here — they are non-durable side effects the pooled path owns.
|
|
||||||
async fn grant_xp_with_levelup_tx(
|
|
||||||
tx: &mut Transaction<'_, Sqlite>,
|
|
||||||
profile_id: &str,
|
|
||||||
club_id: &str,
|
|
||||||
xp_to_add: i64,
|
|
||||||
now: &str,
|
|
||||||
) -> AppResult<Vec<LevelUpEvent>> {
|
|
||||||
let current_xp: i64 = sqlx::query_scalar("SELECT xp FROM profiles WHERE id = ?")
|
|
||||||
.bind(profile_id)
|
|
||||||
.fetch_optional(&mut **tx)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound(format!("profile '{profile_id}' not found")))?;
|
|
||||||
|
|
||||||
let old_level = level_for_xp(current_xp);
|
|
||||||
let new_total = current_xp + xp_to_add;
|
|
||||||
let new_level = level_for_xp(new_total);
|
|
||||||
|
|
||||||
sqlx::query("UPDATE profiles SET xp = ?, level = ?, updated_at = ? WHERE id = ?")
|
|
||||||
.bind(new_total)
|
|
||||||
.bind(new_level)
|
|
||||||
.bind(now)
|
|
||||||
.bind(profile_id)
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let mut events = Vec::new();
|
|
||||||
for lvl in (old_level + 1)..=new_level {
|
|
||||||
let coins = coins_for_level(lvl);
|
|
||||||
let pack = pack_for_level(lvl).map(String::from);
|
|
||||||
if coins > 0 {
|
|
||||||
let credited =
|
|
||||||
sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?")
|
|
||||||
.bind(coins)
|
|
||||||
.bind(now)
|
|
||||||
.bind(club_id)
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
if credited.rows_affected() != 1 {
|
|
||||||
return Err(AppError::NotFound("club not found".into()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(pack_id) = &pack {
|
|
||||||
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_id)
|
|
||||||
.bind(now)
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
events.push(LevelUpEvent {
|
|
||||||
new_level: lvl,
|
|
||||||
coins_granted: coins,
|
|
||||||
pack_granted: pack,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(events)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod match_completion_tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::db;
|
|
||||||
use crate::models::objective::{ObjectiveDefinition, ObjectiveMetric, ObjectiveType};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
const PROFILE: &str = "profile";
|
|
||||||
const CLUB: &str = "club";
|
|
||||||
const START_COINS: i64 = 1000;
|
|
||||||
|
|
||||||
struct Fixture {
|
|
||||||
pool: Pool,
|
|
||||||
url: String,
|
|
||||||
_dir: TempDir,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn seed(pool: &Pool) {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO profiles (id, username, game_id, created_at, updated_at) \
|
|
||||||
VALUES (?, ?, 'fifa17', 't', 't')",
|
|
||||||
)
|
|
||||||
.bind(PROFILE)
|
|
||||||
.bind(PROFILE)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.expect("seed profile");
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) \
|
|
||||||
VALUES (?, ?, ?, ?, 't', 't')",
|
|
||||||
)
|
|
||||||
.bind(CLUB)
|
|
||||||
.bind(PROFILE)
|
|
||||||
.bind(CLUB)
|
|
||||||
.bind(START_COINS)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.expect("seed club");
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn new_fixture() -> Fixture {
|
|
||||||
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");
|
|
||||||
seed(&pool).await;
|
|
||||||
Fixture {
|
|
||||||
pool,
|
|
||||||
url,
|
|
||||||
_dir: dir,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn req(identity: &str, result: MatchResultKind, gf: i64, ga: i64) -> CompleteMatchRequest {
|
|
||||||
CompleteMatchRequest {
|
|
||||||
match_identity: identity.into(),
|
|
||||||
result,
|
|
||||||
squad_id: "squad".into(),
|
|
||||||
opponent_name: "Opponent".into(),
|
|
||||||
goals_for: gf,
|
|
||||||
goals_against: ga,
|
|
||||||
mode: "seasons".into(),
|
|
||||||
goal_positions: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn complete(pool: &Pool, req: &CompleteMatchRequest) -> AppResult<MatchCompletionResult> {
|
|
||||||
complete_match(pool, PROFILE, CLUB, req, &[], &[]).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn coins(pool: &Pool) -> i64 {
|
|
||||||
sqlx::query_scalar("SELECT coins FROM clubs WHERE id = ?")
|
|
||||||
.bind(CLUB)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
async fn xp(pool: &Pool) -> i64 {
|
|
||||||
sqlx::query_scalar("SELECT xp FROM profiles WHERE id = ?")
|
|
||||||
.bind(PROFILE)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
async fn stat(pool: &Pool, col: &str) -> i64 {
|
|
||||||
let sql = format!("SELECT {col} FROM statistics WHERE profile_id = ?");
|
|
||||||
sqlx::query_scalar(&sql)
|
|
||||||
.bind(PROFILE)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
|
||||||
async fn count(pool: &Pool, table: &str) -> i64 {
|
|
||||||
let sql = format!("SELECT COUNT(*) FROM {table}");
|
|
||||||
sqlx::query_scalar(&sql).fetch_one(pool).await.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn win_grants_reward_stats_and_history() {
|
|
||||||
let fx = new_fixture().await;
|
|
||||||
let r = complete(&fx.pool, &req("m1", MatchResultKind::Win, 3, 1))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(r.applied);
|
|
||||||
assert_eq!(r.coins_awarded, COINS_WIN);
|
|
||||||
assert_eq!(r.xp_awarded, XP_WIN);
|
|
||||||
assert_eq!(r.coins_balance, START_COINS + COINS_WIN);
|
|
||||||
assert_eq!(r.match_record.outcome, "win");
|
|
||||||
assert_eq!(coins(&fx.pool).await, START_COINS + COINS_WIN);
|
|
||||||
assert_eq!(xp(&fx.pool).await, XP_WIN);
|
|
||||||
assert_eq!(stat(&fx.pool, "matches_played").await, 1);
|
|
||||||
assert_eq!(stat(&fx.pool, "matches_won").await, 1);
|
|
||||||
assert_eq!(stat(&fx.pool, "goals_scored").await, 3);
|
|
||||||
assert_eq!(stat(&fx.pool, "goals_conceded").await, 1);
|
|
||||||
assert_eq!(stat(&fx.pool, "win_streak").await, 1);
|
|
||||||
assert_eq!(count(&fx.pool, "matches").await, 1);
|
|
||||||
assert_eq!(count(&fx.pool, "match_completions").await, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn draw_and_loss_reward_tiers() {
|
|
||||||
let fx = new_fixture().await;
|
|
||||||
let d = complete(&fx.pool, &req("d", MatchResultKind::Draw, 1, 1))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(d.coins_awarded, COINS_DRAW);
|
|
||||||
assert_eq!(stat(&fx.pool, "matches_drawn").await, 1);
|
|
||||||
let l = complete(&fx.pool, &req("l", MatchResultKind::Loss, 0, 2))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(l.coins_awarded, COINS_LOSS);
|
|
||||||
assert_eq!(stat(&fx.pool, "matches_lost").await, 1);
|
|
||||||
assert_eq!(coins(&fx.pool).await, START_COINS + COINS_DRAW + COINS_LOSS);
|
|
||||||
// A draw then a loss both reset/keep the streak at zero.
|
|
||||||
assert_eq!(stat(&fx.pool, "win_streak").await, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn dnf_is_loss_economics_with_its_own_bucket() {
|
|
||||||
let fx = new_fixture().await;
|
|
||||||
let r = complete(&fx.pool, &req("dnf", MatchResultKind::Dnf, 0, 0))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(r.applied);
|
|
||||||
assert_eq!(r.coins_awarded, COINS_LOSS, "DNF earns the loss tier");
|
|
||||||
assert_eq!(r.xp_awarded, XP_LOSS);
|
|
||||||
assert_eq!(r.match_record.outcome, "dnf");
|
|
||||||
assert_eq!(stat(&fx.pool, "matches_dnf").await, 1);
|
|
||||||
assert_eq!(
|
|
||||||
stat(&fx.pool, "matches_lost").await,
|
|
||||||
0,
|
|
||||||
"DNF is not a loss row"
|
|
||||||
);
|
|
||||||
assert_eq!(stat(&fx.pool, "matches_played").await, 1);
|
|
||||||
assert_eq!(coins(&fx.pool).await, START_COINS + COINS_LOSS);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn no_contest_has_zero_economic_effect() {
|
|
||||||
let fx = new_fixture().await;
|
|
||||||
let r = complete(&fx.pool, &req("nc", MatchResultKind::NoContest, 0, 0))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(r.applied);
|
|
||||||
assert_eq!(r.coins_awarded, 0);
|
|
||||||
assert_eq!(r.xp_awarded, 0);
|
|
||||||
assert_eq!(r.match_record.outcome, "no_contest");
|
|
||||||
assert_eq!(coins(&fx.pool).await, START_COINS, "no coins for a void");
|
|
||||||
assert_eq!(xp(&fx.pool).await, 0);
|
|
||||||
assert_eq!(
|
|
||||||
stat(&fx.pool, "matches_played").await,
|
|
||||||
0,
|
|
||||||
"not counted as played"
|
|
||||||
);
|
|
||||||
// Still recorded for history + idempotency.
|
|
||||||
assert_eq!(count(&fx.pool, "matches").await, 1);
|
|
||||||
assert_eq!(count(&fx.pool, "match_completions").await, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn sequential_duplicate_is_idempotent() {
|
|
||||||
let fx = new_fixture().await;
|
|
||||||
let first = complete(&fx.pool, &req("m", MatchResultKind::Win, 2, 0))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(first.applied);
|
|
||||||
// A stale retry after success: same identity, applied once only.
|
|
||||||
let second = complete(&fx.pool, &req("m", MatchResultKind::Win, 2, 0))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(!second.applied);
|
|
||||||
assert_eq!(second.result, MatchResultKind::Win);
|
|
||||||
assert_eq!(second.coins_balance, START_COINS + COINS_WIN);
|
|
||||||
assert_eq!(coins(&fx.pool).await, START_COINS + COINS_WIN);
|
|
||||||
assert_eq!(xp(&fx.pool).await, XP_WIN);
|
|
||||||
assert_eq!(stat(&fx.pool, "matches_played").await, 1);
|
|
||||||
assert_eq!(count(&fx.pool, "matches").await, 1);
|
|
||||||
assert_eq!(count(&fx.pool, "match_completions").await, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn conflicting_win_then_loss_keeps_first_canonical() {
|
|
||||||
let fx = new_fixture().await;
|
|
||||||
let win = complete(&fx.pool, &req("x", MatchResultKind::Win, 3, 0))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(win.applied);
|
|
||||||
// Same match re-reported with the OPPOSITE result — only the first wins.
|
|
||||||
let conflict = complete(&fx.pool, &req("x", MatchResultKind::Loss, 0, 3))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(!conflict.applied);
|
|
||||||
assert_eq!(conflict.result, MatchResultKind::Win);
|
|
||||||
assert_eq!(coins(&fx.pool).await, START_COINS + COINS_WIN);
|
|
||||||
assert_eq!(stat(&fx.pool, "matches_won").await, 1);
|
|
||||||
assert_eq!(stat(&fx.pool, "matches_lost").await, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn restart_replay_survives_pool_reopen() {
|
|
||||||
let fx = new_fixture().await;
|
|
||||||
complete(&fx.pool, &req("x", MatchResultKind::Win, 1, 0))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
fx.pool.close().await;
|
|
||||||
|
|
||||||
// Reopen a fresh pool on the SAME file — the durable guard persists.
|
|
||||||
let pool = db::init_pool(&fx.url, 5).await.unwrap();
|
|
||||||
db::run_migrations(&pool).await.unwrap();
|
|
||||||
let replay = complete(&pool, &req("x", MatchResultKind::Win, 1, 0))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(!replay.applied, "replay after restart must not re-apply");
|
|
||||||
assert_eq!(coins(&pool).await, START_COINS + COINS_WIN);
|
|
||||||
// A genuinely new match still credits.
|
|
||||||
let fresh = complete(&pool, &req("y", MatchResultKind::Win, 1, 0))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(fresh.applied);
|
|
||||||
assert_eq!(coins(&pool).await, START_COINS + 2 * COINS_WIN);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
||||||
async fn concurrent_duplicate_applies_exactly_once() {
|
|
||||||
let fx = new_fixture().await;
|
|
||||||
let pool = Arc::new(fx.pool.clone());
|
|
||||||
let mut handles = Vec::new();
|
|
||||||
for _ in 0..8 {
|
|
||||||
let p = pool.clone();
|
|
||||||
handles.push(tokio::spawn(async move {
|
|
||||||
complete(&p, &req("race", MatchResultKind::Win, 4, 2)).await
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
let mut applied = 0;
|
|
||||||
for h in handles {
|
|
||||||
if let Ok(r) = h.await.unwrap() {
|
|
||||||
if r.applied {
|
|
||||||
applied += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert_eq!(applied, 1, "exactly one racer applies the economic effect");
|
|
||||||
assert_eq!(coins(&pool).await, START_COINS + COINS_WIN);
|
|
||||||
assert_eq!(count(&pool, "match_completions").await, 1);
|
|
||||||
assert_eq!(count(&pool, "matches").await, 1);
|
|
||||||
assert_eq!(stat(&pool, "matches_played").await, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn empty_match_identity_is_rejected() {
|
|
||||||
let fx = new_fixture().await;
|
|
||||||
let err = complete(&fx.pool, &req(" ", MatchResultKind::Win, 1, 0))
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(err, AppError::BadRequest(_)));
|
|
||||||
assert_eq!(coins(&fx.pool).await, START_COINS);
|
|
||||||
assert_eq!(count(&fx.pool, "match_completions").await, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn unknown_club_rolls_back_whole_match() {
|
|
||||||
let fx = new_fixture().await;
|
|
||||||
// A completion aimed at a club that does not exist: the coin credit
|
|
||||||
// affects no row, the transaction fails, and NOTHING is persisted.
|
|
||||||
let err = complete_match(
|
|
||||||
&fx.pool,
|
|
||||||
PROFILE,
|
|
||||||
"no-such-club",
|
|
||||||
&req("m", MatchResultKind::Win, 1, 0),
|
|
||||||
&[],
|
|
||||||
&[],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(err, AppError::NotFound(_)));
|
|
||||||
assert_eq!(count(&fx.pool, "match_completions").await, 0);
|
|
||||||
assert_eq!(count(&fx.pool, "matches").await, 0);
|
|
||||||
assert_eq!(coins(&fx.pool).await, START_COINS);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALL_FAULTS: &[FaultPoint] = &[
|
|
||||||
FaultPoint::AfterCompletionRow,
|
|
||||||
FaultPoint::AfterHistory,
|
|
||||||
FaultPoint::AfterCoins,
|
|
||||||
FaultPoint::AfterXp,
|
|
||||||
FaultPoint::AfterStatistics,
|
|
||||||
FaultPoint::AfterObjectives,
|
|
||||||
FaultPoint::BeforeCommit,
|
|
||||||
];
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn fault_at_every_stage_rolls_back_completely() {
|
|
||||||
for &fp in ALL_FAULTS {
|
|
||||||
let fx = new_fixture().await;
|
|
||||||
let r = complete_match_inner(
|
|
||||||
&fx.pool,
|
|
||||||
PROFILE,
|
|
||||||
CLUB,
|
|
||||||
&req("m", MatchResultKind::Win, 3, 1),
|
|
||||||
&[],
|
|
||||||
&[],
|
|
||||||
Some(fp),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert!(r.is_err(), "{fp:?} should error");
|
|
||||||
// Re-read persisted state: the whole match rolled back.
|
|
||||||
assert_eq!(coins(&fx.pool).await, START_COINS, "{fp:?} coins");
|
|
||||||
assert_eq!(xp(&fx.pool).await, 0, "{fp:?} xp");
|
|
||||||
assert_eq!(stat(&fx.pool, "matches_played").await, 0, "{fp:?} stats");
|
|
||||||
assert_eq!(count(&fx.pool, "matches").await, 0, "{fp:?} history");
|
|
||||||
assert_eq!(
|
|
||||||
count(&fx.pool, "match_completions").await,
|
|
||||||
0,
|
|
||||||
"{fp:?} guard row"
|
|
||||||
);
|
|
||||||
// The rolled-back guard frees the identity, so a clean retry succeeds.
|
|
||||||
let retry = complete(&fx.pool, &req("m", MatchResultKind::Win, 3, 1))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(retry.applied, "{fp:?} retry-after-fault must apply");
|
|
||||||
assert_eq!(
|
|
||||||
coins(&fx.pool).await,
|
|
||||||
START_COINS + COINS_WIN,
|
|
||||||
"{fp:?} retry"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn obj(id: &str, metric: ObjectiveMetric, target: i64) -> ObjectiveDefinition {
|
|
||||||
ObjectiveDefinition {
|
|
||||||
id: id.into(),
|
|
||||||
title: id.into(),
|
|
||||||
description: id.into(),
|
|
||||||
objective_type: ObjectiveType::Lifetime,
|
|
||||||
metric,
|
|
||||||
target,
|
|
||||||
reward_coins: 0,
|
|
||||||
reward_pack_id: None,
|
|
||||||
reward_xp: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ach(id: &str, trigger: &str, threshold: i64, reward: i64) -> AchievementDefinition {
|
|
||||||
AchievementDefinition {
|
|
||||||
id: id.into(),
|
|
||||||
title: id.into(),
|
|
||||||
description: id.into(),
|
|
||||||
icon: String::new(),
|
|
||||||
trigger: trigger.into(),
|
|
||||||
threshold,
|
|
||||||
reward_coins: reward,
|
|
||||||
rarity: "common".into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn objectives_and_achievements_apply_exactly_once() {
|
|
||||||
let fx = new_fixture().await;
|
|
||||||
let objs = vec![
|
|
||||||
obj("played", ObjectiveMetric::MatchesPlayed, 1),
|
|
||||||
obj("won", ObjectiveMetric::MatchesWon, 1),
|
|
||||||
];
|
|
||||||
let achs = vec![ach("first_win", "matches_won", 1, 50)];
|
|
||||||
|
|
||||||
let first = complete_match(
|
|
||||||
&fx.pool,
|
|
||||||
PROFILE,
|
|
||||||
CLUB,
|
|
||||||
&req("m", MatchResultKind::Win, 2, 0),
|
|
||||||
&objs,
|
|
||||||
&achs,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(first.applied);
|
|
||||||
assert_eq!(first.objectives_updated.len(), 2);
|
|
||||||
assert_eq!(first.achievements_unlocked.len(), 1);
|
|
||||||
// Match reward + achievement reward.
|
|
||||||
assert_eq!(coins(&fx.pool).await, START_COINS + COINS_WIN + 50);
|
|
||||||
assert_eq!(count(&fx.pool, "player_achievements").await, 1);
|
|
||||||
|
|
||||||
// Replay: no double objectives/achievements/coins.
|
|
||||||
let replay = complete_match(
|
|
||||||
&fx.pool,
|
|
||||||
PROFILE,
|
|
||||||
CLUB,
|
|
||||||
&req("m", MatchResultKind::Win, 2, 0),
|
|
||||||
&objs,
|
|
||||||
&achs,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(!replay.applied);
|
|
||||||
assert!(replay.objectives_updated.is_empty());
|
|
||||||
assert!(replay.achievements_unlocked.is_empty());
|
|
||||||
assert_eq!(coins(&fx.pool).await, START_COINS + COINS_WIN + 50);
|
|
||||||
assert_eq!(count(&fx.pool, "player_achievements").await, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,11 +3,8 @@ pub mod card_db;
|
|||||||
pub mod checkin;
|
pub mod checkin;
|
||||||
pub mod club;
|
pub mod club;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
pub mod economy;
|
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod fut_champs;
|
pub mod fut_champs;
|
||||||
pub mod game_ext;
|
|
||||||
pub mod import;
|
|
||||||
pub mod inventory;
|
pub mod inventory;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
pub mod match_service;
|
pub mod match_service;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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;
|
||||||
|
|
||||||
@@ -68,7 +67,10 @@ 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.iter().filter(|d| d.metric.as_str() == metric) {
|
for def in defs
|
||||||
|
.iter()
|
||||||
|
.filter(|d| format!("{:?}", d.metric).to_lowercase() == 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 = ?"
|
||||||
)
|
)
|
||||||
@@ -121,72 +123,6 @@ 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,
|
||||||
|
|||||||
+4
-13
@@ -71,16 +71,7 @@ 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")))?;
|
||||||
|
|
||||||
// Atomically claim the pack before minting any cards: only one concurrent opener
|
if pack.opened {
|
||||||
// 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()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,8 +90,8 @@ pub async fn open_pack(
|
|||||||
.all()
|
.all()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|c| {
|
.filter(|c| {
|
||||||
let r = c.rarity.as_str();
|
let r = format!("{:?}", c.rarity).to_lowercase();
|
||||||
rarities.contains(&r.to_string())
|
rarities.contains(&r)
|
||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect()
|
.collect()
|
||||||
@@ -137,7 +128,7 @@ pub async fn open_pack(
|
|||||||
serde_json::to_string(&cards.iter().map(|c| &c.id).collect::<Vec<_>>()).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_cards = ?, opened_at = ? WHERE id = ?")
|
sqlx::query("UPDATE packs SET opened = 1, opened_cards = ?, opened_at = ? WHERE id = ?")
|
||||||
.bind(&card_ids_json)
|
.bind(&card_ids_json)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.bind(pack_id)
|
.bind(pack_id)
|
||||||
|
|||||||
+8
-22
@@ -5,41 +5,33 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|
||||||
/// Fetch the active profile for a game. Single-profile-per-game: there is exactly
|
pub async fn get_active_profile(pool: &Pool) -> AppResult<Profile> {
|
||||||
/// one profile row per `game_id`, so we take the earliest for that game.
|
|
||||||
pub async fn get_active_profile(pool: &Pool, game_id: &str) -> AppResult<Profile> {
|
|
||||||
sqlx::query_as::<_, Profile>(
|
sqlx::query_as::<_, Profile>(
|
||||||
"SELECT id, username, level, xp, game_id, created_at, updated_at \
|
"SELECT id, username, level, xp, created_at, updated_at FROM profiles ORDER BY created_at ASC LIMIT 1"
|
||||||
FROM profiles WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
|
|
||||||
)
|
)
|
||||||
.bind(game_id)
|
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("no profile exists; call POST /auth/local first".into()))
|
.ok_or_else(|| AppError::NotFound("no profile exists; call POST /auth/local first".into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_profile(pool: &Pool, username: &str, game_id: &str) -> AppResult<Profile> {
|
pub async fn create_profile(pool: &Pool, username: &str) -> AppResult<Profile> {
|
||||||
// Single-player per game: one profile per game_id, not one globally.
|
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles")
|
||||||
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM profiles WHERE game_id = ?")
|
|
||||||
.bind(game_id)
|
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
if existing > 0 {
|
if existing > 0 {
|
||||||
return Err(AppError::Conflict(
|
return Err(AppError::Conflict(
|
||||||
"a profile already exists for this game; OpenFUT is single-player per game".into(),
|
"a profile already exists; OpenFUT is single-player only".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let profile = Profile::new(username, game_id);
|
let profile = Profile::new(username);
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at) \
|
"INSERT INTO profiles (id, username, level, xp, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
)
|
)
|
||||||
.bind(&profile.id)
|
.bind(&profile.id)
|
||||||
.bind(&profile.username)
|
.bind(&profile.username)
|
||||||
.bind(profile.level)
|
.bind(profile.level)
|
||||||
.bind(profile.xp)
|
.bind(profile.xp)
|
||||||
.bind(&profile.game_id)
|
|
||||||
.bind(profile.created_at)
|
.bind(profile.created_at)
|
||||||
.bind(profile.updated_at)
|
.bind(profile.updated_at)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@@ -67,13 +59,7 @@ pub async fn add_xp_with_levelup(
|
|||||||
club_id: &str,
|
club_id: &str,
|
||||||
xp_to_add: i64,
|
xp_to_add: i64,
|
||||||
) -> AppResult<Vec<LevelUpEvent>> {
|
) -> AppResult<Vec<LevelUpEvent>> {
|
||||||
let profile = sqlx::query_as::<_, Profile>(
|
let profile = get_active_profile(pool).await?;
|
||||||
"SELECT id, username, level, xp, game_id, created_at, updated_at FROM profiles WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(profile_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| AppError::NotFound(format!("profile '{profile_id}' not found")))?;
|
|
||||||
let old_level = level_for_xp(profile.xp);
|
let old_level = level_for_xp(profile.xp);
|
||||||
let new_total_xp = profile.xp + xp_to_add;
|
let new_total_xp = profile.xp + xp_to_add;
|
||||||
let new_level = level_for_xp(new_total_xp);
|
let new_level = level_for_xp(new_total_xp);
|
||||||
|
|||||||
+60
-1183
File diff suppressed because it is too large
Load Diff
+7
-14
@@ -1,6 +1,6 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
db::Pool,
|
db::Pool,
|
||||||
error::{AppError, AppResult},
|
error::AppResult,
|
||||||
models::season::{Season, SeasonEndSummary, SeasonHistoryEntry, SeasonResult},
|
models::season::{Season, SeasonEndSummary, SeasonHistoryEntry, SeasonResult},
|
||||||
services::{club, pack},
|
services::{club, pack},
|
||||||
};
|
};
|
||||||
@@ -20,11 +20,7 @@ pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult<Season> {
|
|||||||
.bind(&now)
|
.bind(&now)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
fetch(pool, profile_id).await?.ok_or_else(|| {
|
Ok(fetch(pool, profile_id).await?.expect("just inserted"))
|
||||||
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>> {
|
||||||
@@ -70,11 +66,7 @@ pub async fn record_match(
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let season = fetch(pool, profile_id).await?.ok_or_else(|| {
|
let season = fetch(pool, profile_id).await?.expect("season must exist");
|
||||||
AppError::Internal(anyhow::anyhow!(
|
|
||||||
"season row missing after record_match update"
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if !season.is_complete() {
|
if !season.is_complete() {
|
||||||
return Ok((season, None));
|
return Ok((season, None));
|
||||||
@@ -109,6 +101,9 @@ pub async fn record_match(
|
|||||||
// Grant rewards
|
// Grant rewards
|
||||||
club::add_coins(pool, club_id, coins).await?;
|
club::add_coins(pool, club_id, coins).await?;
|
||||||
if let Some(pack_def) = pack_id {
|
if let Some(pack_def) = pack_id {
|
||||||
|
let dummy_pack_id = Uuid::new_v4().to_string();
|
||||||
|
// Grant via pack system so it shows in inventory
|
||||||
|
let _ = dummy_pack_id; // will use grant_pack instead
|
||||||
pack::grant_pack(pool, club_id, pack_def).await?;
|
pack::grant_pack(pool, club_id, pack_def).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,9 +144,7 @@ pub async fn record_match(
|
|||||||
pack_awarded: pack_id.map(String::from),
|
pack_awarded: pack_id.map(String::from),
|
||||||
};
|
};
|
||||||
|
|
||||||
let updated = fetch(pool, profile_id).await?.ok_or_else(|| {
|
let updated = fetch(pool, profile_id).await?.expect("season must exist");
|
||||||
AppError::Internal(anyhow::anyhow!("season row missing after season rollover"))
|
|
||||||
})?;
|
|
||||||
Ok((updated, Some(summary)))
|
Ok((updated, Some(summary)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-413
@@ -3,7 +3,6 @@ use crate::{
|
|||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::{
|
models::{
|
||||||
card::{CardDefinition, OwnedCard},
|
card::{CardDefinition, OwnedCard},
|
||||||
game_ext::{GameEntityExt, OpaqueExtensionWrite},
|
|
||||||
squad::{
|
squad::{
|
||||||
SaveSquadRequest, SlotAssignment, Squad, SquadPlayer, SquadPlayerInput, SquadReplaced,
|
SaveSquadRequest, SlotAssignment, Squad, SquadPlayer, SquadPlayerInput, SquadReplaced,
|
||||||
SquadReplacement,
|
SquadReplacement,
|
||||||
@@ -11,7 +10,6 @@ use crate::{
|
|||||||
},
|
},
|
||||||
services::{
|
services::{
|
||||||
card_db::CardDb,
|
card_db::CardDb,
|
||||||
game_ext,
|
|
||||||
squad_rules::{
|
squad_rules::{
|
||||||
ClientReportedEvaluation, DefaultSquadRules, SquadPlayerCard, SquadRules, SquadSnapshot,
|
ClientReportedEvaluation, DefaultSquadRules, SquadPlayerCard, SquadRules, SquadSnapshot,
|
||||||
},
|
},
|
||||||
@@ -74,7 +72,6 @@ async fn get_players(pool: &Pool, squad_id: &str) -> AppResult<Vec<SquadPlayer>>
|
|||||||
pub async fn validate_formation(
|
pub async fn validate_formation(
|
||||||
pool: &Pool,
|
pool: &Pool,
|
||||||
card_db: &CardDb,
|
card_db: &CardDb,
|
||||||
club_id: &str,
|
|
||||||
players: &[SquadPlayerInput],
|
players: &[SquadPlayerInput],
|
||||||
) -> AppResult<()> {
|
) -> AppResult<()> {
|
||||||
let starters: Vec<&SquadPlayerInput> = players.iter().filter(|p| !p.is_on_bench).collect();
|
let starters: Vec<&SquadPlayerInput> = players.iter().filter(|p| !p.is_on_bench).collect();
|
||||||
@@ -89,13 +86,12 @@ 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>(
|
||||||
"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 = ?",
|
"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(&sp.owned_card_id)
|
.bind(&sp.owned_card_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", 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" {
|
||||||
@@ -224,23 +220,15 @@ pub async fn calculate_chemistry(
|
|||||||
/// Validation happens BEFORE any write, so a rejected replacement leaves the
|
/// Validation happens BEFORE any write, so a rejected replacement leaves the
|
||||||
/// existing squad exactly as it was. Everything that does write happens inside
|
/// existing squad exactly as it was. Everything that does write happens inside
|
||||||
/// one transaction.
|
/// one transaction.
|
||||||
#[allow(clippy::too_many_arguments)]
|
pub async fn replace_squad(
|
||||||
async fn replace_squad_inner(
|
|
||||||
pool: &Pool,
|
pool: &Pool,
|
||||||
card_db: &CardDb,
|
card_db: &CardDb,
|
||||||
rules: &dyn SquadRules,
|
rules: &dyn SquadRules,
|
||||||
game_id: Option<&str>,
|
|
||||||
club_id: &str,
|
club_id: &str,
|
||||||
squad_id: Option<&str>,
|
squad_id: Option<&str>,
|
||||||
replacement: &SquadReplacement,
|
replacement: &SquadReplacement,
|
||||||
client_reported: &ClientReportedEvaluation,
|
client_reported: &ClientReportedEvaluation,
|
||||||
ext: Option<&OpaqueExtensionWrite>,
|
|
||||||
) -> AppResult<SquadReplaced> {
|
) -> AppResult<SquadReplaced> {
|
||||||
// Generic bounds on the opaque extension, before any write (fail fast, no
|
|
||||||
// partial state). Core guards size only — the adapter owns payload meaning.
|
|
||||||
if let Some(ext) = ext {
|
|
||||||
ext.validate().map_err(AppError::BadRequest)?;
|
|
||||||
}
|
|
||||||
// ── validate before touching anything ────────────────────────────────
|
// ── validate before touching anything ────────────────────────────────
|
||||||
let mut seen: HashSet<&str> = HashSet::new();
|
let mut seen: HashSet<&str> = HashSet::new();
|
||||||
let mut slots_seen: HashSet<i64> = HashSet::new();
|
let mut slots_seen: HashSet<i64> = HashSet::new();
|
||||||
@@ -369,35 +357,6 @@ async fn replace_squad_inner(
|
|||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Fingerprint the COMMITTED canonical state (server-computed; never a
|
|
||||||
// client/adapter value) and, atomically in this same tx, persist the opaque
|
|
||||||
// game extension anchored to it. Canonical squad + extension commit together
|
|
||||||
// or not at all — no split-brain, no distributed protocol.
|
|
||||||
let canonical_fingerprint = squad_fingerprint(
|
|
||||||
&squad_id,
|
|
||||||
&squad.formation,
|
|
||||||
resolved
|
|
||||||
.iter()
|
|
||||||
.map(|(s, o)| (s.slot, o.id.as_str(), s.is_captain, s.is_on_bench)),
|
|
||||||
);
|
|
||||||
if let Some(ext) = ext {
|
|
||||||
let gid = game_id.expect("game_id is required whenever an extension is written");
|
|
||||||
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(gid)
|
|
||||||
.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?;
|
tx.commit().await?;
|
||||||
|
|
||||||
// ── evaluate with the game's rules, never with the client's numbers ──
|
// ── evaluate with the game's rules, never with the client's numbers ──
|
||||||
@@ -429,7 +388,6 @@ async fn replace_squad_inner(
|
|||||||
slots_written: resolved.len(),
|
slots_written: resolved.len(),
|
||||||
evaluation,
|
evaluation,
|
||||||
client_disagreements,
|
client_disagreements,
|
||||||
canonical_fingerprint,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,125 +397,6 @@ struct SlotAssignmentRef {
|
|||||||
is_on_bench: bool,
|
is_on_bench: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace a squad's slots atomically (no game extension).
|
|
||||||
pub async fn replace_squad(
|
|
||||||
pool: &Pool,
|
|
||||||
card_db: &CardDb,
|
|
||||||
rules: &dyn SquadRules,
|
|
||||||
club_id: &str,
|
|
||||||
squad_id: Option<&str>,
|
|
||||||
replacement: &SquadReplacement,
|
|
||||||
client_reported: &ClientReportedEvaluation,
|
|
||||||
) -> AppResult<SquadReplaced> {
|
|
||||||
replace_squad_inner(
|
|
||||||
pool,
|
|
||||||
card_db,
|
|
||||||
rules,
|
|
||||||
None,
|
|
||||||
club_id,
|
|
||||||
squad_id,
|
|
||||||
replacement,
|
|
||||||
client_reported,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replace a squad AND persist an opaque game extension in ONE transaction, so
|
|
||||||
/// the canonical squad and its game-only round-trip state can never split-brain.
|
|
||||||
/// The extension is anchored to the committed squad by a server-computed
|
|
||||||
/// fingerprint; Core never interprets the payload.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub async fn replace_squad_with_extension(
|
|
||||||
pool: &Pool,
|
|
||||||
card_db: &CardDb,
|
|
||||||
rules: &dyn SquadRules,
|
|
||||||
game_id: &str,
|
|
||||||
club_id: &str,
|
|
||||||
squad_id: Option<&str>,
|
|
||||||
replacement: &SquadReplacement,
|
|
||||||
client_reported: &ClientReportedEvaluation,
|
|
||||||
ext: &OpaqueExtensionWrite,
|
|
||||||
) -> AppResult<SquadReplaced> {
|
|
||||||
replace_squad_inner(
|
|
||||||
pool,
|
|
||||||
card_db,
|
|
||||||
rules,
|
|
||||||
Some(game_id),
|
|
||||||
club_id,
|
|
||||||
squad_id,
|
|
||||||
replacement,
|
|
||||||
client_reported,
|
|
||||||
Some(ext),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deterministic, order-stable fingerprint of a squad's canonical state. Server-
|
|
||||||
/// computed; non-cryptographic (FNV-1a-64) — a stale-extension guard, not a
|
|
||||||
/// security boundary. The encoding is sorted + delimited so it never depends on
|
|
||||||
/// row/iteration order.
|
|
||||||
pub(crate) fn squad_fingerprint<'a>(
|
|
||||||
squad_id: &str,
|
|
||||||
formation: &str,
|
|
||||||
slots: impl Iterator<Item = (i64, &'a str, bool, bool)>,
|
|
||||||
) -> String {
|
|
||||||
let mut items: Vec<String> = slots
|
|
||||||
.map(|(slot, owned, cap, bench)| format!("{slot}:{owned}:{}:{}", cap as u8, bench as u8))
|
|
||||||
.collect();
|
|
||||||
items.sort();
|
|
||||||
let canon = format!("v1|{squad_id}|{formation}|{}", items.join(";"));
|
|
||||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
|
||||||
for b in canon.as_bytes() {
|
|
||||||
h ^= *b as u64;
|
|
||||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
|
||||||
}
|
|
||||||
format!("{h:016x}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Freshness of a squad's opaque extension vs the current canonical squad.
|
|
||||||
pub enum SquadExtState {
|
|
||||||
Fresh(GameEntityExt),
|
|
||||||
Stale {
|
|
||||||
stored: GameEntityExt,
|
|
||||||
current_fingerprint: String,
|
|
||||||
},
|
|
||||||
Missing,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read a club's active squad, its players, and its opaque game extension for
|
|
||||||
/// `namespace`, with an explicit freshness verdict. NEVER silently projects a
|
|
||||||
/// stale blob — the caller decides policy on `Stale`/`Missing`.
|
|
||||||
pub async fn read_squad_with_ext(
|
|
||||||
pool: &Pool,
|
|
||||||
game_id: &str,
|
|
||||||
club_id: &str,
|
|
||||||
namespace: &str,
|
|
||||||
) -> AppResult<(Squad, Vec<SquadPlayer>, SquadExtState)> {
|
|
||||||
let (squad, players) = get_squad(pool, club_id).await?;
|
|
||||||
let current = squad_fingerprint(
|
|
||||||
&squad.id,
|
|
||||||
&squad.formation,
|
|
||||||
players.iter().map(|p| {
|
|
||||||
(
|
|
||||||
p.position_index,
|
|
||||||
p.owned_card_id.as_str(),
|
|
||||||
p.is_captain,
|
|
||||||
p.is_on_bench,
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
let state = match game_ext::get_ext(pool, game_id, "squad", &squad.id, namespace).await? {
|
|
||||||
None => SquadExtState::Missing,
|
|
||||||
Some(row) if row.canonical_fingerprint == current => SquadExtState::Fresh(row),
|
|
||||||
Some(row) => SquadExtState::Stale {
|
|
||||||
stored: row,
|
|
||||||
current_fingerprint: current,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Ok((squad, players, state))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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
|
||||||
@@ -888,253 +727,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(out.evaluation.rules, "openfut-default-v2");
|
assert_eq!(out.evaluation.rules, "openfut-default-v2");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── opaque game-extension (co-located, single-transaction) ──────────────
|
|
||||||
|
|
||||||
const NS: &str = "fifa17.squad.v1";
|
|
||||||
|
|
||||||
fn ext(payload: &str) -> OpaqueExtensionWrite {
|
|
||||||
OpaqueExtensionWrite {
|
|
||||||
namespace: NS.into(),
|
|
||||||
schema_version: 1,
|
|
||||||
payload: payload.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn replace_ext(
|
|
||||||
pool: &Pool,
|
|
||||||
db: &CardDb,
|
|
||||||
game: &str,
|
|
||||||
club: &str,
|
|
||||||
id: Option<&str>,
|
|
||||||
slots: Vec<SlotAssignment>,
|
|
||||||
payload: &str,
|
|
||||||
) -> AppResult<SquadReplaced> {
|
|
||||||
replace_squad_with_extension(
|
|
||||||
pool,
|
|
||||||
db,
|
|
||||||
&DefaultSquadRules,
|
|
||||||
game,
|
|
||||||
club,
|
|
||||||
id,
|
|
||||||
&SquadReplacement {
|
|
||||||
name: Some("S".into()),
|
|
||||||
formation: Some("4-4-2".into()),
|
|
||||||
slots,
|
|
||||||
},
|
|
||||||
&ClientReportedEvaluation::default(),
|
|
||||||
&ext(payload),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn squad_and_extension_commit_atomically_and_read_fresh() {
|
|
||||||
let (pool, db) = fixture().await;
|
|
||||||
let out = replace_ext(
|
|
||||||
&pool,
|
|
||||||
&db,
|
|
||||||
"fifa17",
|
|
||||||
"club-a",
|
|
||||||
None,
|
|
||||||
vec![slot("card-1", 0)],
|
|
||||||
"{\"custom\":[1,2,3]}",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(!out.canonical_fingerprint.is_empty());
|
|
||||||
|
|
||||||
let (_s, _p, state) = read_squad_with_ext(&pool, "fifa17", "club-a", NS)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
match state {
|
|
||||||
SquadExtState::Fresh(row) => {
|
|
||||||
assert_eq!(row.payload, "{\"custom\":[1,2,3]}");
|
|
||||||
assert_eq!(row.schema_version, 1);
|
|
||||||
assert_eq!(row.canonical_fingerprint, out.canonical_fingerprint);
|
|
||||||
}
|
|
||||||
_ => panic!("expected Fresh extension"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn oversized_extension_rejected_with_no_partial_write() {
|
|
||||||
let (pool, db) = fixture().await;
|
|
||||||
let big = "x".repeat(crate::models::game_ext::MAX_EXT_PAYLOAD_BYTES + 1);
|
|
||||||
let err = replace_ext(
|
|
||||||
&pool,
|
|
||||||
&db,
|
|
||||||
"fifa17",
|
|
||||||
"club-a",
|
|
||||||
None,
|
|
||||||
vec![slot("card-1", 0)],
|
|
||||||
&big,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert!(
|
|
||||||
matches!(err, Err(AppError::BadRequest(_))),
|
|
||||||
"oversized payload must be rejected"
|
|
||||||
);
|
|
||||||
// Fail-fast before the tx: no squad was created.
|
|
||||||
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM squads WHERE club_id = 'club-a'")
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(n, 0, "rejected replacement leaves no partial squad");
|
|
||||||
let e: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM game_entity_ext")
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(e, 0, "no extension row written");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn fingerprint_is_deterministic_and_placement_sensitive() {
|
|
||||||
let (pool, db) = fixture().await;
|
|
||||||
let a = replace_ext(
|
|
||||||
&pool,
|
|
||||||
&db,
|
|
||||||
"fifa17",
|
|
||||||
"club-a",
|
|
||||||
None,
|
|
||||||
vec![slot("card-1", 0), slot("card-2", 1)],
|
|
||||||
"p",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
// Same placement again → identical fingerprint (idempotent, deterministic).
|
|
||||||
let b = replace_ext(
|
|
||||||
&pool,
|
|
||||||
&db,
|
|
||||||
"fifa17",
|
|
||||||
"club-a",
|
|
||||||
Some(&a.squad.id),
|
|
||||||
vec![slot("card-1", 0), slot("card-2", 1)],
|
|
||||||
"p",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(a.canonical_fingerprint, b.canonical_fingerprint);
|
|
||||||
// Different placement (swap the two slots) → different fingerprint.
|
|
||||||
let c = replace_ext(
|
|
||||||
&pool,
|
|
||||||
&db,
|
|
||||||
"fifa17",
|
|
||||||
"club-a",
|
|
||||||
Some(&a.squad.id),
|
|
||||||
vec![slot("card-1", 1), slot("card-2", 0)],
|
|
||||||
"p",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_ne!(a.canonical_fingerprint, c.canonical_fingerprint);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn stale_extension_is_detected_never_silently_fresh() {
|
|
||||||
let (pool, db) = fixture().await;
|
|
||||||
let first = replace_ext(
|
|
||||||
&pool,
|
|
||||||
&db,
|
|
||||||
"fifa17",
|
|
||||||
"club-a",
|
|
||||||
None,
|
|
||||||
vec![slot("card-1", 0)],
|
|
||||||
"p1",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
// A later plain replace (no extension) changes the canonical squad.
|
|
||||||
replace(
|
|
||||||
&pool,
|
|
||||||
&db,
|
|
||||||
"club-a",
|
|
||||||
Some(&first.squad.id),
|
|
||||||
vec![slot("card-2", 0)],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let (_s, _p, state) = read_squad_with_ext(&pool, "fifa17", "club-a", NS)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
match state {
|
|
||||||
SquadExtState::Stale {
|
|
||||||
stored,
|
|
||||||
current_fingerprint,
|
|
||||||
} => {
|
|
||||||
assert_eq!(stored.canonical_fingerprint, first.canonical_fingerprint);
|
|
||||||
assert_ne!(current_fingerprint, first.canonical_fingerprint);
|
|
||||||
}
|
|
||||||
_ => panic!("expected Stale extension after canonical squad changed"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn idempotent_repeat_does_not_duplicate_extension() {
|
|
||||||
let (pool, db) = fixture().await;
|
|
||||||
let a = replace_ext(
|
|
||||||
&pool,
|
|
||||||
&db,
|
|
||||||
"fifa17",
|
|
||||||
"club-a",
|
|
||||||
None,
|
|
||||||
vec![slot("card-1", 0)],
|
|
||||||
"same",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
replace_ext(
|
|
||||||
&pool,
|
|
||||||
&db,
|
|
||||||
"fifa17",
|
|
||||||
"club-a",
|
|
||||||
Some(&a.squad.id),
|
|
||||||
vec![slot("card-1", 0)],
|
|
||||||
"same",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let rows: i64 =
|
|
||||||
sqlx::query_scalar("SELECT COUNT(*) FROM game_entity_ext WHERE entity_id = ?")
|
|
||||||
.bind(&a.squad.id)
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(rows, 1, "identical repeat converges on one extension row");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn extension_is_scoped_by_game_and_namespace() {
|
|
||||||
let (pool, db) = fixture().await;
|
|
||||||
let out = replace_ext(
|
|
||||||
&pool,
|
|
||||||
&db,
|
|
||||||
"fifa17",
|
|
||||||
"club-a",
|
|
||||||
None,
|
|
||||||
vec![slot("card-1", 0)],
|
|
||||||
"p",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let sid = &out.squad.id;
|
|
||||||
assert!(game_ext::get_ext(&pool, "fifa17", "squad", sid, NS)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.is_some());
|
|
||||||
assert!(
|
|
||||||
game_ext::get_ext(&pool, "fifa17", "squad", sid, "other.ns")
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.is_none(),
|
|
||||||
"wrong namespace"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
game_ext::get_ext(&pool, "fifa23", "squad", sid, NS)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.is_none(),
|
|
||||||
"wrong game"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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, 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 = ?";
|
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 = ?";
|
||||||
|
|
||||||
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)
|
||||||
@@ -78,77 +77,6 @@ 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();
|
||||||
@@ -193,26 +121,6 @@ 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",
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
//! 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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
//! Content preflight: a real profile with owned players but a missing
|
|
||||||
//! CardDefinition must fail startup LOUDLY, never serve a silent empty club.
|
|
||||||
|
|
||||||
use axum::{
|
|
||||||
body::Body,
|
|
||||||
http::{Request, StatusCode},
|
|
||||||
};
|
|
||||||
use openfut_core::services::card_db::CardDb;
|
|
||||||
use tower::ServiceExt;
|
|
||||||
|
|
||||||
async fn fresh_pool() -> sqlx::SqlitePool {
|
|
||||||
let p = sqlx::sqlite::SqlitePoolOptions::new()
|
|
||||||
.max_connections(1)
|
|
||||||
.connect("sqlite::memory:")
|
|
||||||
.await
|
|
||||||
.expect("in-memory sqlite");
|
|
||||||
sqlx::migrate!("./migrations")
|
|
||||||
.run(&p)
|
|
||||||
.await
|
|
||||||
.expect("migrations");
|
|
||||||
p
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_profile(app: &axum::Router) {
|
|
||||||
let resp = app
|
|
||||||
.clone()
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri("/auth/local")
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.body(Body::from(r#"{"username":"CAGE"}"#))
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
resp.status(),
|
|
||||||
StatusCode::OK,
|
|
||||||
"auth/local should create a profile+club"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn insert_owned(pool: &sqlx::SqlitePool, id: &str, club_id: &str, card_id: &str) {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \
|
|
||||||
VALUES (?, ?, ?, 0, NULL, ?)",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.bind(club_id)
|
|
||||||
.bind(card_id)
|
|
||||||
.bind("2026-01-01T00:00:00Z")
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn preflight_fails_on_owned_card_missing_definition() {
|
|
||||||
let pool = fresh_pool().await;
|
|
||||||
// first build is fine: no owned cards yet.
|
|
||||||
let app = openfut_core::build_app(pool.clone(), "data").await.unwrap();
|
|
||||||
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-bogus", &club, "fifa17_definitely_missing_999999").await;
|
|
||||||
|
|
||||||
// second build must now fail preflight: one owned card references a def that
|
|
||||||
// is not loaded — must not silently serve an empty collection.
|
|
||||||
let err = openfut_core::build_app(pool.clone(), "data")
|
|
||||||
.await
|
|
||||||
.expect_err("preflight must fail on a missing definition");
|
|
||||||
let msg = format!("{err:#}");
|
|
||||||
assert!(
|
|
||||||
msg.contains("content preflight failed"),
|
|
||||||
"unexpected error: {msg}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn preflight_passes_when_owned_card_definition_is_loaded() {
|
|
||||||
let pool = fresh_pool().await;
|
|
||||||
// pick a definition that IS in the default data/cards catalog.
|
|
||||||
let valid_id = CardDb::load("data")
|
|
||||||
.unwrap()
|
|
||||||
.all()
|
|
||||||
.first()
|
|
||||||
.map(|c| c.id.clone())
|
|
||||||
.expect("data/cards must be non-empty");
|
|
||||||
|
|
||||||
let app = openfut_core::build_app(pool.clone(), "data").await.unwrap();
|
|
||||||
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-valid", &club, &valid_id).await;
|
|
||||||
|
|
||||||
let _app = openfut_core::build_app(pool.clone(), "data")
|
|
||||||
.await
|
|
||||||
.expect("preflight passes when the owned card's definition is loaded");
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
//! FIFA 17 development content pack + ownership seed (Commit 5).
|
|
||||||
//!
|
|
||||||
//! Proves: the dev pack is opt-in and isolated from default content; the seed
|
|
||||||
//! creates a `game_id=fifa17` profile/club and grants real Core `OwnedCard`s
|
|
||||||
//! (never FIFA wire ids); it is idempotent and leaves the default profile alone;
|
|
||||||
//! and the seeded inventory can exercise the retail `/club` filter + pagination.
|
|
||||||
|
|
||||||
use openfut_core::db::Pool;
|
|
||||||
use openfut_core::services::card_db::CardDb;
|
|
||||||
|
|
||||||
async fn pool() -> Pool {
|
|
||||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
|
||||||
.max_connections(1)
|
|
||||||
.connect("sqlite::memory:")
|
|
||||||
.await
|
|
||||||
.expect("in-memory sqlite");
|
|
||||||
sqlx::migrate!("./migrations")
|
|
||||||
.run(&pool)
|
|
||||||
.await
|
|
||||||
.expect("migrations");
|
|
||||||
pool
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dev_card_db() -> CardDb {
|
|
||||||
let mut db = CardDb::load("data").expect("default cards");
|
|
||||||
db.load_game_dev("data", "fifa17").expect("dev pack");
|
|
||||||
db
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Content isolation ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_load_never_contains_dev_pack() {
|
|
||||||
// The default global loader reads only data/cards — the dev pack under
|
|
||||||
// data/games/fifa17/dev must be invisible unless explicitly requested.
|
|
||||||
let default = CardDb::load("data").expect("default cards");
|
|
||||||
let leaked: Vec<_> = default
|
|
||||||
.cards
|
|
||||||
.keys()
|
|
||||||
.filter(|k| k.starts_with("fifa17_"))
|
|
||||||
.collect();
|
|
||||||
assert!(
|
|
||||||
leaked.is_empty(),
|
|
||||||
"default content must not include FIFA17 dev cards: {leaked:?}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!default.cards.is_empty(),
|
|
||||||
"default synthetic catalogue still loads"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn opt_in_load_adds_dev_pack_only() {
|
|
||||||
let default_n = CardDb::load("data").unwrap().cards.len();
|
|
||||||
let db = dev_card_db();
|
|
||||||
let dev: Vec<_> = db
|
|
||||||
.cards
|
|
||||||
.keys()
|
|
||||||
.filter(|k| k.starts_with("fifa17_"))
|
|
||||||
.collect();
|
|
||||||
assert_eq!(dev.len(), 32, "the curated dev pack is 32 definitions");
|
|
||||||
assert_eq!(
|
|
||||||
db.cards.len(),
|
|
||||||
default_n + 32,
|
|
||||||
"dev pack is additive; default content unchanged"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dev_definitions_carry_semantic_names_not_raw_ids() {
|
|
||||||
let db = dev_card_db();
|
|
||||||
for c in db.cards.values().filter(|c| c.id.starts_with("fifa17_")) {
|
|
||||||
// Semantic Core fields are names, never raw FIFA numeric entity ids.
|
|
||||||
assert!(
|
|
||||||
c.nation.parse::<i64>().is_err(),
|
|
||||||
"nation must be a name, got {:?}",
|
|
||||||
c.nation
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
c.league.parse::<i64>().is_err(),
|
|
||||||
"league must be a name: {:?}",
|
|
||||||
c.league
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
c.club.parse::<i64>().is_err(),
|
|
||||||
"club must be a name: {:?}",
|
|
||||||
c.club
|
|
||||||
);
|
|
||||||
assert!(!c.name.is_empty(), "every dev card has a player name");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Ownership seed ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn seed_grants_game_scoped_inventory_with_filter_coverage() {
|
|
||||||
let pool = pool().await;
|
|
||||||
let db = dev_card_db();
|
|
||||||
let r = openfut_core::seed::seed_fifa17_dev(&pool, &db)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(r.game_id, "fifa17");
|
|
||||||
assert!(!r.already_seeded);
|
|
||||||
assert_eq!(r.definitions_available, 32);
|
|
||||||
assert_eq!(r.owned_total, 33, "32 defs + 1 deliberate duplicate");
|
|
||||||
assert_eq!(r.unique_definitions, 32);
|
|
||||||
assert!(r.gold_over_one_page, "gold spans >1 page (22 > 11)");
|
|
||||||
assert!(r.gold > 11, "enough gold for pagination");
|
|
||||||
assert!(r.silver >= 1 && r.bronze >= 1, "quality spread");
|
|
||||||
assert!(r.positions.contains_key("GK"), "GK present");
|
|
||||||
assert!(r.positions.contains_key("ST"), "ST present");
|
|
||||||
assert!(r.distinct_leagues >= 2, "multiple leagues");
|
|
||||||
assert!(r.distinct_nations >= 2, "multiple nations");
|
|
||||||
assert!(r.max_same_club >= 2, "a same-club group for team filters");
|
|
||||||
|
|
||||||
// The seed created ONLY a fifa17 profile — the default (fifa23) profile and
|
|
||||||
// any synthetic inventory are untouched.
|
|
||||||
let games: Vec<(String,)> = sqlx::query_as("SELECT game_id FROM profiles")
|
|
||||||
.fetch_all(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
games,
|
|
||||||
vec![("fifa17".to_string(),)],
|
|
||||||
"only the fifa17 profile exists"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Every seeded owned card references a dev-pack definition (all renderable).
|
|
||||||
let orphans: i64 = sqlx::query_scalar(
|
|
||||||
"SELECT COUNT(*) FROM owned_cards o \
|
|
||||||
WHERE o.card_id LIKE 'fifa17_%' AND o.card_id NOT IN \
|
|
||||||
(SELECT card_id FROM owned_cards WHERE card_id LIKE 'fifa17_%')",
|
|
||||||
)
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(orphans, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn seed_is_idempotent_across_reruns() {
|
|
||||||
let pool = pool().await;
|
|
||||||
let db = dev_card_db();
|
|
||||||
let first = openfut_core::seed::seed_fifa17_dev(&pool, &db)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let second = openfut_core::seed::seed_fifa17_dev(&pool, &db)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(!first.already_seeded);
|
|
||||||
assert!(second.already_seeded, "second run sees existing ownership");
|
|
||||||
assert_eq!(first.owned_total, second.owned_total, "no duplicate grants");
|
|
||||||
|
|
||||||
let n: i64 =
|
|
||||||
sqlx::query_scalar("SELECT COUNT(*) FROM owned_cards WHERE card_id LIKE 'fifa17_%'")
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(n, 33, "row count stable after rerun");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn seed_creates_exactly_one_two_copy_definition() {
|
|
||||||
let pool = pool().await;
|
|
||||||
let db = dev_card_db();
|
|
||||||
openfut_core::seed::seed_fifa17_dev(&pool, &db)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
// Exactly one definition is owned twice (distinct owned ids, same card_id):
|
|
||||||
// the identity foundation for "two copies of one card" later.
|
|
||||||
let dupes: Vec<(String, i64)> = sqlx::query_as(
|
|
||||||
"SELECT card_id, COUNT(*) c FROM owned_cards WHERE card_id LIKE 'fifa17_%' \
|
|
||||||
GROUP BY card_id HAVING c > 1",
|
|
||||||
)
|
|
||||||
.fetch_all(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(dupes.len(), 1, "exactly one duplicated definition");
|
|
||||||
assert_eq!(dupes[0].1, 2, "owned twice");
|
|
||||||
}
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
//! Generic transactional profile import (services::import). These also serve as
|
|
||||||
//! the Core-level half of the migration mutation battery: each hostile input is
|
|
||||||
//! rejected BEFORE any partial write, and re-runs converge instead of duplicating.
|
|
||||||
|
|
||||||
use openfut_core::services::card_db::CardDb;
|
|
||||||
use openfut_core::services::import::{
|
|
||||||
apply_profile_import, ImportClub, ImportEntitlement, ImportExtension, ImportOwnedCard,
|
|
||||||
ImportProfile, ImportSlot, ImportSquad, ProfileImportRequest,
|
|
||||||
};
|
|
||||||
|
|
||||||
async fn fresh_pool() -> sqlx::SqlitePool {
|
|
||||||
let p = sqlx::sqlite::SqlitePoolOptions::new()
|
|
||||||
.max_connections(1)
|
|
||||||
.connect("sqlite::memory:")
|
|
||||||
.await
|
|
||||||
.expect("in-memory sqlite");
|
|
||||||
sqlx::migrate!("./migrations")
|
|
||||||
.run(&p)
|
|
||||||
.await
|
|
||||||
.expect("migrations");
|
|
||||||
p
|
|
||||||
}
|
|
||||||
|
|
||||||
fn valid_ids(n: usize) -> Vec<String> {
|
|
||||||
let db = CardDb::load("data").expect("load data catalog");
|
|
||||||
let ids: Vec<String> = db.all().iter().take(n).map(|c| c.id.clone()).collect();
|
|
||||||
assert!(ids.len() >= n, "data catalog too small for test");
|
|
||||||
ids
|
|
||||||
}
|
|
||||||
|
|
||||||
fn owned(ids: &[String]) -> Vec<ImportOwnedCard> {
|
|
||||||
ids.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, id)| ImportOwnedCard {
|
|
||||||
owned_item_id: format!("oc-{i}"),
|
|
||||||
card_id: id.clone(),
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn squad_over(owned: &[ImportOwnedCard]) -> ImportSquad {
|
|
||||||
ImportSquad {
|
|
||||||
formation: "f433".into(),
|
|
||||||
name: "OpenFUT".into(),
|
|
||||||
slots: owned
|
|
||||||
.iter()
|
|
||||||
.take(3)
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, o)| ImportSlot {
|
|
||||||
owned_item_id: o.owned_item_id.clone(),
|
|
||||||
position_index: i as i64,
|
|
||||||
is_captain: i == 0,
|
|
||||||
is_on_bench: false,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
extension: ImportExtension {
|
|
||||||
namespace: "fifa17.squad.v1".into(),
|
|
||||||
schema_version: 1,
|
|
||||||
payload: r#"{"custom":[]}"#.into(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn request(
|
|
||||||
game: &str,
|
|
||||||
fp: &str,
|
|
||||||
owned: Vec<ImportOwnedCard>,
|
|
||||||
squad: Option<ImportSquad>,
|
|
||||||
) -> ProfileImportRequest {
|
|
||||||
ProfileImportRequest {
|
|
||||||
source_fingerprint: fp.into(),
|
|
||||||
profile: ImportProfile {
|
|
||||||
username: format!("CAGE-{game}"),
|
|
||||||
game_id: game.into(),
|
|
||||||
},
|
|
||||||
club: ImportClub {
|
|
||||||
name: "OpenFUT".into(),
|
|
||||||
coins: 28_112_944,
|
|
||||||
},
|
|
||||||
owned,
|
|
||||||
squad,
|
|
||||||
entitlements: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn count(pool: &sqlx::SqlitePool, table: &str) -> i64 {
|
|
||||||
sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}"))
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn imports_profile_club_owned_and_squad_in_one_shot() {
|
|
||||||
let pool = fresh_pool().await;
|
|
||||||
let db = CardDb::load("data").unwrap();
|
|
||||||
let ids = valid_ids(5);
|
|
||||||
let ow = owned(&ids);
|
|
||||||
let sq = squad_over(&ow);
|
|
||||||
let req = request("g_happy", "fp-happy", ow, Some(sq));
|
|
||||||
|
|
||||||
let out = apply_profile_import(&pool, &db, &req)
|
|
||||||
.await
|
|
||||||
.expect("import");
|
|
||||||
assert!(matches!(
|
|
||||||
out,
|
|
||||||
openfut_core::services::import::ImportOutcome::Imported {
|
|
||||||
owned: 5,
|
|
||||||
squad_slots: 3
|
|
||||||
}
|
|
||||||
));
|
|
||||||
|
|
||||||
assert_eq!(count(&pool, "profiles").await, 1);
|
|
||||||
assert_eq!(count(&pool, "clubs").await, 1);
|
|
||||||
assert_eq!(count(&pool, "owned_cards").await, 5);
|
|
||||||
assert_eq!(count(&pool, "squad_players").await, 3);
|
|
||||||
// opaque extension persisted with a Core-computed fingerprint.
|
|
||||||
let fp: String =
|
|
||||||
sqlx::query_scalar("SELECT canonical_fingerprint FROM game_entity_ext LIMIT 1")
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(fp.len(), 16, "16-hex FNV fingerprint");
|
|
||||||
let stored_import_fp: String =
|
|
||||||
sqlx::query_scalar("SELECT import_fingerprint FROM profiles LIMIT 1")
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
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]
|
|
||||||
async fn rerun_same_fingerprint_is_idempotent_noop() {
|
|
||||||
let pool = fresh_pool().await;
|
|
||||||
let db = CardDb::load("data").unwrap();
|
|
||||||
let ids = valid_ids(4);
|
|
||||||
let mk = || {
|
|
||||||
request(
|
|
||||||
"g_rerun",
|
|
||||||
"fp-x",
|
|
||||||
owned(&ids),
|
|
||||||
Some(squad_over(&owned(&ids))),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
apply_profile_import(&pool, &db, &mk())
|
|
||||||
.await
|
|
||||||
.expect("first");
|
|
||||||
let out = apply_profile_import(&pool, &db, &mk())
|
|
||||||
.await
|
|
||||||
.expect("second");
|
|
||||||
assert_eq!(
|
|
||||||
out,
|
|
||||||
openfut_core::services::import::ImportOutcome::AlreadyImported
|
|
||||||
);
|
|
||||||
// no duplication.
|
|
||||||
assert_eq!(count(&pool, "profiles").await, 1);
|
|
||||||
assert_eq!(count(&pool, "owned_cards").await, 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn different_fingerprint_on_imported_game_fails() {
|
|
||||||
let pool = fresh_pool().await;
|
|
||||||
let db = CardDb::load("data").unwrap();
|
|
||||||
let ids = valid_ids(3);
|
|
||||||
apply_profile_import(&pool, &db, &request("g_diff", "fp-a", owned(&ids), None))
|
|
||||||
.await
|
|
||||||
.expect("first");
|
|
||||||
let err = apply_profile_import(&pool, &db, &request("g_diff", "fp-b", owned(&ids), None))
|
|
||||||
.await
|
|
||||||
.expect_err("second, different fingerprint");
|
|
||||||
assert!(format!("{err:#}").contains("different source"), "{err:#}");
|
|
||||||
assert_eq!(count(&pool, "profiles").await, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn missing_definition_fails_preflight_with_no_writes() {
|
|
||||||
let pool = fresh_pool().await;
|
|
||||||
let db = CardDb::load("data").unwrap();
|
|
||||||
let mut ow = owned(&valid_ids(2));
|
|
||||||
ow.push(ImportOwnedCard {
|
|
||||||
owned_item_id: "oc-bad".into(),
|
|
||||||
card_id: "fifa17_definitely_absent_999999".into(),
|
|
||||||
});
|
|
||||||
let err = apply_profile_import(&pool, &db, &request("g_miss", "fp", ow, None))
|
|
||||||
.await
|
|
||||||
.expect_err("missing definition must fail");
|
|
||||||
assert!(
|
|
||||||
format!("{err:#}").contains("definition preflight failed"),
|
|
||||||
"{err:#}"
|
|
||||||
);
|
|
||||||
// preflight is before the tx: nothing was written.
|
|
||||||
assert_eq!(count(&pool, "profiles").await, 0);
|
|
||||||
assert_eq!(count(&pool, "owned_cards").await, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn squad_slot_not_in_ownership_fails() {
|
|
||||||
let pool = fresh_pool().await;
|
|
||||||
let db = CardDb::load("data").unwrap();
|
|
||||||
let ids = valid_ids(3);
|
|
||||||
let ow = owned(&ids);
|
|
||||||
let mut sq = squad_over(&ow);
|
|
||||||
sq.slots[1].owned_item_id = "oc-not-owned".into();
|
|
||||||
let err = apply_profile_import(&pool, &db, &request("g_sq", "fp", ow, Some(sq)))
|
|
||||||
.await
|
|
||||||
.expect_err("squad slot not owned must fail");
|
|
||||||
assert!(format!("{err:#}").contains("all-or-nothing"), "{err:#}");
|
|
||||||
assert_eq!(count(&pool, "profiles").await, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn duplicate_owned_item_id_fails() {
|
|
||||||
let pool = fresh_pool().await;
|
|
||||||
let db = CardDb::load("data").unwrap();
|
|
||||||
let ids = valid_ids(2);
|
|
||||||
let mut ow = owned(&ids);
|
|
||||||
ow[1].owned_item_id = ow[0].owned_item_id.clone();
|
|
||||||
let err = apply_profile_import(&pool, &db, &request("g_dup", "fp", ow, None))
|
|
||||||
.await
|
|
||||||
.expect_err("duplicate OwnedItemId must fail");
|
|
||||||
assert!(
|
|
||||||
format!("{err:#}").contains("duplicate OwnedItemId"),
|
|
||||||
"{err:#}"
|
|
||||||
);
|
|
||||||
assert_eq!(count(&pool, "profiles").await, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn non_imported_profile_is_not_clobbered() {
|
|
||||||
let pool = fresh_pool().await;
|
|
||||||
let db = CardDb::load("data").unwrap();
|
|
||||||
// simulate a gameplay/dev profile with NO import_fingerprint for this game.
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at) \
|
|
||||||
VALUES ('p0','someone',1,0,'g_clobber','2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')",
|
|
||||||
)
|
|
||||||
.execute(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let ids = valid_ids(2);
|
|
||||||
let err = apply_profile_import(&pool, &db, &request("g_clobber", "fp", owned(&ids), None))
|
|
||||||
.await
|
|
||||||
.expect_err("must refuse to clobber a non-imported profile");
|
|
||||||
assert!(format!("{err:#}").contains("non-imported"), "{err:#}");
|
|
||||||
assert_eq!(count(&pool, "owned_cards").await, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn empty_owned_fails() {
|
|
||||||
let pool = fresh_pool().await;
|
|
||||||
let db = CardDb::load("data").unwrap();
|
|
||||||
let err = apply_profile_import(&pool, &db, &request("g_empty", "fp", vec![], None))
|
|
||||||
.await
|
|
||||||
.expect_err("empty owned must fail");
|
|
||||||
assert!(format!("{err:#}").contains("zero owned cards"), "{err:#}");
|
|
||||||
}
|
|
||||||
+5
-651
@@ -269,68 +269,6 @@ async fn test_sbc_submit_with_bronze_cards() {
|
|||||||
assert!(result["reward"].is_object());
|
assert!(result["reward"].is_object());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_sbc_rejects_duplicate_cards() {
|
|
||||||
// Regression: a single owned card repeated to fill an SBC must be rejected. Before
|
|
||||||
// the dedup guard the same id resolved N times, passed validation, and granted the
|
|
||||||
// reward while only one card was consumed (free-reward exploit).
|
|
||||||
let app = build_test_app().await;
|
|
||||||
auth(&app, "SBCDupePlayer").await;
|
|
||||||
|
|
||||||
let (s, _) = json_post(
|
|
||||||
&app,
|
|
||||||
"/packs/buy",
|
|
||||||
serde_json::json!({ "pack_definition_id": "bronze_pack" }),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(s, StatusCode::OK);
|
|
||||||
let (_, packs_json) = json_get(&app, "/packs").await;
|
|
||||||
let pack_id = packs_json["packs"]
|
|
||||||
.as_array()
|
|
||||||
.unwrap()
|
|
||||||
.iter()
|
|
||||||
.find(|p| p["definition_id"] == "bronze_pack")
|
|
||||||
.expect("bronze pack in inventory")["pack_id"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap()
|
|
||||||
.to_string();
|
|
||||||
let (s, _) = json_post(
|
|
||||||
&app,
|
|
||||||
&format!("/packs/open/{pack_id}"),
|
|
||||||
serde_json::json!({}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(s, StatusCode::OK);
|
|
||||||
|
|
||||||
let (_, coll) = json_get(&app, "/collection").await;
|
|
||||||
let one_card = coll["collection"].as_array().unwrap()[0]["owned_card_id"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let (s, result) = json_post(
|
|
||||||
&app,
|
|
||||||
"/sbc/submit",
|
|
||||||
serde_json::json!({
|
|
||||||
"sbc_id": "sbc_bronze_upgrade",
|
|
||||||
"owned_card_ids": vec![one_card; 11]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(
|
|
||||||
s,
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"duplicate submission must be rejected: {result}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
result["error"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.contains("duplicate"),
|
|
||||||
"expected a duplicate-card error, got: {result}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_settings_read_write() {
|
async fn test_settings_read_write() {
|
||||||
let app = build_test_app().await;
|
let app = build_test_app().await;
|
||||||
@@ -751,7 +689,7 @@ async fn test_draft_pick_advances_session() {
|
|||||||
assert_eq!(pick1["status"], "active");
|
assert_eq!(pick1["status"], "active");
|
||||||
assert_eq!(pick1["progress"]["filled"], 1);
|
assert_eq!(pick1["progress"]["filled"], 1);
|
||||||
assert_eq!(pick1["current_position"], "RB");
|
assert_eq!(pick1["current_position"], "RB");
|
||||||
assert!(!pick1["candidates"].as_array().unwrap().is_empty());
|
assert!(pick1["candidates"].as_array().unwrap().len() >= 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1611,18 +1549,10 @@ async fn test_rivals_reward_increments_week_counter() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// First claim succeeds
|
json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await;
|
||||||
let (s, json) = json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await;
|
let (s, json) = json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await;
|
||||||
assert_eq!(s, StatusCode::OK, "{json}");
|
assert_eq!(s, StatusCode::OK, "{json}");
|
||||||
assert_eq!(json["week_number"], 1, "first claim should be week 1");
|
assert_eq!(json["week_number"], 2, "second claim should be week 2");
|
||||||
|
|
||||||
// Immediate re-claim is blocked by the 24-hour cooldown
|
|
||||||
let (s, _) = json_post(&app, "/rivals/claim-weekly", serde_json::json!({})).await;
|
|
||||||
assert_eq!(
|
|
||||||
s,
|
|
||||||
StatusCode::CONFLICT,
|
|
||||||
"re-claim within 24h should be rejected"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Phase 15: Pack Store ──────────────────────────────────────────────────────
|
// ── Phase 15: Pack Store ──────────────────────────────────────────────────────
|
||||||
@@ -2038,12 +1968,7 @@ async fn test_auth_reset_clears_profile() {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
let (status, json) = json_post(
|
let (status, json) = json_post(&app, "/auth/reset", serde_json::json!({})).await;
|
||||||
&app,
|
|
||||||
"/auth/reset",
|
|
||||||
serde_json::json!({ "confirm": "reset" }),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(status, StatusCode::OK, "{json}");
|
assert_eq!(status, StatusCode::OK, "{json}");
|
||||||
assert_eq!(json["reset"], true);
|
assert_eq!(json["reset"], true);
|
||||||
|
|
||||||
@@ -2065,12 +1990,7 @@ async fn test_auth_reset_allows_new_profile() {
|
|||||||
let app = build_test_app().await;
|
let app = build_test_app().await;
|
||||||
auth(&app, "FirstProfile").await;
|
auth(&app, "FirstProfile").await;
|
||||||
|
|
||||||
json_post(
|
json_post(&app, "/auth/reset", serde_json::json!({})).await;
|
||||||
&app,
|
|
||||||
"/auth/reset",
|
|
||||||
serde_json::json!({ "confirm": "reset" }),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Should be able to create a new profile after reset
|
// Should be able to create a new profile after reset
|
||||||
let (status, json) = json_post(
|
let (status, json) = json_post(
|
||||||
@@ -2637,569 +2557,3 @@ async fn test_owned_query_parameter_order_invariance() {
|
|||||||
);
|
);
|
||||||
assert_eq!(a["total"], b["total"]);
|
assert_eq!(a["total"], b["total"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn json_put(app: &axum::Router, uri: &str, payload: Value) -> (StatusCode, Value) {
|
|
||||||
let resp = app
|
|
||||||
.clone()
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.method("PUT")
|
|
||||||
.uri(uri)
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.body(Body::from(payload.to_string()))
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let status = resp.status();
|
|
||||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
(status, serde_json::from_slice(&body).unwrap())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_squad_ext_replace_read_roundtrip_and_idempotency() {
|
|
||||||
let app = build_test_app().await;
|
|
||||||
auth(&app, "SquadExtUser").await;
|
|
||||||
|
|
||||||
// Owned cards from the starter pack.
|
|
||||||
let (_, packs) = json_get(&app, "/packs").await;
|
|
||||||
let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap().to_string();
|
|
||||||
json_post(
|
|
||||||
&app,
|
|
||||||
&format!("/packs/open/{pack_id}"),
|
|
||||||
serde_json::json!({}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let (_, coll) = json_get(&app, "/collection").await;
|
|
||||||
let ids: Vec<String> = coll["collection"]
|
|
||||||
.as_array()
|
|
||||||
.unwrap()
|
|
||||||
.iter()
|
|
||||||
.take(2)
|
|
||||||
.map(|c| c["owned_card_id"].as_str().unwrap().to_string())
|
|
||||||
.collect();
|
|
||||||
assert!(ids.len() >= 2, "starter pack should yield >=2 owned cards");
|
|
||||||
|
|
||||||
let payload = "{\"custom\":\"[1,2,3]\",\"kit_numbers\":{}}";
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"name": "OpenFUT",
|
|
||||||
"formation": "f442",
|
|
||||||
"slots": [
|
|
||||||
{"owned_card_id": ids[0], "slot": 0, "is_captain": true, "is_on_bench": false},
|
|
||||||
{"owned_card_id": ids[1], "slot": 1, "is_captain": false, "is_on_bench": false},
|
|
||||||
],
|
|
||||||
"client_reported": {
|
|
||||||
"client_reported_chemistry": 52,
|
|
||||||
"client_reported_rating": 90,
|
|
||||||
"client_reported_star_rating": 90
|
|
||||||
},
|
|
||||||
"extension": {"namespace": "fifa17.squad", "schema_version": 1, "payload": payload},
|
|
||||||
});
|
|
||||||
|
|
||||||
let (s, put) = json_put(&app, "/squad/replace", body.clone()).await;
|
|
||||||
assert_eq!(s, StatusCode::OK, "{put}");
|
|
||||||
assert_eq!(put["slots_written"], 2);
|
|
||||||
let fp = put["canonical_fingerprint"].as_str().unwrap().to_string();
|
|
||||||
|
|
||||||
// Read the canonical squad + opaque extension back: Fresh, payload verbatim.
|
|
||||||
let (s, ext) = json_get(&app, "/squad/ext?namespace=fifa17.squad").await;
|
|
||||||
assert_eq!(s, StatusCode::OK, "{ext}");
|
|
||||||
assert_eq!(ext["extension"]["state"], "fresh");
|
|
||||||
assert_eq!(
|
|
||||||
ext["extension"]["payload"], payload,
|
|
||||||
"opaque payload round-trips verbatim"
|
|
||||||
);
|
|
||||||
assert_eq!(ext["extension"]["schema_version"], 1);
|
|
||||||
assert_eq!(ext["extension"]["stored_fingerprint"], fp);
|
|
||||||
assert_eq!(ext["squad"]["formation"], "f442");
|
|
||||||
assert_eq!(ext["players"].as_array().unwrap().len(), 2);
|
|
||||||
|
|
||||||
// Idempotent: an identical replacement converges to the same fingerprint.
|
|
||||||
let (s2, put2) = json_put(&app, "/squad/replace", body).await;
|
|
||||||
assert_eq!(s2, StatusCode::OK);
|
|
||||||
assert_eq!(
|
|
||||||
put2["canonical_fingerprint"], fp,
|
|
||||||
"identical PUT is idempotent"
|
|
||||||
);
|
|
||||||
|
|
||||||
// A different namespace has no stored extension: Missing, never fabricated.
|
|
||||||
let (_, other) = json_get(&app, "/squad/ext?namespace=other.ns").await;
|
|
||||||
assert_eq!(other["extension"]["state"], "missing");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────── economy HTTP boundary ──────────────────────────
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_economy_balance_and_reward() {
|
|
||||||
let app = build_test_app().await;
|
|
||||||
auth(&app, "econ-a").await;
|
|
||||||
let (st, bal) = json_get(&app, "/economy/balance").await;
|
|
||||||
assert_eq!(st, StatusCode::OK);
|
|
||||||
assert_eq!(bal["balance"], 5000);
|
|
||||||
let (st, r) = json_post(
|
|
||||||
&app,
|
|
||||||
"/economy/grant-reward",
|
|
||||||
serde_json::json!({"amount": 1000}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(st, StatusCode::OK);
|
|
||||||
assert_eq!(r["balance"], 6000);
|
|
||||||
let (_, club) = json_get(&app, "/club").await;
|
|
||||||
assert_eq!(club["coins"], 6000);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_economy_purchase_and_redeem_entitlement() {
|
|
||||||
let app = build_test_app().await;
|
|
||||||
auth(&app, "econ-b").await;
|
|
||||||
let (st, buy) = json_post(
|
|
||||||
&app,
|
|
||||||
"/economy/purchase-entitlement",
|
|
||||||
serde_json::json!({"cost": 400, "definition_id": "pack-x"}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(st, StatusCode::OK);
|
|
||||||
assert_eq!(buy["balance"], 4600);
|
|
||||||
let ent_id = buy["entitlement_id"].as_str().unwrap().to_string();
|
|
||||||
let count_pack_x = |ents: &Value| {
|
|
||||||
ents.as_array()
|
|
||||||
.unwrap()
|
|
||||||
.iter()
|
|
||||||
.filter(|e| e["definition_id"] == "pack-x")
|
|
||||||
.count()
|
|
||||||
};
|
|
||||||
let (_, ents) = json_get(&app, "/economy/entitlements").await;
|
|
||||||
// The club also has a starter pack; exactly one purchased "pack-x" is present.
|
|
||||||
assert_eq!(count_pack_x(&ents), 1);
|
|
||||||
let (st, redeem) = json_post(
|
|
||||||
&app,
|
|
||||||
"/economy/redeem-entitlement",
|
|
||||||
serde_json::json!({"entitlement_id": ent_id, "items": [{"item_id": "inst-1", "card_id": "def-1"}]}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(st, StatusCode::OK);
|
|
||||||
assert_eq!(redeem["definition_id"], "pack-x");
|
|
||||||
let (_, ents2) = json_get(&app, "/economy/entitlements").await;
|
|
||||||
assert_eq!(count_pack_x(&ents2), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_economy_purchase_item_and_sell() {
|
|
||||||
let app = build_test_app().await;
|
|
||||||
auth(&app, "econ-c").await;
|
|
||||||
let (st, buy) = json_post(
|
|
||||||
&app,
|
|
||||||
"/economy/purchase-item",
|
|
||||||
serde_json::json!({"cost": 500, "item_id": "mkt-1", "card_id": "def-9"}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(st, StatusCode::OK);
|
|
||||||
assert_eq!(buy["balance"], 4500);
|
|
||||||
let (st, sell) = json_post(
|
|
||||||
&app,
|
|
||||||
"/economy/sell-item",
|
|
||||||
serde_json::json!({"item_id": "mkt-1", "price": 200}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(st, StatusCode::OK);
|
|
||||||
assert_eq!(sell["balance"], 4700);
|
|
||||||
// Selling the same item again fails (not owned) and does not credit.
|
|
||||||
let (st, _) = json_post(
|
|
||||||
&app,
|
|
||||||
"/economy/sell-item",
|
|
||||||
serde_json::json!({"item_id": "mkt-1", "price": 200}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
|
||||||
let (_, bal) = json_get(&app, "/economy/balance").await;
|
|
||||||
assert_eq!(bal["balance"], 4700);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_economy_insufficient_funds_fail_closed() {
|
|
||||||
let app = build_test_app().await;
|
|
||||||
auth(&app, "econ-d").await;
|
|
||||||
let (st, _) = json_post(
|
|
||||||
&app,
|
|
||||||
"/economy/purchase-entitlement",
|
|
||||||
serde_json::json!({"cost": 999999, "definition_id": "pack-x"}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
|
||||||
let (_, bal) = json_get(&app, "/economy/balance").await;
|
|
||||||
assert_eq!(bal["balance"], 5000);
|
|
||||||
let (_, ents) = json_get(&app, "/economy/entitlements").await;
|
|
||||||
// No purchased "pack-x" entitlement was created (starter pack aside).
|
|
||||||
assert!(!ents
|
|
||||||
.as_array()
|
|
||||||
.unwrap()
|
|
||||||
.iter()
|
|
||||||
.any(|e| e["definition_id"] == "pack-x"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_economy_purchase_items_debits_and_mints_all() {
|
|
||||||
let app = build_test_app().await;
|
|
||||||
auth(&app, "econ-e").await;
|
|
||||||
let (st, buy) = json_post(
|
|
||||||
&app,
|
|
||||||
"/economy/purchase-items",
|
|
||||||
serde_json::json!({
|
|
||||||
"cost": 800,
|
|
||||||
"items": [
|
|
||||||
{"item_id": "bx-1", "card_id": "d-1"},
|
|
||||||
{"item_id": "bx-2", "card_id": "d-2"}
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(st, StatusCode::OK);
|
|
||||||
assert_eq!(buy["balance"], 4200);
|
|
||||||
let (_, bal) = json_get(&app, "/economy/balance").await;
|
|
||||||
assert_eq!(bal["balance"], 4200);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- transfer-market settlement fixtures -------------------------------------
|
|
||||||
// Two parties, because a club buying its own listing is not a market path and
|
|
||||||
// would hide every ownership bug these tests exist to catch. Each party gets its
|
|
||||||
// OWN game id so that `X-OpenFUT-Game` can address either club's balance through
|
|
||||||
// the normal active-profile resolver.
|
|
||||||
|
|
||||||
/// Seeded `created_at` for the market parties. Must be LATER than the rival row
|
|
||||||
/// dated `2020-01-01` in the omitted-seller test, whose whole point is that the
|
|
||||||
/// earliest row for a game is the active one.
|
|
||||||
const MKT_TS: &str = "2026-01-01T00:00:00Z";
|
|
||||||
const SELLER_GAME: &str = "mkt-a";
|
|
||||||
const BUYER_GAME: &str = "mkt-b";
|
|
||||||
const SELLER_CLUB: &str = "mkt-club-a";
|
|
||||||
const BUYER_CLUB: &str = "mkt-club-b";
|
|
||||||
const MKT_ITEM: &str = "mkt-item-x";
|
|
||||||
const MKT_CARD: &str = "mkt-def-x";
|
|
||||||
/// The canonical sale: 15_000 gross, 750 fee (floored 5%), 14_250 to the seller.
|
|
||||||
const CANON_GROSS: i64 = 15_000;
|
|
||||||
const CANON_FEE: i64 = 750;
|
|
||||||
|
|
||||||
/// Seed one profile + its club directly. `created_at` fixes activation order
|
|
||||||
/// (the active profile for a game is its earliest row).
|
|
||||||
async fn seed_party(
|
|
||||||
pool: &sqlx::SqlitePool,
|
|
||||||
profile_id: &str,
|
|
||||||
game_id: &str,
|
|
||||||
club_id: &str,
|
|
||||||
coins: i64,
|
|
||||||
created_at: &str,
|
|
||||||
) {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO profiles (id, username, game_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
|
||||||
)
|
|
||||||
.bind(profile_id)
|
|
||||||
.bind(profile_id)
|
|
||||||
.bind(game_id)
|
|
||||||
.bind(created_at)
|
|
||||||
.bind(created_at)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.expect("profile");
|
|
||||||
sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)")
|
|
||||||
.bind(club_id)
|
|
||||||
.bind(profile_id)
|
|
||||||
.bind(club_id)
|
|
||||||
.bind(coins)
|
|
||||||
.bind(created_at)
|
|
||||||
.bind(created_at)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.expect("club");
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn seed_owned(pool: &sqlx::SqlitePool, item_id: &str, club_id: &str, card_id: &str) {
|
|
||||||
sqlx::query("INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) VALUES (?, ?, ?, 0, ?)")
|
|
||||||
.bind(item_id)
|
|
||||||
.bind(club_id)
|
|
||||||
.bind(card_id)
|
|
||||||
.bind(MKT_TS)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.expect("owned card");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// THE canonical fixture: seller 1_000 owning `mkt-item-x`, buyer 20_000.
|
|
||||||
async fn seed_market(pool: &sqlx::SqlitePool) {
|
|
||||||
seed_party(pool, "mkt-prof-a", SELLER_GAME, SELLER_CLUB, 1_000, MKT_TS).await;
|
|
||||||
seed_party(pool, "mkt-prof-b", BUYER_GAME, BUYER_CLUB, 20_000, MKT_TS).await;
|
|
||||||
seed_owned(pool, MKT_ITEM, SELLER_CLUB, MKT_CARD).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn club_coins(pool: &sqlx::SqlitePool, club_id: &str) -> i64 {
|
|
||||||
sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
|
|
||||||
.bind(club_id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn item_owner(pool: &sqlx::SqlitePool, 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()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn item_row_count(pool: &sqlx::SqlitePool, item_id: &str) -> i64 {
|
|
||||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards WHERE id = ?")
|
|
||||||
.bind(item_id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Total modelled coins — a two-club sale must shrink this by exactly the fee.
|
|
||||||
async fn all_club_coins(pool: &sqlx::SqlitePool) -> i64 {
|
|
||||||
sqlx::query_scalar::<_, i64>("SELECT COALESCE(SUM(coins), 0) FROM clubs")
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `json_get` for a specific game, so each seeded club can be read over HTTP.
|
|
||||||
async fn json_get_as_game(app: &axum::Router, uri: &str, game: &str) -> (StatusCode, Value) {
|
|
||||||
let resp = app
|
|
||||||
.clone()
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.uri(uri)
|
|
||||||
.header("x-openfut-game", game)
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let status = resp.status();
|
|
||||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
(status, serde_json::from_slice(&body).unwrap())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn settle_body(gross: i64, fee: i64, seller: Option<&str>, buyer: Option<&str>) -> Value {
|
|
||||||
let mut body = serde_json::json!({"item_id": MKT_ITEM, "gross": gross, "fee": fee});
|
|
||||||
if let Some(seller) = seller {
|
|
||||||
body["seller_club_id"] = seller.into();
|
|
||||||
}
|
|
||||||
if let Some(buyer) = buyer {
|
|
||||||
body["buyer_club_id"] = buyer.into();
|
|
||||||
}
|
|
||||||
body
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_economy_settle_sale_route_two_party_sale() {
|
|
||||||
let (app, pool) = build_test_app_with_pool().await;
|
|
||||||
seed_market(&pool).await;
|
|
||||||
// A third, solvent club: the double-sale attempt below must be stopped by the
|
|
||||||
// ownership CAS, not merely by the first buyer having run out of coins.
|
|
||||||
seed_party(&pool, "mkt-prof-c", "mkt-c", "mkt-club-c", 20_000, MKT_TS).await;
|
|
||||||
assert_eq!(all_club_coins(&pool).await, 41_000);
|
|
||||||
|
|
||||||
let (st, r) = json_post(
|
|
||||||
&app,
|
|
||||||
"/economy/settle-sale",
|
|
||||||
settle_body(CANON_GROSS, CANON_FEE, Some(SELLER_CLUB), Some(BUYER_CLUB)),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(st, StatusCode::OK, "{r}");
|
|
||||||
assert_eq!(r["item_id"], MKT_ITEM);
|
|
||||||
assert_eq!(r["card_id"], MKT_CARD);
|
|
||||||
assert_eq!(r["seller_club_id"], SELLER_CLUB);
|
|
||||||
assert_eq!(r["buyer_club_id"], BUYER_CLUB);
|
|
||||||
assert_eq!(r["gross"], CANON_GROSS);
|
|
||||||
assert_eq!(r["fee"], CANON_FEE);
|
|
||||||
assert_eq!(r["proceeds"], 14_250);
|
|
||||||
assert_eq!(r["seller_balance"], 15_250);
|
|
||||||
assert_eq!(r["buyer_balance"], 5_000);
|
|
||||||
|
|
||||||
// Ownership MOVED — one row, new owner. A mint-based "buy" would leave two.
|
|
||||||
assert_eq!(
|
|
||||||
item_owner(&pool, MKT_ITEM).await.as_deref(),
|
|
||||||
Some(BUYER_CLUB)
|
|
||||||
);
|
|
||||||
assert_eq!(item_row_count(&pool, MKT_ITEM).await, 1);
|
|
||||||
|
|
||||||
// Same balances read back over HTTP, each club via its own game header.
|
|
||||||
let (s, seller) = json_get_as_game(&app, "/economy/balance", SELLER_GAME).await;
|
|
||||||
assert_eq!(s, StatusCode::OK, "{seller}");
|
|
||||||
assert_eq!(seller["balance"], 15_250);
|
|
||||||
let (s, buyer) = json_get_as_game(&app, "/economy/balance", BUYER_GAME).await;
|
|
||||||
assert_eq!(s, StatusCode::OK, "{buyer}");
|
|
||||||
assert_eq!(buyer["balance"], 5_000);
|
|
||||||
|
|
||||||
// The economy lost exactly the fee.
|
|
||||||
assert_eq!(all_club_coins(&pool).await, 40_250);
|
|
||||||
|
|
||||||
// Selling the same item AGAIN — to a buyer who can afford it — is rejected:
|
|
||||||
// the named seller no longer owns it, so the transfer's ownership predicate
|
|
||||||
// matches nothing and the whole transaction (including the second buyer's
|
|
||||||
// debit) rolls back.
|
|
||||||
let (st, _) = json_post(
|
|
||||||
&app,
|
|
||||||
"/economy/settle-sale",
|
|
||||||
settle_body(
|
|
||||||
CANON_GROSS,
|
|
||||||
CANON_FEE,
|
|
||||||
Some(SELLER_CLUB),
|
|
||||||
Some("mkt-club-c"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
|
||||||
assert_eq!(club_coins(&pool, "mkt-club-c").await, 20_000);
|
|
||||||
assert_eq!(all_club_coins(&pool).await, 40_250);
|
|
||||||
assert_eq!(
|
|
||||||
item_owner(&pool, MKT_ITEM).await.as_deref(),
|
|
||||||
Some(BUYER_CLUB)
|
|
||||||
);
|
|
||||||
assert_eq!(item_row_count(&pool, MKT_ITEM).await, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_economy_settle_sale_route_omitted_buyer_settles_outside() {
|
|
||||||
let (app, pool) = build_test_app_with_pool().await;
|
|
||||||
seed_market(&pool).await;
|
|
||||||
|
|
||||||
let (st, r) = json_post(
|
|
||||||
&app,
|
|
||||||
"/economy/settle-sale",
|
|
||||||
settle_body(CANON_GROSS, CANON_FEE, Some(SELLER_CLUB), None),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(st, StatusCode::OK, "{r}");
|
|
||||||
// No modelled counterparty: an omitted buyer is OUTSIDE, never the active club.
|
|
||||||
assert!(r["buyer_club_id"].is_null());
|
|
||||||
assert!(r["buyer_balance"].is_null());
|
|
||||||
assert_eq!(r["proceeds"], 14_250);
|
|
||||||
assert_eq!(r["seller_balance"], 15_250);
|
|
||||||
|
|
||||||
// The item left the inventory entirely and no other club was debited.
|
|
||||||
assert_eq!(item_row_count(&pool, MKT_ITEM).await, 0);
|
|
||||||
assert_eq!(club_coins(&pool, SELLER_CLUB).await, 15_250);
|
|
||||||
assert_eq!(club_coins(&pool, BUYER_CLUB).await, 20_000);
|
|
||||||
|
|
||||||
// Replay: the item is gone, so the seller is not credited a second time.
|
|
||||||
let (st, _) = json_post(
|
|
||||||
&app,
|
|
||||||
"/economy/settle-sale",
|
|
||||||
settle_body(CANON_GROSS, CANON_FEE, Some(SELLER_CLUB), None),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
|
||||||
assert_eq!(club_coins(&pool, SELLER_CLUB).await, 15_250);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_economy_settle_sale_route_omitted_seller_uses_active_club() {
|
|
||||||
let (app, pool) = build_test_app_with_pool().await;
|
|
||||||
// Seeded first (and dated earliest) so a resolution that ignored the game
|
|
||||||
// dimension would pick this club instead of the request's active one.
|
|
||||||
seed_party(
|
|
||||||
&pool,
|
|
||||||
"mkt-prof-b",
|
|
||||||
BUYER_GAME,
|
|
||||||
BUYER_CLUB,
|
|
||||||
20_000,
|
|
||||||
"2020-01-01T00:00:00Z",
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
// The active club for the default game header (fifa23), holding the item.
|
|
||||||
let session = auth(&app, "econ-settle-active").await;
|
|
||||||
let active_club = session["club"]["id"].as_str().unwrap().to_string();
|
|
||||||
seed_owned(&pool, MKT_ITEM, &active_club, MKT_CARD).await;
|
|
||||||
|
|
||||||
let (st, r) = json_post(
|
|
||||||
&app,
|
|
||||||
"/economy/settle-sale",
|
|
||||||
settle_body(CANON_GROSS, CANON_FEE, None, Some(BUYER_CLUB)),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(st, StatusCode::OK, "{r}");
|
|
||||||
assert_eq!(r["seller_club_id"], active_club);
|
|
||||||
assert_eq!(r["buyer_club_id"], BUYER_CLUB);
|
|
||||||
// The active club starts at 5_000 and is credited gross - fee.
|
|
||||||
assert_eq!(r["seller_balance"], 19_250);
|
|
||||||
assert_eq!(r["buyer_balance"], 5_000);
|
|
||||||
assert_eq!(
|
|
||||||
item_owner(&pool, MKT_ITEM).await.as_deref(),
|
|
||||||
Some(BUYER_CLUB)
|
|
||||||
);
|
|
||||||
let (_, bal) = json_get(&app, "/economy/balance").await;
|
|
||||||
assert_eq!(bal["balance"], 19_250);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_economy_settle_sale_route_rejects_fee_above_gross() {
|
|
||||||
let (app, pool) = build_test_app_with_pool().await;
|
|
||||||
seed_market(&pool).await;
|
|
||||||
|
|
||||||
let (st, _) = json_post(
|
|
||||||
&app,
|
|
||||||
"/economy/settle-sale",
|
|
||||||
settle_body(
|
|
||||||
CANON_GROSS,
|
|
||||||
CANON_GROSS + 1,
|
|
||||||
Some(SELLER_CLUB),
|
|
||||||
Some(BUYER_CLUB),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
|
||||||
assert_eq!(club_coins(&pool, SELLER_CLUB).await, 1_000);
|
|
||||||
assert_eq!(club_coins(&pool, BUYER_CLUB).await, 20_000);
|
|
||||||
assert_eq!(
|
|
||||||
item_owner(&pool, MKT_ITEM).await.as_deref(),
|
|
||||||
Some(SELLER_CLUB)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_economy_settle_sale_route_rejects_unaffordable_buyer() {
|
|
||||||
let (app, pool) = build_test_app_with_pool().await;
|
|
||||||
seed_market(&pool).await;
|
|
||||||
|
|
||||||
let (st, _) = json_post(
|
|
||||||
&app,
|
|
||||||
"/economy/settle-sale",
|
|
||||||
settle_body(20_001, CANON_FEE, Some(SELLER_CLUB), Some(BUYER_CLUB)),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
|
||||||
// Fail-closed: the debit is attempted before ownership moves, and the whole
|
|
||||||
// transaction rolls back.
|
|
||||||
assert_eq!(club_coins(&pool, SELLER_CLUB).await, 1_000);
|
|
||||||
assert_eq!(club_coins(&pool, BUYER_CLUB).await, 20_000);
|
|
||||||
assert_eq!(
|
|
||||||
item_owner(&pool, MKT_ITEM).await.as_deref(),
|
|
||||||
Some(SELLER_CLUB)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_economy_settle_sale_route_rejects_self_dealing() {
|
|
||||||
let (app, pool) = build_test_app_with_pool().await;
|
|
||||||
seed_market(&pool).await;
|
|
||||||
|
|
||||||
let (st, _) = json_post(
|
|
||||||
&app,
|
|
||||||
"/economy/settle-sale",
|
|
||||||
settle_body(CANON_GROSS, CANON_FEE, Some(SELLER_CLUB), Some(SELLER_CLUB)),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(st, StatusCode::CONFLICT);
|
|
||||||
assert_eq!(club_coins(&pool, SELLER_CLUB).await, 1_000);
|
|
||||||
assert_eq!(club_coins(&pool, BUYER_CLUB).await, 20_000);
|
|
||||||
assert_eq!(
|
|
||||||
item_owner(&pool, MKT_ITEM).await.as_deref(),
|
|
||||||
Some(SELLER_CLUB)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user