Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5d356e8be | |||
| 0b189b36c5 | |||
| 11c17f3039 | |||
| 871d02406f | |||
| 6811caeab1 | |||
| d71234b03d | |||
| 8e1fb640b6 | |||
| 0019806a3b | |||
| c7c057a31a | |||
| 89dc1b1d85 | |||
| 13a22c4507 | |||
| 1db9acdf6d | |||
| 911c7a34fd | |||
| fcc314afb1 | |||
| b323244ac9 | |||
| 1d6a6fffcc | |||
| f56aa613da | |||
| 8c17f896b4 | |||
| c68c10cf04 | |||
| 7116046195 | |||
| dcac2c546b | |||
| 5c8e2dc0bd | |||
| bd03aec82a | |||
| e8d1c1ddac | |||
| f9740f640d |
@@ -0,0 +1,25 @@
|
||||
# OpenFUT Docker stack configuration. Copy to .env and adjust.
|
||||
# All values have sensible defaults in docker-compose.yml; override as needed.
|
||||
|
||||
# --- Container registry (Gitea) ---
|
||||
# Images resolve to ${REGISTRY}/${NAMESPACE}/<image>:${TAG}
|
||||
# e.g. git.aleshym.co/openfut/openfut-core:latest
|
||||
REGISTRY=git.aleshym.co
|
||||
NAMESPACE=openfut
|
||||
TAG=latest
|
||||
|
||||
# --- Networking ---
|
||||
# Where the bridge (FIFA client entry point) is published. 0.0.0.0 = all
|
||||
# interfaces so LAN clients can connect. Set to a specific IP to restrict.
|
||||
BRIDGE_PUBLISH=0.0.0.0
|
||||
# Where core's REST API is published. 127.0.0.1 keeps it host-local (the bridge
|
||||
# still reaches it over the internal docker network). Set 0.0.0.0 to expose it.
|
||||
CORE_PUBLISH=127.0.0.1
|
||||
|
||||
# --- Behaviour ---
|
||||
# Bridge returns placeholder JSON + captures unknown routes when true.
|
||||
PLACEHOLDER_MODE=true
|
||||
|
||||
# --- Logging (RUST_LOG filters) ---
|
||||
CORE_LOG=openfut_core=info,tower_http=info
|
||||
BRIDGE_LOG=openfut_bridge=info,tower_http=info
|
||||
@@ -0,0 +1,163 @@
|
||||
# AGENTS.md — OpenFUT
|
||||
|
||||
**Read this first.** It is the entry point for AI-assisted work on OpenFUT. It supersedes the
|
||||
root `README.md` and `CLAUDE.md`, which are **stale** (they describe an earlier FIFA 23 plan).
|
||||
|
||||
## Project
|
||||
|
||||
OpenFUT is a preservation / private-server project that restores **offline, single-player FIFA
|
||||
Ultimate Team (FUT)** after EA retired the online servers. You must own the game legitimately; the
|
||||
project does not bypass ownership checks — it only re-serves the dead online services locally.
|
||||
|
||||
**Current active target: FIFA 17 (PC).** A clean-room emulation of the full online + FUT stack
|
||||
was proven working end-to-end on **2026-08-01** (auth → Blaze login → device-trust → FUT hub).
|
||||
This lives in `fifa17-recon/`. The FIFA 17 work is explicitly the **Rosetta Stone for FIFA 23**
|
||||
(identical Blaze/LSX/UTAS wire format), so FIFA 23 remains the eventual second target.
|
||||
|
||||
Three moving parts, kept strictly separate:
|
||||
- **The FIFA client** — the retail game (FIFA 17 now). Unmodified except live cert-verify patches.
|
||||
- **The emulation layer** — Python responders in `fifa17-recon/tools/` (LSX, Blaze, UTAS, roster)
|
||||
that impersonate EA's online services on localhost. This is where all reverse engineering lives.
|
||||
- **OpenFUT Core** — a game-independent REST FUT economy backend (`openfut-core/`), feature-complete
|
||||
and tested. Knows nothing about FIFA. Intended to eventually back the emulation layer's FUT data.
|
||||
|
||||
> The emulation layer and Core are **not yet wired together.** The FIFA 17 UTAS server currently
|
||||
> serves its own hardcoded/JSON payloads, not Core's API. See `docs/PROJECT_STATE.md`.
|
||||
|
||||
## Repository map
|
||||
|
||||
Monorepo. `openfut-core`, `openfut-bridge`, `openfut-launcher`, `fifa-blaze` are **git submodules**
|
||||
(each with independent history — use `tea`/Gitea, not `gh`). `fifa17-recon/` is a plain directory.
|
||||
|
||||
| Path | What it is | Status |
|
||||
|---|---|---|
|
||||
| `fifa17-recon/` | **The live path.** FIFA 17 offline FUT emulation: Python responders, cert patcher, runbook, RE write-ups. | Working |
|
||||
| `openfut-core/` | Rust (Axum + SQLite) FUT economy backend. Game-independent REST API. | Working, tested |
|
||||
| `openfut-bridge/` | Rust FIFA 23 in-process hook / proxy RE effort. | Blocked (see below) |
|
||||
| `fifa-blaze/` | Rust Blaze protocol emulator scaffold for FIFA 23 (capture stub). | Milestone 1 stub |
|
||||
| `openfut-launcher/` | Rust egui/eframe desktop launcher (targets FIFA 23 hook flow). | Legacy plan |
|
||||
| `docs/` | **Mirrors** of the vault (`OpenFUT-Vault`), which is canonical. Direction pivots + context. | — |
|
||||
| `tools/` | Host-side RE helpers (file-watch-diff, exporters, squad-injector) from the FLE-bridge idea. | Legacy plan |
|
||||
| `setup.sh` | FIFA 23 full-stack orchestrator (core+bridge). | Legacy plan |
|
||||
|
||||
**Legacy vs live:** the project pivoted twice — (1) FIFA 23 Blaze backend → (2) FIFA 23 as a match
|
||||
renderer driven by an FLE Lua bridge (`docs/direction.md`) → (3) **FIFA 17 full online emulation,
|
||||
which succeeded and is now the primary path** (`fifa17-recon/`). Treat `openfut-bridge`,
|
||||
`openfut-launcher`, `fifa-blaze`, `tools/`, `setup.sh`, and `docs/direction.md` as historical unless
|
||||
a task explicitly targets the FIFA 23 port.
|
||||
|
||||
## Architecture (live path)
|
||||
|
||||
```
|
||||
FIFA 17 client (Wine/Proton, base 0x140000000)
|
||||
│ autopatch.py NOPs two ProtoSSL cert-verify gates in /proc/PID/mem
|
||||
├─ LSX 127.0.0.1:4216 → lsx_responder_v2.py (Origin login/profile/authcode)
|
||||
├─ TLS 127.0.0.1:42127 → blaze_responder_v3b.py (Blaze redirector, via DNAT of 159.153.51.20)
|
||||
├─ Blaze 42130 / Nucleus 42131 → blaze_responder_v3b.py (Fire2/Heat2 binary + login)
|
||||
├─ easw.easports.com (→127.0.0.1) :8099 → utas_server.py (UTAS/RS4 FUT API + device-trust)
|
||||
└─ roster :8081 → roster_server.py (FUT roster-update XML)
|
||||
|
||||
OpenFUT Core (openfut-core, :8080) ── clean REST FUT economy ── NOT YET CONNECTED to the above
|
||||
```
|
||||
|
||||
Host arming (`root_arm.sh` via `pkexec`, volatile across reboot): `ptrace_scope=0`,
|
||||
`route_localnet=1`, iptables DNAT `159.153.51.20→127.0.0.1:42127`, `/etc/hosts easw.easports.com`.
|
||||
|
||||
## Development commands (verified)
|
||||
|
||||
**FIFA 17 emulation** (from `fifa17-recon/tools/`):
|
||||
- Start everything (idempotent; re-run after reboot): `./openfut-fut.sh start`
|
||||
- Status / stop / restart: `./openfut-fut.sh status | stop | restart`
|
||||
- Then launch the game fresh (`~/Desktop/launch-fifa17.sh`) and pick Ultimate Team.
|
||||
- Logs: `/tmp/{lsx,blaze,roster,utas,autopatch}.log`
|
||||
- Full procedure + gate-ladder troubleshooting: `fifa17-recon/FUT-RUNBOOK.md`
|
||||
|
||||
**OpenFUT Core** (from `openfut-core/`): `cargo run` (creates `openfut.db`) · `cargo test`
|
||||
(full in-memory integration suite; requires `data/`) · `cargo test <name>` for one ·
|
||||
`cargo clippy -- -D warnings` · `cargo fmt`. Env: `LISTEN_ADDR` (127.0.0.1:8080), `DATABASE_URL`
|
||||
(sqlite://openfut.db), `DATA_DIR` (data).
|
||||
|
||||
**Other Rust crates** (`openfut-bridge`, `fifa-blaze`, `openfut-launcher`): standard
|
||||
`cargo run/build/test/clippy/fmt` from within each. `fifa-blaze` is a workspace (`--bin blaze-server`).
|
||||
|
||||
**CI:** only `openfut-core` has it (`.gitea/workflows/ci.yml`): `fmt --check`, `clippy -D warnings`,
|
||||
`build --locked`, `test --locked` on push/PR to main. No CI on the other crates or the recon dir.
|
||||
|
||||
There is **no install step, no Docker, no JS/TS frontend, no typecheck** in this repo. Do not invent them.
|
||||
|
||||
## Coding conventions
|
||||
|
||||
- **Rust (Core):** Axum 0.7 + SQLx 0.7 (SQLite, compile-time-checked queries). Strict layering —
|
||||
`routes/` (handlers, extract state, call services) → `services/` (own **all** DB access + logic)
|
||||
→ `models/` (pure `Serde`/`FromRow` data). Errors via `AppError` (`src/error.rs`) with
|
||||
`IntoResponse`. One file per domain across `routes/`, `services/`, `models/`. **Single-profile
|
||||
design:** every service reads "the active profile" as the first DB row — intentional, don't
|
||||
parameterize it. Content is data-driven: JSON under `data/` loaded at startup into Arc registries
|
||||
in `AppState`. Add content by dropping JSON files, not code. Migrations are numbered SQL in
|
||||
`migrations/`. Keep `clippy -D warnings` and `fmt` clean (CI enforces).
|
||||
- **Python (recon):** stdlib-only servers, no framework. Each responder is a standalone script with
|
||||
the reverse-engineered contract documented in its module docstring (byte offsets, VAs, symbol
|
||||
names). When changing a responder, preserve byte-exactness — the client is the oracle.
|
||||
- **Clean-room, always.** Every finding derives from binaries we own + live observation. **Never**
|
||||
use, reference, or reproduce leaked EA source. If a task seems to need it, stop and say so.
|
||||
|
||||
## AI-agent rules
|
||||
|
||||
1. Read this file before exploring the repo.
|
||||
2. Read the vault file relevant to the task (`../OpenFUT-Vault/`), not the whole tree. Repo
|
||||
`docs/` files are mirrors of the vault — consult them for the same content, but treat the
|
||||
vault as canonical.
|
||||
3. Don't scan the whole repository unless the knowledge base is clearly stale — if you find it
|
||||
stale, update the vault, then its repo `docs/` mirror.
|
||||
4. Search the specific directory (`fifa17-recon/`, `openfut-core/src/<layer>/`) before a repo-wide search.
|
||||
5. Update the vault when architecture materially changes (and sync the matching `docs/` mirror).
|
||||
6. Don't refactor or rewrite unrelated working code.
|
||||
7. Prefer small, testable changes; run the narrowest relevant test first (`cargo test <name>`).
|
||||
8. **Never invent EA/FIFA/Blaze protocol behavior.** Values you don't know are `TODO/CONFIRM`, not
|
||||
confident guesses. The live client is the only oracle for whether a gate is satisfied.
|
||||
9. Clearly separate discovered behavior from hypotheses; record findings in
|
||||
`../OpenFUT-Vault/02 Reverse Engineering/FIFA 17/Protocol Findings.md` under the right confidence
|
||||
tier — never silently promote a hypothesis to a fact.
|
||||
10. Root `README.md` / `CLAUDE.md` and `openfut-bridge/CLAUDE.md` describe superseded FIFA 23 plans;
|
||||
prefer vault + repository evidence over them when they conflict.
|
||||
|
||||
## AI Session Bootstrap
|
||||
|
||||
Future agents should start with:
|
||||
1. Read `AGENTS.md`.
|
||||
2. Read the vault README (`../OpenFUT-Vault/README.md`) to locate the canonical files.
|
||||
3. Identify the subsystem the task affects and read the corresponding vault file: Architecture,
|
||||
Project State, Roadmap/Current Priorities, or Protocol Findings.
|
||||
4. Inspect only the relevant source directories.
|
||||
5. Check `../OpenFUT-Vault/02 Reverse Engineering/FIFA 17/Protocol Findings.md` before assuming
|
||||
anything about FIFA/EA behavior.
|
||||
6. Check `../OpenFUT-Vault/06 Agent Memory/Project State.md` before assuming a feature exists.
|
||||
7. Implement the smallest coherent change.
|
||||
8. Run the narrowest relevant tests.
|
||||
9. Update the vault (and its repo `docs/` mirror) only if the change makes existing knowledge
|
||||
inaccurate.
|
||||
|
||||
Do not reread the entire repository during every session.
|
||||
|
||||
## OpenFUT Knowledge Base
|
||||
|
||||
**The OpenFUT Vault is the canonical project knowledge base.** Repo `docs/` files mirror it; the
|
||||
vault wins on any disagreement. Consult it before starting substantial work and update it after
|
||||
durable discoveries.
|
||||
|
||||
Vault location: `../OpenFUT-Vault/` — start at `../OpenFUT-Vault/README.md`.
|
||||
|
||||
Canonical files:
|
||||
- Dashboard: `00 Dashboard/OpenFUT.md`
|
||||
- Architecture: `01 Architecture/Architecture.md` (repo mirror `docs/ARCHITECTURE.md`)
|
||||
- RE findings: `02 Reverse Engineering/FIFA 17/Protocol Findings.md`
|
||||
(repo mirror `docs/research/KNOWN_FINDINGS.md`)
|
||||
- Direction history: `04 Decisions/Direction History.md`
|
||||
- Project State: `06 Agent Memory/Project State.md` (repo mirror `docs/PROJECT_STATE.md`)
|
||||
- Current Priorities: `06 Agent Memory/Current Priorities.md`
|
||||
- Known Issues: `06 Agent Memory/Known Issues.md`
|
||||
- Important Discoveries: `06 Agent Memory/Important Discoveries.md`
|
||||
- Roadmap: `08 Roadmap/Roadmap.md` (repo mirror `docs/ROADMAP.md`)
|
||||
|
||||
When editing knowledge that exists in both places, edit the vault first, then update the matching
|
||||
`docs/` mirror so they stay in sync.
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
> ⚠️ **Stale (FIFA 23).** This file's status and targets predate the FIFA 17 pivot. Prefer [`docs/PROJECT_STATE.md`](./docs/PROJECT_STATE.md) (canonical). The working target is **FIFA 17**; the canonical server is `fifa17-recon/docker/fifa17-python` (`docker compose up -d`). `openfut-bridge` (FIFA 23) is superseded; `openfut-core` remains the shared backend.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
This is a monorepo containing three independent Rust crates as git submodules:
|
||||
|
||||
Generated
+1
@@ -3210,6 +3210,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tower 0.5.3",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# OpenFUT
|
||||
|
||||
> ⚠️ **Status — see [`docs/PROJECT_STATE.md`](./docs/PROJECT_STATE.md) (canonical).** The working, actively-developed target is **FIFA 17**, not FIFA 23. Everything below this banner describes the **superseded FIFA 23 `bridge` lineage** and is kept for historical context.
|
||||
>
|
||||
> **Run the server (canonical):** `cd fifa17-recon/docker/fifa17-python && docker compose up -d` — see [`fifa17-recon/FUT-RUNBOOK.md`](./fifa17-recon/FUT-RUNBOOK.md). `openfut-core` is the shared offline backend (still used by the FIFA 17 path); `openfut-bridge` is the retired FIFA 23 integration.
|
||||
|
||||
**Offline Ultimate Team — like SPT, but for FIFA 23.**
|
||||
|
||||
OpenFUT replaces EA's retired FUT servers with a fully offline, single-player backend. You own FIFA 23 legitimately. You just want to keep playing after EA shut down the servers.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# ============================================================================
|
||||
# ⚠️ LEGACY (FIFA 23 lineage). This compose runs core + bridge for the
|
||||
# superseded FIFA 23 direction. It is NOT the canonical server bring-up.
|
||||
#
|
||||
# Canonical server (FIFA 17):
|
||||
# cd fifa17-recon/docker/fifa17-python && docker compose up -d
|
||||
# (runbook: fifa17-recon/FUT-RUNBOOK.md)
|
||||
#
|
||||
# `core` (openfut-core) IS still the shared, game-independent backend and is
|
||||
# used by the FIFA 17 UTAS host (OPENFUT_CORE_URL). `bridge` (openfut-bridge)
|
||||
# is the retired FIFA 23 integration, kept for reference.
|
||||
# Status source of truth: docs/PROJECT_STATE.md
|
||||
# ============================================================================
|
||||
# OpenFUT server stack — offline FUT backend (Core) + FIFA proxy (Bridge).
|
||||
#
|
||||
# Bring up: docker compose up -d
|
||||
# Tear down: docker compose down (keeps data/captures volumes)
|
||||
# Wipe state: docker compose down -v (also drops volumes)
|
||||
# Rebuild: docker compose build (or ./scripts/registry.sh build)
|
||||
# Logs: docker compose logs -f
|
||||
#
|
||||
# Images are pulled from / pushed to the Gitea container registry. Override the
|
||||
# registry, namespace, or tag in .env (see .env.example). When REGISTRY is set,
|
||||
# `up` pulls prebuilt images; the build: blocks let you rebuild locally too.
|
||||
|
||||
name: openfut
|
||||
|
||||
services:
|
||||
core:
|
||||
image: ${REGISTRY:-git.aleshym.co}/${NAMESPACE:-openfut}/openfut-core:${TAG:-latest}
|
||||
build:
|
||||
context: ./openfut-core
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LISTEN_ADDR: 0.0.0.0:8080
|
||||
DATABASE_URL: sqlite:///app/db/openfut.db
|
||||
DATA_DIR: /app/data
|
||||
RUST_LOG: ${CORE_LOG:-openfut_core=info,tower_http=info}
|
||||
volumes:
|
||||
- core-db:/app/db
|
||||
# Bound to localhost by default — the bridge reaches core over the internal
|
||||
# network, so core need not be world-exposed. Set CORE_PUBLISH=0.0.0.0 in
|
||||
# .env if you want to hit the REST API directly from other hosts.
|
||||
ports:
|
||||
- "${CORE_PUBLISH:-127.0.0.1}:8080:8080"
|
||||
networks:
|
||||
- openfut
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
|
||||
interval: 15s
|
||||
timeout: 4s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
bridge:
|
||||
image: ${REGISTRY:-git.aleshym.co}/${NAMESPACE:-openfut}/openfut-bridge:${TAG:-latest}
|
||||
build:
|
||||
context: ./openfut-bridge
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
core:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
BRIDGE_LISTEN_ADDR: 0.0.0.0:8443
|
||||
CORE_URL: http://core:8080
|
||||
CAPTURES_DIR: /app/captures
|
||||
PLACEHOLDER_MODE: ${PLACEHOLDER_MODE:-true}
|
||||
TLS_ENABLED: "true"
|
||||
RUST_LOG: ${BRIDGE_LOG:-openfut_bridge=info,tower_http=info}
|
||||
volumes:
|
||||
- bridge-captures:/app/captures
|
||||
# The FIFA client connects here — publish on all interfaces by default so
|
||||
# LAN clients (e.g. 10.10.0.0/24) can reach it.
|
||||
ports:
|
||||
- "${BRIDGE_PUBLISH:-0.0.0.0}:8443:8443"
|
||||
networks:
|
||||
- openfut
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsSk", "https://127.0.0.1:8443/_bridge/health"]
|
||||
interval: 15s
|
||||
timeout: 4s
|
||||
retries: 5
|
||||
start_period: 8s
|
||||
|
||||
networks:
|
||||
openfut:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
core-db:
|
||||
bridge-captures:
|
||||
@@ -9,3 +9,25 @@ OPENFUT_ADVERTISE=203.0.113.10 # <- REPLACE with this host's LAN IP
|
||||
# Defaults to 0.0.0.0 (container-facing); the original all-on-localhost flow
|
||||
# uses the loopback default baked into the responders when unset.
|
||||
OPENFUT_BIND=0.0.0.0
|
||||
|
||||
# OPENFUT_SERVERS — which Python responders Docker runs (space/comma separated).
|
||||
# Default (unset) = the server-side set: "blaze roster utas pow".
|
||||
#
|
||||
# This host is the SERVER (.120). Docker runs ONLY components that have NOT been
|
||||
# migrated to a Rust host. During migration the Rust hosts (redirector / roster
|
||||
# / utas) run OUTSIDE Docker; as each Python component is replaced, remove its
|
||||
# name here so the two never serve the same role at once.
|
||||
# blaze Blaze redirector + main + nucleus (bundled) :42127 :42130 :42131
|
||||
# roster FUT roster-update XML :8081
|
||||
# utas FUT/UTAS RS4 API :8099
|
||||
# (Rust utas-host still proxies its non-/club routes here for now)
|
||||
# pow POW / EASFC :8094 (+ content :8080)
|
||||
# lsx Origin LSX bootstrap :4216
|
||||
# CLIENT-SIDE: LSX runs on the game machine (.105) with autopatch, NOT
|
||||
# on this server. Leave it OUT unless client and server share one box.
|
||||
#
|
||||
# Example — Rust already owns roster, so Docker should not also serve it:
|
||||
# OPENFUT_SERVERS=blaze utas pow
|
||||
# When you drop a component, also stop advertising / DNAT'ing its port to this
|
||||
# container so the client is routed to the Rust host instead.
|
||||
#OPENFUT_SERVERS=blaze roster utas pow
|
||||
|
||||
@@ -36,10 +36,14 @@ services:
|
||||
FUT_PROFILE_ROOT: "/state/accounts"
|
||||
FUT_SETTINGS: "off"
|
||||
FUT_MODES: "1"
|
||||
# Which Python responders this SERVER runs. Default excludes lsx (that is
|
||||
# a client-side responder — see below). Drop a name once it is migrated to
|
||||
# a Rust host (run outside Docker) so the two never overlap. See .env.example.
|
||||
OPENFUT_SERVERS: "${OPENFUT_SERVERS:-blaze roster utas pow}"
|
||||
volumes:
|
||||
- "../state:/state"
|
||||
ports:
|
||||
- "4216:4216" # LSX (Origin bootstrap)
|
||||
- "4216:4216" # LSX — CLIENT-SIDE (.105); only used if lsx is enabled for all-on-one-box
|
||||
- "42127:42127" # Blaze redirector (TLS)
|
||||
- "42130:42130" # Blaze main
|
||||
- "42131:42131" # Nucleus OAuth stub
|
||||
|
||||
@@ -57,10 +57,40 @@ declare -a SERVERS=(
|
||||
"pow|pow_server.py|-"
|
||||
)
|
||||
|
||||
# ── Component selection ──────────────────────────────────────────────────────
|
||||
# OPENFUT_SERVERS picks which Python responders run (space- or comma-separated).
|
||||
# This container is the SERVER side (.120). It serves ONLY components that have
|
||||
# NOT been migrated to a Rust host — as each moves to Rust (which runs OUTSIDE
|
||||
# Docker during migration), drop its name so the two never serve the same role.
|
||||
# blaze Blaze redirector + main + nucleus (bundled) :42127 :42130 :42131
|
||||
# roster FUT roster-update XML :8081
|
||||
# utas FUT/UTAS RS4 API :8099
|
||||
# (the Rust utas-host currently reverse-proxies its non-/club routes
|
||||
# back here, so keep this enabled until UTAS is fully migrated)
|
||||
# pow POW / EASFC :8094 (+ content :8080)
|
||||
# lsx Origin LSX bootstrap :4216
|
||||
# CLIENT-SIDE — LSX runs on the game machine (.105) with autopatch,
|
||||
# NOT on the server. Excluded by default; enable ONLY for an
|
||||
# all-on-one-box dev setup where client and server share a host.
|
||||
OPENFUT_SERVERS="${OPENFUT_SERVERS:-blaze roster utas pow}"
|
||||
want=" ${OPENFUT_SERVERS//,/ } "
|
||||
known=" lsx blaze roster utas pow "
|
||||
for w in $want; do
|
||||
case "$known" in
|
||||
*" $w "*) ;;
|
||||
*) echo "[openfut] unknown component '$w' in OPENFUT_SERVERS (valid: lsx blaze roster utas pow)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
echo "[openfut] servers=$OPENFUT_SERVERS"
|
||||
|
||||
pids=()
|
||||
names=()
|
||||
for entry in "${SERVERS[@]}"; do
|
||||
IFS='|' read -r name script env <<<"$entry"
|
||||
case "$want" in
|
||||
*" $name "*) ;;
|
||||
*) echo "[openfut] skipping $name (not in OPENFUT_SERVERS)"; continue ;;
|
||||
esac
|
||||
envprefix=""; [ "$env" != "-" ] && envprefix="env $env"
|
||||
echo "[openfut] starting $name ($script)"
|
||||
# shellcheck disable=SC2086
|
||||
@@ -69,6 +99,11 @@ for entry in "${SERVERS[@]}"; do
|
||||
names+=("$name")
|
||||
done
|
||||
|
||||
if [ "${#pids[@]}" -eq 0 ]; then
|
||||
echo "[openfut] OPENFUT_SERVERS selected no components; nothing to run" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Propagate SIGTERM/SIGINT to children so `docker stop` is clean.
|
||||
term() {
|
||||
echo "[openfut] shutting down…"
|
||||
|
||||
@@ -424,6 +424,25 @@ Chemistry/rating/nation/league-count constraints (`teamChemistry 0x307`, `starRa
|
||||
generically as `{eligibilityKey, eligibilityOperation, eligibilityValue}` triples, **not** as
|
||||
named scalar fields on the record. **FREEZE-RISK: elgReq must be a JSON array of objects.**
|
||||
|
||||
> **2026-08-19 — `eligibilityKey`/`eligibilityOperation` are LOCALIZATION ORDINALS, not the
|
||||
> atom hex ids above.** Reversed from the pinned CardsDLL (`4706a881…`). The client's sole
|
||||
> confirmed consumer of these fields is the requirement-display string builder at
|
||||
> `~0x1800ef900`: it loads the eligibility int fields (`0x148(rcx)`) and formats them through
|
||||
> *indexed localization keys* — `ELIGIBILITY_STRING%d` (`0x1802186b8`), `LOC_SBC_ELG_KEY_%d`
|
||||
> (`0x180226710`), `ELIGIBILITY_OPERATION` (`0x1802186e8`) — appending to a string builder via
|
||||
> vtable `*0x10`/`*0x20`. There is **no comparison/branch**: the client does not validate on
|
||||
> these ints, it renders `LOC_SBC_ELG_KEY_<eligibilityKey>` (and an operation string) as
|
||||
> display text. Therefore `eligibilityKey` is a small ordinal that indexes the **packed FIFA17
|
||||
> locale**, NOT `0x307`/`0x22f`/etc. (those hex values are the atom ids of the *named* fields
|
||||
> the encoding replaces, not the ordinal values). CONSEQUENCE: correct projection needs the
|
||||
> ordinal→locale-string map, which lives only in the packed locale (absent from CardsDLL and
|
||||
> every `fifa17-recon/data` file; a game-dir locale probe on the live client found none) or a
|
||||
> real EA `elgReq` capture (unavailable on a private server). Emitting a *guessed* ordinal
|
||||
> renders the WRONG requirement text to the player, so `elgReq` stays `[]` until the ordinal
|
||||
> map is recovered. This is a display-only gap: SBC submission is fully validated server-side
|
||||
> (Core), and an invalid squad's generic comms modal originates from the server 400, not from
|
||||
> the empty `elgReq`.
|
||||
|
||||
**awards / grantedAwards** — nested array of reward objects (atoms: `rewardType 0x28e`,
|
||||
`rewardValue 0x28f`, `rewardQuantity 0x28d`, `rewardMultiplier 0x28c`, `awardCount 0x40`,
|
||||
`awardSet 0x45`, `awardSetId 0x46`, `prizeSet 0x253`). **FREEZE-RISK: must be array.**
|
||||
|
||||
@@ -55,6 +55,34 @@ verify_exports() {
|
||||
done
|
||||
}
|
||||
|
||||
# Refuse any DLL that is not a FIFA-17-profile build.
|
||||
#
|
||||
# openfut-hook builds TWO mutually exclusive injection paths from one crate: the
|
||||
# default (FIFA 23) path installs getaddrinfo/connect/ProtoSSL/origin hooks, while
|
||||
# `--features fifa17` installs ONLY the FIFA-17-safe logic (module map, FIFA 17
|
||||
# cert-verify, SBC dispatch, store tab bind). Deploying a default-feature build
|
||||
# into FIFA 17 hijacks the login transport and the client reports "Unable to
|
||||
# connect to the EA servers", with none of the FIFA 17 repairs present.
|
||||
#
|
||||
# That exact mistake happened on 2026-08-19 (artifact 1c71a17a, hand-built without
|
||||
# the feature): two failed launches, diagnosed only by comparing embedded strings.
|
||||
# `build` below passes the feature, but a hand-built DLL can reach `stage`/`deploy`
|
||||
# via OPENFUT_FIFA17_HOOK_DLL, so assert the profile on the bytes themselves.
|
||||
verify_fifa17_profile() {
|
||||
local dll=$1 marker
|
||||
# Markers that MUST be present: the FIFA 17 target module and its repairs.
|
||||
for marker in 'CardsDLL_Win64_retail.dll' 'SBC_DISPATCH'; do
|
||||
grep -qaF -- "$marker" "$dll" ||
|
||||
die "$dll is not a --features fifa17 build (missing $marker); refusing to stage/deploy"
|
||||
done
|
||||
# Markers that MUST be absent: the FIFA-23-only transport hooking.
|
||||
for marker in 'getaddrinfo IAT patched' 'connect: inline-hooked' 'origin_spy'; do
|
||||
if grep -qaF -- "$marker" "$dll"; then
|
||||
die "$dll contains FIFA-23-only hook '$marker'; build with --features fifa17"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
verify_inputs() {
|
||||
command -v sha256sum >/dev/null || die "sha256sum is required"
|
||||
command -v x86_64-w64-mingw32-objdump >/dev/null ||
|
||||
@@ -62,6 +90,7 @@ verify_inputs() {
|
||||
need_file "$hook_dll"
|
||||
need_file "$system_version"
|
||||
verify_pe64 "$hook_dll"
|
||||
verify_fifa17_profile "$hook_dll"
|
||||
}
|
||||
|
||||
inspect() {
|
||||
@@ -129,6 +158,7 @@ deploy() {
|
||||
need_file "$manifest"
|
||||
verify_pe64 "$staged"
|
||||
verify_exports "$staged"
|
||||
verify_fifa17_profile "$staged"
|
||||
local recorded actual
|
||||
recorded="$(awk -F= '$1=="artifact_sha256"{print $2}' "$manifest")"
|
||||
actual="$(sha256 "$staged")"
|
||||
@@ -158,7 +188,7 @@ launch() {
|
||||
local trace_enabled=0
|
||||
local request_trace_enabled=0
|
||||
local notifier_trace_enabled=0
|
||||
local commit_enabled=0
|
||||
local dispatch_enabled=0
|
||||
case "$mode" in
|
||||
baseline)
|
||||
[[ "${OPENFUT_FIFA17_LAUNCH:-}" == "I_ACCEPT_M1_BASELINE_LAUNCH" ]] ||
|
||||
@@ -177,14 +207,11 @@ launch() {
|
||||
request_trace_enabled=1
|
||||
notifier_trace_enabled=1
|
||||
;;
|
||||
commit)
|
||||
[[ "${OPENFUT_FIFA17_COMMIT:-}" == "I_ACCEPT_POST_PARSE_READY_BYTE" ]] ||
|
||||
die "launch-commit requires OPENFUT_FIFA17_COMMIT=I_ACCEPT_POST_PARSE_READY_BYTE"
|
||||
hook_enabled=1
|
||||
trace_enabled=1
|
||||
dispatch)
|
||||
[[ "${OPENFUT_FIFA17_DISPATCH:-}" == "I_ACCEPT_GUARDED_NATIVE_DISPATCH" ]] ||
|
||||
die "launch-dispatch requires OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH"
|
||||
request_trace_enabled=1
|
||||
notifier_trace_enabled=1
|
||||
commit_enabled=1
|
||||
dispatch_enabled=1
|
||||
;;
|
||||
*) die "unknown launch mode: $mode" ;;
|
||||
esac
|
||||
@@ -207,7 +234,7 @@ launch() {
|
||||
done
|
||||
mkdir -p "${wine_prefix}/dosdevices"
|
||||
ln -sfn /mnt "${wine_prefix}/dosdevices/w:"
|
||||
note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; SBC_COMMIT=$commit_enabled); log=/tmp/fifa17-hook-m1-launch.log"
|
||||
note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; SBC_DISPATCH=$dispatch_enabled); log=/tmp/fifa17-hook-m1-launch.log"
|
||||
cd "$game_dir"
|
||||
env \
|
||||
GAMEID=fifa17 \
|
||||
@@ -218,8 +245,8 @@ launch() {
|
||||
OPENFUT_SBC_TRACE="$trace_enabled" \
|
||||
OPENFUT_SBC_REQUEST_TRACE="$request_trace_enabled" \
|
||||
OPENFUT_SBC_NOTIFIER_TRACE="$notifier_trace_enabled" \
|
||||
OPENFUT_SBC_DISPATCH=0 \
|
||||
OPENFUT_SBC_COMMIT="$commit_enabled" \
|
||||
OPENFUT_SBC_DISPATCH="$dispatch_enabled" \
|
||||
OPENFUT_SBC_DISPATCH_TRACE=0 \
|
||||
OPENFUT_SBC_ARM_ONLY=0 \
|
||||
OPENFUT_SBC_POPULATE=0 \
|
||||
umu-run _fifa17.exe 2>&1 | tee /tmp/fifa17-hook-m1-launch.log
|
||||
@@ -227,7 +254,7 @@ launch() {
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace|launch-commit]
|
||||
Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace|launch-dispatch]
|
||||
|
||||
inspect Read-only PE/hash/export preflight (default).
|
||||
build Cross-build the inert FIFA17 hook, then run inspect.
|
||||
@@ -240,11 +267,11 @@ Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launc
|
||||
Start M2 resolve-only mode (guarded reads/logging, no detours/writes); requires:
|
||||
OPENFUT_FIFA17_RESOLVE=I_ACCEPT_M2_RESOLVE_LAUNCH
|
||||
launch-trace
|
||||
Start the single M3 passive factory/deserializer trace; requires:
|
||||
Start the M3-M6 passive parser/request/notifier trace; requires:
|
||||
OPENFUT_FIFA17_TRACE=I_ACCEPT_M3_PASSIVE_TRACE
|
||||
launch-commit
|
||||
Trace and arm the SBC cache only after a validated native parse; requires:
|
||||
OPENFUT_FIFA17_COMMIT=I_ACCEPT_POST_PARSE_READY_BYTE
|
||||
launch-dispatch
|
||||
Trace and repair only a fully validated native status-999 completion; requires:
|
||||
OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH
|
||||
|
||||
Optional path overrides:
|
||||
OPENFUT_FIFA17_HOOK_DLL, OPENFUT_FIFA17_GAME_DIR,
|
||||
@@ -260,7 +287,7 @@ case "${1:-inspect}" in
|
||||
launch) launch baseline ;;
|
||||
launch-resolve) launch resolve ;;
|
||||
launch-trace) launch trace ;;
|
||||
launch-commit) launch commit ;;
|
||||
launch-dispatch) launch dispatch ;;
|
||||
-h|--help|help) usage ;;
|
||||
*) usage >&2; die "unknown command: $1" ;;
|
||||
esac
|
||||
|
||||
@@ -379,7 +379,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn honest_zero_club_items_present() {
|
||||
let g = global(&club_stats_body(&[player(90, false, None)], ContextField::Nation));
|
||||
let g = global(&club_stats_body(
|
||||
&[player(90, false, None)],
|
||||
ContextField::Nation,
|
||||
));
|
||||
for atom in [
|
||||
"stadia",
|
||||
"balls",
|
||||
@@ -411,7 +414,10 @@ mod tests {
|
||||
.filter(|r| r["contextId"] == 3 && r["contextValue"] == 13)
|
||||
.collect();
|
||||
assert_eq!(league_rows.len(), 6);
|
||||
let gold = league_rows.iter().find(|r| r["type"] == "playersGold").unwrap();
|
||||
let gold = league_rows
|
||||
.iter()
|
||||
.find(|r| r["type"] == "playersGold")
|
||||
.unwrap();
|
||||
assert_eq!(gold["typeValue"], 1);
|
||||
|
||||
// league screen -> team (teamid) buckets: players/kits/badgeDBid (3 rows).
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod item;
|
||||
pub mod non_economy;
|
||||
pub mod owned_query;
|
||||
pub mod pack_content;
|
||||
pub mod sbc;
|
||||
pub mod squad;
|
||||
pub mod squad_ext;
|
||||
pub mod squad_projection;
|
||||
|
||||
@@ -67,12 +67,19 @@ pub fn feature_off_body() -> Value {
|
||||
pub fn item_def(resource_id: i64) -> Value {
|
||||
let asset = resource_id & 0xff_ffff;
|
||||
// (name, rating, position, nation, leagueId, teamid, [6 attrs])
|
||||
let (name, rating, pos, nation, league, team, attrs): (&str, i64, &str, i64, i64, i64, [i64; 6]) =
|
||||
if asset == 20801 {
|
||||
("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80])
|
||||
} else {
|
||||
("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70])
|
||||
};
|
||||
let (name, rating, pos, nation, league, team, attrs): (
|
||||
&str,
|
||||
i64,
|
||||
&str,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
[i64; 6],
|
||||
) = if asset == 20801 {
|
||||
("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80])
|
||||
} else {
|
||||
("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70])
|
||||
};
|
||||
let attribute_list: Vec<Value> = attrs
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -365,7 +372,11 @@ pub fn user_mass_info_body(
|
||||
pub fn format_utc_datetime(epoch_secs: i64) -> String {
|
||||
let days = epoch_secs.div_euclid(86_400);
|
||||
let secs_of_day = epoch_secs.rem_euclid(86_400);
|
||||
let (hour, min, sec) = (secs_of_day / 3600, (secs_of_day % 3600) / 60, secs_of_day % 60);
|
||||
let (hour, min, sec) = (
|
||||
secs_of_day / 3600,
|
||||
(secs_of_day % 3600) / 60,
|
||||
secs_of_day % 60,
|
||||
);
|
||||
// civil_from_days: days is a count of days since 1970-01-01.
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
@@ -628,8 +639,14 @@ mod tests {
|
||||
});
|
||||
let body = user_mass_info_body(squad, 29_859_876, 0, 33_068_179, "Real FUT", "RF", "2016");
|
||||
// Flat top-level envelope.
|
||||
assert_eq!(body["pileSizeClientData"]["entries"][0], json!({"key": 2, "value": 100}));
|
||||
assert_eq!(body["pileSizeClientData"]["entries"][1], json!({"key": 4, "value": 50}));
|
||||
assert_eq!(
|
||||
body["pileSizeClientData"]["entries"][0],
|
||||
json!({"key": 2, "value": 100})
|
||||
);
|
||||
assert_eq!(
|
||||
body["pileSizeClientData"]["entries"][1],
|
||||
json!({"key": 4, "value": 50})
|
||||
);
|
||||
assert_eq!(body["settings"], json!({"configs": []}));
|
||||
assert_eq!(body["userData"], json!({}));
|
||||
// userInfo economy + club identity.
|
||||
|
||||
@@ -7,20 +7,16 @@
|
||||
//! (only card ids that resolve in BOTH the FIFA catalogue and Core content),
|
||||
//! mints the drawn cards into Core, and shapes them onto the wire.
|
||||
//!
|
||||
//! ## Parity note — Python `open_pack` / `_pack_body`
|
||||
//! (`fifa17-recon/tools/fut_store.py:689`, `utas_server.py:3474`)
|
||||
//! 1. `open_pack(price, count, gold, tiers, special_chance)` deducts coins then
|
||||
//! draws `count` items (mostly players); the reveal body wraps them verbatim.
|
||||
//! 2. Non-tiered draws split the pool at rating 75 by `gold` (`p[1] >= 75 == gold`)
|
||||
//! and fall back to the whole pool when that tier is empty (`... or PACK_POOL`).
|
||||
//! 3. Each drawn player becomes a special with probability `special_chance`
|
||||
//! (`random.random() < special_chance`).
|
||||
//! 4. `FUT_PACK_MIX` swaps ~`count // 4` players for consumables/staff extras;
|
||||
//! we deliberately OMIT that mix (Core candidates are player defs — players-only).
|
||||
//! 5. Prices/counts/odds are the OpenFUT **PLACEHOLDER** economy (the audit found
|
||||
//! them invented); only the wire *shape* is EA-observed/oracle-verified.
|
||||
//! 6. This port reproduces the count + gold-tier split + `special_chance` gate as
|
||||
//! that same PLACEHOLDER policy, drawing with replacement from the pool.
|
||||
//! ## Policy (real FUT 17 composition, DESIGNED odds)
|
||||
//!
|
||||
//! A pack draws [`PackDef::count`] cards split across rating tiers by the pack's
|
||||
//! `n_bronze`/`n_silver`/`n_gold` composition (the same numbers the tile shows in
|
||||
//! `packContentInfo`), drawing with replacement from the candidate pool. Each pick
|
||||
//! is biased toward a special version with probability `special_chance` (a DESIGNED
|
||||
//! placeholder — FUT 17 pack odds are unrecoverable). An empty tier falls back to
|
||||
//! the whole pool so a draw is always possible even when the pool lacks that tier.
|
||||
//! `FUT_PACK_MIX` (consumable/staff extras) is deliberately OMITTED — Core
|
||||
//! candidates are player defs.
|
||||
|
||||
use rand::Rng;
|
||||
|
||||
@@ -77,50 +73,83 @@ pub struct GeneratedCard {
|
||||
pub attributes: [u8; 6],
|
||||
}
|
||||
|
||||
/// Draw `pack.count` cards from `pool` with the injected RNG. Pure and
|
||||
/// deterministic under a seeded RNG. Returns an empty `Vec` (fail-closed) when
|
||||
/// the pool is empty or the pack awards no cards.
|
||||
/// Draw a pack's cards from `pool` with the injected RNG. Pure and deterministic
|
||||
/// under a seeded RNG. Returns an empty `Vec` (fail-closed) when the pool is empty
|
||||
/// or the pack awards no cards.
|
||||
///
|
||||
/// Policy (PLACEHOLDER — see the module parity note): draw with replacement from
|
||||
/// the pack's tier (`gold`), biasing each draw toward a special card with
|
||||
/// probability `special_chance`. An empty tier or partition falls back to the
|
||||
/// next-wider set so a draw is always possible when the pool is non-empty.
|
||||
/// Draws the pack's per-tier composition (`n_gold` gold-tier, `n_silver` silver,
|
||||
/// `n_bronze` bronze), biasing each pick toward a special with `special_chance`.
|
||||
/// An empty tier falls back to the whole pool (so a draw is always possible).
|
||||
pub fn generate_pack_contents(
|
||||
pack: &PackDef,
|
||||
rng: &mut impl Rng,
|
||||
pool: &[GeneratedCandidate],
|
||||
) -> Vec<GeneratedCard> {
|
||||
if pool.is_empty() || pack.count == 0 {
|
||||
if pool.is_empty() || pack.count() == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
// Tier split: a gold pack draws gold-tier candidates, a non-gold pack draws
|
||||
// non-gold; an empty tier falls back to the whole pool (oracle `... or POOL`).
|
||||
let tier: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.gold == pack.gold).collect();
|
||||
let tier: Vec<&GeneratedCandidate> = if tier.is_empty() {
|
||||
pool.iter().collect()
|
||||
} else {
|
||||
tier
|
||||
};
|
||||
// Partition the tier by special so `special_chance` can bias a draw; either
|
||||
// partition falls back to the whole tier when empty.
|
||||
let special: Vec<&GeneratedCandidate> = tier.iter().copied().filter(|c| c.special).collect();
|
||||
let normal: Vec<&GeneratedCandidate> = tier.iter().copied().filter(|c| !c.special).collect();
|
||||
let chance = pack.special_chance.clamp(0.0, 1.0);
|
||||
let all: Vec<&GeneratedCandidate> = pool.iter().collect();
|
||||
let gold: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating >= 75).collect();
|
||||
let silver: Vec<&GeneratedCandidate> = pool
|
||||
.iter()
|
||||
.filter(|c| (65..75).contains(&c.rating))
|
||||
.collect();
|
||||
let bronze: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating < 65).collect();
|
||||
|
||||
let mut out = Vec::with_capacity(pack.count as usize);
|
||||
for _ in 0..pack.count {
|
||||
let mut out = Vec::with_capacity(pack.count() as usize);
|
||||
draw_tier(&mut out, &gold, &all, pack.n_gold, pack.special_chance, rng);
|
||||
draw_tier(
|
||||
&mut out,
|
||||
&silver,
|
||||
&all,
|
||||
pack.n_silver,
|
||||
pack.special_chance,
|
||||
rng,
|
||||
);
|
||||
draw_tier(
|
||||
&mut out,
|
||||
&bronze,
|
||||
&all,
|
||||
pack.n_bronze,
|
||||
pack.special_chance,
|
||||
rng,
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// Draw `n` cards from `tier` — or the whole-pool `fallback` when `tier` is empty —
|
||||
/// biasing each pick toward a special version with probability `chance`. Either the
|
||||
/// special or normal partition falls back to the tier when empty.
|
||||
fn draw_tier(
|
||||
out: &mut Vec<GeneratedCard>,
|
||||
tier: &[&GeneratedCandidate],
|
||||
fallback: &[&GeneratedCandidate],
|
||||
n: u64,
|
||||
chance: f64,
|
||||
rng: &mut impl Rng,
|
||||
) {
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let src: &[&GeneratedCandidate] = if tier.is_empty() { fallback } else { tier };
|
||||
if src.is_empty() {
|
||||
return;
|
||||
}
|
||||
let special: Vec<&GeneratedCandidate> = src.iter().copied().filter(|c| c.special).collect();
|
||||
let normal: Vec<&GeneratedCandidate> = src.iter().copied().filter(|c| !c.special).collect();
|
||||
let chance = chance.clamp(0.0, 1.0);
|
||||
for _ in 0..n {
|
||||
let want_special = chance > 0.0 && rng.gen_bool(chance);
|
||||
let sub: &[&GeneratedCandidate] = if want_special && !special.is_empty() {
|
||||
&special
|
||||
} else if !want_special && !normal.is_empty() {
|
||||
&normal
|
||||
} else {
|
||||
&tier
|
||||
src
|
||||
};
|
||||
let pick = sub[rng.gen_range(0..sub.len())];
|
||||
out.push(pick.to_card());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -156,13 +185,22 @@ mod tests {
|
||||
]
|
||||
}
|
||||
|
||||
fn pack(id: u64, count: u64, gold: bool, special_chance: f64) -> PackDef {
|
||||
fn pack(id: u64, n_bronze: u64, n_silver: u64, n_gold: u64, special_chance: f64) -> PackDef {
|
||||
PackDef {
|
||||
id,
|
||||
name: "Test Pack",
|
||||
price: 1000,
|
||||
count,
|
||||
gold,
|
||||
n_bronze,
|
||||
n_silver,
|
||||
n_gold,
|
||||
rares: 0,
|
||||
category: if n_gold > 0 {
|
||||
"gold"
|
||||
} else if n_silver > 0 {
|
||||
"silver"
|
||||
} else {
|
||||
"bronze"
|
||||
},
|
||||
special_chance,
|
||||
owned_only: false,
|
||||
}
|
||||
@@ -171,7 +209,7 @@ mod tests {
|
||||
#[test]
|
||||
fn same_seed_same_output() {
|
||||
let pool = pool();
|
||||
let p = pack(5, 7, true, 0.3);
|
||||
let p = pack(5, 0, 0, 7, 0.3);
|
||||
let mut a = StdRng::seed_from_u64(42);
|
||||
let mut b = StdRng::seed_from_u64(42);
|
||||
assert_eq!(
|
||||
@@ -183,7 +221,7 @@ mod tests {
|
||||
#[test]
|
||||
fn different_seeds_can_diverge() {
|
||||
let pool = pool();
|
||||
let p = pack(5, 7, true, 0.3);
|
||||
let p = pack(5, 0, 0, 7, 0.3);
|
||||
let a = generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &pool);
|
||||
let b = generate_pack_contents(&p, &mut StdRng::seed_from_u64(999), &pool);
|
||||
// Not a hard guarantee, but with this pool/count the two seeds differ.
|
||||
@@ -196,7 +234,7 @@ mod tests {
|
||||
let ids: std::collections::HashSet<&str> =
|
||||
pool.iter().map(|c| c.card_id.as_str()).collect();
|
||||
for &n in &[1u64, 5, 7, 11] {
|
||||
let p = pack(6, n, true, 0.08);
|
||||
let p = pack(6, 0, 0, n, 0.08);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(n), &pool);
|
||||
assert_eq!(cards.len() as u64, n);
|
||||
for c in &cards {
|
||||
@@ -212,7 +250,7 @@ mod tests {
|
||||
#[test]
|
||||
fn gold_pack_draws_only_gold_tier() {
|
||||
let pool = pool();
|
||||
let p = pack(5, 20, true, 0.03);
|
||||
let p = pack(5, 0, 0, 20, 0.03);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
|
||||
assert!(
|
||||
cards.iter().all(|c| c.rating >= 75),
|
||||
@@ -223,7 +261,7 @@ mod tests {
|
||||
#[test]
|
||||
fn bronze_pack_draws_only_bronze_tier() {
|
||||
let pool = pool();
|
||||
let p = pack(1, 20, false, 0.005);
|
||||
let p = pack(1, 20, 0, 0, 0.005);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
|
||||
assert!(
|
||||
cards.iter().all(|c| c.rating < 75),
|
||||
@@ -239,7 +277,7 @@ mod tests {
|
||||
.filter(|c| c.special)
|
||||
.map(|c| c.card_id.as_str())
|
||||
.collect();
|
||||
let p = pack(7, 11, true, 1.0);
|
||||
let p = pack(7, 0, 0, 11, 1.0);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(3), &pool);
|
||||
assert!(cards
|
||||
.iter()
|
||||
@@ -248,7 +286,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn empty_pool_fails_closed() {
|
||||
let p = pack(5, 7, true, 0.03);
|
||||
let p = pack(5, 0, 0, 7, 0.03);
|
||||
assert!(generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &[]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
//! FIFA 17 Squad Building Challenge wire shapes.
|
||||
//!
|
||||
//! Numeric category/set/challenge ids and container types mirror the reversed FIFA 17
|
||||
//! `/sbs/*` family. Core ids remain opaque strings and are mapped here, never in Core.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub const CATEGORY_ID: i64 = 1;
|
||||
pub const CATEGORY_NAME: &str = "Foundations";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ChallengeIdentity {
|
||||
pub core_id: &'static str,
|
||||
pub set_id: i64,
|
||||
pub challenge_id: i64,
|
||||
pub priority: i64,
|
||||
}
|
||||
|
||||
pub const CHALLENGES: [ChallengeIdentity; 2] = [
|
||||
ChallengeIdentity {
|
||||
core_id: "sbc_bronze_upgrade",
|
||||
set_id: 1,
|
||||
challenge_id: 101,
|
||||
priority: 1,
|
||||
},
|
||||
ChallengeIdentity {
|
||||
core_id: "sbc_hybrid_nations",
|
||||
set_id: 2,
|
||||
challenge_id: 201,
|
||||
priority: 2,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ChallengeView {
|
||||
pub identity: ChallengeIdentity,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub repeatable: bool,
|
||||
pub times_completed: i64,
|
||||
}
|
||||
|
||||
pub fn identity_for_core(core_id: &str) -> Option<ChallengeIdentity> {
|
||||
CHALLENGES
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|entry| entry.core_id == core_id)
|
||||
}
|
||||
|
||||
pub fn identity_for_challenge(challenge_id: i64) -> Option<ChallengeIdentity> {
|
||||
CHALLENGES
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|entry| entry.challenge_id == challenge_id)
|
||||
}
|
||||
|
||||
/// `GET sbs/sets`: object root; categories, sets and awards are always arrays.
|
||||
pub fn sets_body(challenges: &[ChallengeView]) -> Value {
|
||||
let sets: Vec<Value> = challenges
|
||||
.iter()
|
||||
.map(|challenge| {
|
||||
json!({
|
||||
"setId": challenge.identity.set_id,
|
||||
"categoryId": CATEGORY_ID,
|
||||
"name": challenge.name,
|
||||
"description": challenge.description,
|
||||
"priority": challenge.identity.priority,
|
||||
"challengesCount": 1,
|
||||
"challengesCompletedCount": i64::from(!challenge.repeatable && challenge.times_completed > 0),
|
||||
"awards": [],
|
||||
"hidden": false,
|
||||
"endTime": 4_102_444_800_i64
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"categories": [{
|
||||
"categoryId": CATEGORY_ID,
|
||||
"name": CATEGORY_NAME,
|
||||
"priority": 1,
|
||||
"sets": sets
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
/// `GET sbs/setId/{id}/challenges`: object root with a challenge array.
|
||||
pub fn challenges_body(set_id: i64, challenges: &[ChallengeView]) -> Value {
|
||||
let records: Vec<Value> = challenges
|
||||
.iter()
|
||||
.filter(|challenge| challenge.identity.set_id == set_id)
|
||||
.map(|challenge| {
|
||||
// A repeatable challenge is always available to enter again. The FIFA 17
|
||||
// client gates challenge re-entry on `timesCompleted` (not on `repeatable`):
|
||||
// a nonzero count renders the tile COMPLETED and refuses re-entry. So a
|
||||
// repeatable challenge never reports itself as terminally completed here.
|
||||
// Core keeps the true completion record (economy authority); this is
|
||||
// presentation only. Live-proven on the retail client 2026-08-18.
|
||||
let times_completed = if challenge.repeatable {
|
||||
0
|
||||
} else {
|
||||
challenge.times_completed
|
||||
};
|
||||
json!({
|
||||
"challengeId": challenge.identity.challenge_id,
|
||||
"setId": challenge.identity.set_id,
|
||||
"categoryId": CATEGORY_ID,
|
||||
"index": 0,
|
||||
"type": "OPEN_CHALLENGE",
|
||||
"name": challenge.name,
|
||||
"description": challenge.description,
|
||||
"challengeImageId": "",
|
||||
"formation": "f442",
|
||||
"endTime": 0,
|
||||
"repeatable": challenge.repeatable,
|
||||
"trophyId": 0,
|
||||
"status": "OPEN",
|
||||
"timesCompleted": times_completed,
|
||||
"awards": [],
|
||||
// `elgReq` stays empty deliberately. Reversed from the pinned CardsDLL
|
||||
// (2026-08-19, see ENDPOINT_MAP.md "Shared record shapes"): the client
|
||||
// consumes `eligibilityKey`/`eligibilityOperation` only as ordinals that
|
||||
// index the packed locale (`LOC_SBC_ELG_KEY_%d`) to render requirement
|
||||
// text — it does NOT validate on them. The ordinal->string map is not
|
||||
// recoverable from any asset we have, so any value we emit would show the
|
||||
// WRONG requirement to the player. Submission is validated server-side by
|
||||
// Core regardless; leaving this empty is display-only, never a correctness
|
||||
// gap. Populate ONLY once the locale ordinal map is captured.
|
||||
"elgReq": []
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({ "challenges": records })
|
||||
}
|
||||
|
||||
/// Empty-body POST starts a challenge. `squad` is object-root on this response class.
|
||||
pub fn start_body(challenge_id: i64) -> Value {
|
||||
json!({
|
||||
"challengeId": challenge_id,
|
||||
"squad": {},
|
||||
"playerRequirements": []
|
||||
})
|
||||
}
|
||||
|
||||
/// GET challenge squad. The reversed response requires array containers.
|
||||
pub fn squad_body(challenge_id: i64, wire_item_ids: &[i64]) -> Value {
|
||||
let squad: Vec<Value> = wire_item_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, id)| {
|
||||
json!({
|
||||
"index": index,
|
||||
"itemData": { "id": id },
|
||||
"kitNumber": 0
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"id": challenge_id,
|
||||
"squad": squad,
|
||||
"playerRequirements": []
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save_body(challenge_id: i64) -> Value {
|
||||
json!({ "id": challenge_id })
|
||||
}
|
||||
|
||||
pub fn submit_body(challenge_id: i64, set_id: i64, credits: i64, unopened_packs: i64) -> Value {
|
||||
json!({
|
||||
"challengeId": challenge_id,
|
||||
"setId": set_id,
|
||||
"credits": credits,
|
||||
"preOrderPacks": 0,
|
||||
"recoveredPacks": unopened_packs,
|
||||
"grantedChallengeAwards": [],
|
||||
"grantedSetAwards": []
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum SbcWireError {
|
||||
Json(String),
|
||||
MissingSquad,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SbcWireError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Json(error) => write!(formatter, "invalid SBC squad JSON: {error}"),
|
||||
Self::MissingSquad => {
|
||||
formatter.write_str("SBC squad body contains no supported squad container")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SbcWireError {}
|
||||
|
||||
/// Extract FIFA wire item ids from a saved/submitted challenge squad.
|
||||
///
|
||||
/// The exact retail challenge-squad body is now captured (2026-08-18 Gate C,
|
||||
/// `PUT /ut/game/fifa17/sbs/challenge/101/squad`): a fixed 23-entry `players`
|
||||
/// array of `{index, itemData:{id, dream}}` (empty slots carry `id == 0`),
|
||||
/// alongside sibling `chemistry`, `rating`, `formation`, and a `manager` array
|
||||
/// of `{id, dream}`. Only `players[].itemData.id` selects the consumed cards;
|
||||
/// the manager, chemistry, rating, formation, dream, and index fields are
|
||||
/// presentation/validation hints and are never consumed. The `squad` array
|
||||
/// container remains accepted for the alternate proven shape. Within either,
|
||||
/// only non-zero `itemData.id` values are interpreted.
|
||||
pub fn parse_wire_item_ids(body: &[u8]) -> Result<Vec<i64>, SbcWireError> {
|
||||
let root: Value =
|
||||
serde_json::from_slice(body).map_err(|error| SbcWireError::Json(error.to_string()))?;
|
||||
let entries = root
|
||||
.get("players")
|
||||
.and_then(Value::as_array)
|
||||
.or_else(|| root.get("squad").and_then(Value::as_array))
|
||||
.ok_or(SbcWireError::MissingSquad)?;
|
||||
|
||||
let mut ids = Vec::new();
|
||||
for entry in entries {
|
||||
if let Some(id) = entry
|
||||
.get("itemData")
|
||||
.and_then(|item| item.get("id"))
|
||||
.and_then(Value::as_i64)
|
||||
.filter(|id| *id != 0)
|
||||
{
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn challenge() -> ChallengeView {
|
||||
ChallengeView {
|
||||
identity: CHALLENGES[0],
|
||||
name: "Bronze Upgrade".into(),
|
||||
description: "Submit players".into(),
|
||||
repeatable: true,
|
||||
times_completed: 2,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_containers_match_reversed_fifa17_shapes() {
|
||||
let sets = sets_body(&[challenge()]);
|
||||
assert!(sets.is_object());
|
||||
assert!(sets["categories"].is_array());
|
||||
assert!(sets["categories"][0]["sets"].is_array());
|
||||
assert!(sets["categories"][0]["sets"][0]["awards"].is_array());
|
||||
|
||||
let challenges = challenges_body(1, &[challenge()]);
|
||||
assert!(challenges.is_object());
|
||||
assert!(challenges["challenges"].is_array());
|
||||
assert!(challenges["challenges"][0]["awards"].is_array());
|
||||
assert!(challenges["challenges"][0]["elgReq"].is_array());
|
||||
|
||||
assert!(start_body(101)["squad"].is_object());
|
||||
assert!(start_body(101)["playerRequirements"].is_array());
|
||||
assert!(squad_body(101, &[100_000_001])["squad"].is_array());
|
||||
let submit = submit_body(101, 1, 1234, 1);
|
||||
assert!(submit["grantedChallengeAwards"].is_array());
|
||||
assert!(submit["grantedSetAwards"].is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeatable_completed_challenge_stays_enterable() {
|
||||
// The FIFA 17 client refuses challenge re-entry when timesCompleted > 0, so
|
||||
// a repeatable challenge must always project as not-yet-completed while a
|
||||
// non-repeatable one keeps its true count. Core holds the real record.
|
||||
let repeatable = ChallengeView {
|
||||
identity: CHALLENGES[0],
|
||||
name: "Bronze Upgrade".into(),
|
||||
description: "Submit players".into(),
|
||||
repeatable: true,
|
||||
times_completed: 3,
|
||||
};
|
||||
let once = ChallengeView {
|
||||
identity: CHALLENGES[1],
|
||||
name: "Hybrid Nations".into(),
|
||||
description: "Submit a hybrid squad".into(),
|
||||
repeatable: false,
|
||||
times_completed: 1,
|
||||
};
|
||||
|
||||
let repeatable_view = challenges_body(CHALLENGES[0].set_id, &[repeatable.clone()]);
|
||||
assert_eq!(repeatable_view["challenges"][0]["timesCompleted"], 0);
|
||||
assert_eq!(repeatable_view["challenges"][0]["status"], "OPEN");
|
||||
let once_view = challenges_body(CHALLENGES[1].set_id, &[once.clone()]);
|
||||
assert_eq!(once_view["challenges"][0]["timesCompleted"], 1);
|
||||
|
||||
let sets = sets_body(&[repeatable, once]);
|
||||
let sets_arr = sets["categories"][0]["sets"].as_array().unwrap();
|
||||
let repeatable_set = sets_arr
|
||||
.iter()
|
||||
.find(|set| set["setId"] == CHALLENGES[0].set_id)
|
||||
.unwrap();
|
||||
let once_set = sets_arr
|
||||
.iter()
|
||||
.find(|set| set["setId"] == CHALLENGES[1].set_id)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
repeatable_set["challengesCompletedCount"], 0,
|
||||
"a repeatable set never reports itself terminally completed"
|
||||
);
|
||||
assert_eq!(
|
||||
once_set["challengesCompletedCount"], 1,
|
||||
"a one-shot set counts its single completion"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_accepts_only_known_squad_containers_and_item_ids() {
|
||||
let normal = br#"{"players":[{"index":0,"itemData":{"id":100000001}},{"index":1,"itemData":{"id":0}}]}"#;
|
||||
assert_eq!(parse_wire_item_ids(normal).unwrap(), [100_000_001]);
|
||||
let sbc = br#"{"squad":[{"itemData":{"id":100000002}}]}"#;
|
||||
assert_eq!(parse_wire_item_ids(sbc).unwrap(), [100_000_002]);
|
||||
assert_eq!(
|
||||
parse_wire_item_ids(br#"{"challengeId":101}"#),
|
||||
Err(SbcWireError::MissingSquad)
|
||||
);
|
||||
assert!(matches!(
|
||||
parse_wire_item_ids(br#"{"squad":"not-an-array"}"#),
|
||||
Err(SbcWireError::MissingSquad)
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_wire_item_ids(br#"{"squad":["#),
|
||||
Err(SbcWireError::Json(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_matches_captured_retail_challenge_squad_body() {
|
||||
// Verbatim shape from the 2026-08-18 Gate C retail capture of
|
||||
// PUT /ut/game/fifa17/sbs/challenge/101/squad (11 filled + 12 empty
|
||||
// slots, plus manager/chemistry/rating/formation siblings). Only the 11
|
||||
// non-zero player itemData.id values are consumed, in wire order; the
|
||||
// manager and all presentation fields are ignored.
|
||||
let retail = br#"{"chemistry":21,"rating":86,"formation":"f433",
|
||||
"manager":[{"id":100000427,"dream":false}],
|
||||
"players":[
|
||||
{"index":0,"itemData":{"id":100004227,"dream":false}},
|
||||
{"index":1,"itemData":{"id":100004233,"dream":false}},
|
||||
{"index":2,"itemData":{"id":100001317,"dream":false}},
|
||||
{"index":3,"itemData":{"id":100001531,"dream":false}},
|
||||
{"index":4,"itemData":{"id":100000966,"dream":false}},
|
||||
{"index":5,"itemData":{"id":100001947,"dream":false}},
|
||||
{"index":6,"itemData":{"id":100000169,"dream":false}},
|
||||
{"index":7,"itemData":{"id":100002017,"dream":false}},
|
||||
{"index":8,"itemData":{"id":100002765,"dream":false}},
|
||||
{"index":9,"itemData":{"id":100000147,"dream":false}},
|
||||
{"index":10,"itemData":{"id":100000311,"dream":false}},
|
||||
{"index":11,"itemData":{"id":0,"dream":false}},
|
||||
{"index":12,"itemData":{"id":0,"dream":false}},
|
||||
{"index":13,"itemData":{"id":0,"dream":false}},
|
||||
{"index":14,"itemData":{"id":0,"dream":false}},
|
||||
{"index":15,"itemData":{"id":0,"dream":false}},
|
||||
{"index":16,"itemData":{"id":0,"dream":false}},
|
||||
{"index":17,"itemData":{"id":0,"dream":false}},
|
||||
{"index":18,"itemData":{"id":0,"dream":false}},
|
||||
{"index":19,"itemData":{"id":0,"dream":false}},
|
||||
{"index":20,"itemData":{"id":0,"dream":false}},
|
||||
{"index":21,"itemData":{"id":0,"dream":false}},
|
||||
{"index":22,"itemData":{"id":0,"dream":false}}
|
||||
]}"#;
|
||||
assert_eq!(
|
||||
parse_wire_item_ids(retail).unwrap(),
|
||||
[
|
||||
100_004_227,
|
||||
100_004_233,
|
||||
100_001_317,
|
||||
100_001_531,
|
||||
100_000_966,
|
||||
100_001_947,
|
||||
100_000_169,
|
||||
100_002_017,
|
||||
100_002_765,
|
||||
100_000_147,
|
||||
100_000_311
|
||||
],
|
||||
"exactly the 11 non-zero players in wire order; manager and empty slots ignored"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,92 +1,217 @@
|
||||
//! FIFA 17 Store pack catalogue + `/store/purchasegroup` wire shaping.
|
||||
//!
|
||||
//! A faithful Rust port of the Python oracle's `PACK_CATALOG` + `_pack_body` +
|
||||
//! `store_catalog` assembly (`fifa17-recon/tools/{fut_store,utas_server}.py`) at the
|
||||
//! **production flag defaults** (`FUT_STORE_DISPLAYGROUP=1` on, `FUT_STORE_GROUPID=0`
|
||||
//! off, `FUT_PRICE_PROBE=0` off). Parity is pinned by differential fixtures generated
|
||||
//! from the Python oracle (`tests/fixtures/purchasegroup_*.json`).
|
||||
//! Rust is the authoritative store owner: [`build_purchasegroup`] is served live
|
||||
//! by the host via `EconomyRoute::PurchaseGroup` over Core economy authority (Core
|
||||
//! owns coins + unopened packs), so there is no Python dependency and no
|
||||
//! dual-write/split-brain.
|
||||
//!
|
||||
//! ## Scope / split-brain safety
|
||||
//! ## Relationship to the Python oracle
|
||||
//!
|
||||
//! This is **pure wire shaping** — no economy state, no IO. [`build_purchasegroup`]
|
||||
//! is a function of `(owned unopened pack ids, empty-My-Packs StoreMode)`. It is
|
||||
//! deliberately **not yet wired** into the live host: serving purchasegroup from Rust
|
||||
//! requires an authoritative Rust owner of `unopenedPackIds`, and today Python is the
|
||||
//! single writer of coins + unopened packs (BUY, quick-sell, rewards). Wiring this
|
||||
//! before that economy authority exists would create a dual-write/split-brain. See
|
||||
//! the R3 economy-authority prerequisite in the vault (`Rust UTAS Migration`).
|
||||
//! The wire *shape* was RE'd from the client and cross-checked against the Python
|
||||
//! oracle's `_pack_body`/`store_catalog`
|
||||
//! (`fifa17-recon/tools/{fut_store,utas_server}.py`). Rust now diverges from the
|
||||
//! oracle where the RE proved the oracle wrong: it does NOT emit `extPrice`, whose
|
||||
//! parser side-effect creates an `"mtx"` currency row and switches on the broken
|
||||
//! `or %1s` FIFA-Points tile line (plan-2026-08-05-store-subsystem.md §3.4). The
|
||||
//! Python oracle stays the rollback baseline and is never modified; the
|
||||
//! `tests/fixtures/purchasegroup_*.json` goldens pin Rust's authoritative output.
|
||||
//!
|
||||
//! ## Economy-parameter provenance
|
||||
//!
|
||||
//! Prices, counts and odds are the current OpenFUT **PLACEHOLDER** economy, NOT
|
||||
//! EA-authentic (the overnight audit established the store economy is invented). The
|
||||
//! wire *shape* is EA-observed/oracle-verified; the *numbers* are placeholders.
|
||||
//! Pack prices and tier composition are the real always-available FUT 17
|
||||
//! regular-store packs (community-documented on fifauteam). Pack ODDS
|
||||
//! (`special_chance`) are DESIGNED placeholders, NOT EA-authentic — EA never
|
||||
//! published FUT 17 pack probabilities. The wire *shape* is EA-observed/RE-verified.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::store_session::{StoreMode, SENTINEL_PACK_ID};
|
||||
|
||||
/// A FIFA 17 Store pack definition. Wire shape is oracle-verified; the economy
|
||||
/// numbers (`price`/`count`/`special_chance`) are OpenFUT PLACEHOLDER, not EA-authentic.
|
||||
/// A FIFA 17 Store pack definition. The wire *shape* is RE-verified; the price and
|
||||
/// per-tier composition are the real always-available FUT 17 regular-store packs,
|
||||
/// with DESIGNED (not EA-authentic) `special_chance` odds.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PackDef {
|
||||
pub id: u64,
|
||||
pub name: &'static str,
|
||||
pub price: u64,
|
||||
pub count: u64,
|
||||
pub gold: bool,
|
||||
/// Cards awarded per rating-tier band: bronze `< 65`, silver `65..=74`, gold
|
||||
/// `>= 75`. These are ALSO the wire `packContentInfo` per-tier quantities, so a
|
||||
/// pack's displayed composition matches what its generator draws.
|
||||
pub n_bronze: u64,
|
||||
pub n_silver: u64,
|
||||
pub n_gold: u64,
|
||||
/// `rareQuantity` shown on the tile (wire display only).
|
||||
pub rares: u64,
|
||||
/// StoreFront category token (`displayGroup.value`): one of the six hard-coded
|
||||
/// client tokens — here `"bronze"`, `"silver"` or `"gold"`.
|
||||
pub category: &'static str,
|
||||
/// Per-draw probability the awarded card is a special version (DESIGNED
|
||||
/// placeholder; FUT 17 odds are unrecoverable).
|
||||
pub special_chance: f64,
|
||||
/// Reward-only pack (no purchase path): excluded from the normal catalogue,
|
||||
/// rendered only when owned (in `unopenedPackIds`).
|
||||
pub owned_only: bool,
|
||||
}
|
||||
|
||||
/// The current supported FIFA 17 pack catalogue (`fut_store.py:820`). Only observed/
|
||||
/// currently-supported ids. The 65534 sentinel is deliberately ABSENT — it is a
|
||||
/// compatibility shim, never a catalogue pack (never purchasable/openable).
|
||||
impl PackDef {
|
||||
/// Total cards awarded / wire `itemQuantity` — the sum of the per-tier counts.
|
||||
pub fn count(&self) -> u64 {
|
||||
self.n_bronze + self.n_silver + self.n_gold
|
||||
}
|
||||
}
|
||||
|
||||
/// The always-available FIFA 17 FUT regular-store packs (real fifauteam-documented
|
||||
/// prices + tier composition), plus the OpenFUT reward pack. Two packs per client
|
||||
/// category (bronze/silver/gold), which the client renders as separate buyable tiles
|
||||
/// on drill-in. The 65534 sentinel is deliberately ABSENT — a compatibility shim,
|
||||
/// never purchasable/openable.
|
||||
pub const PACK_CATALOG: &[PackDef] = &[
|
||||
// ── Bronze category ──
|
||||
PackDef {
|
||||
id: 1,
|
||||
name: "Bronze Pack",
|
||||
price: 400,
|
||||
count: 5,
|
||||
gold: false,
|
||||
special_chance: 0.005,
|
||||
n_bronze: 10,
|
||||
n_silver: 2,
|
||||
n_gold: 0,
|
||||
rares: 1,
|
||||
category: "bronze",
|
||||
special_chance: 0.01,
|
||||
owned_only: false,
|
||||
},
|
||||
PackDef {
|
||||
id: 2,
|
||||
name: "Premium Bronze Pack",
|
||||
price: 750,
|
||||
n_bronze: 10,
|
||||
n_silver: 2,
|
||||
n_gold: 0,
|
||||
rares: 3,
|
||||
category: "bronze",
|
||||
special_chance: 0.02,
|
||||
owned_only: false,
|
||||
},
|
||||
// ── Silver category ──
|
||||
PackDef {
|
||||
id: 3,
|
||||
name: "Silver Pack",
|
||||
price: 2500,
|
||||
n_bronze: 1,
|
||||
n_silver: 11,
|
||||
n_gold: 0,
|
||||
rares: 1,
|
||||
category: "silver",
|
||||
special_chance: 0.015,
|
||||
owned_only: false,
|
||||
},
|
||||
PackDef {
|
||||
id: 4,
|
||||
name: "Premium Silver Pack",
|
||||
price: 3750,
|
||||
n_bronze: 1,
|
||||
n_silver: 11,
|
||||
n_gold: 0,
|
||||
rares: 3,
|
||||
category: "silver",
|
||||
special_chance: 0.03,
|
||||
owned_only: false,
|
||||
},
|
||||
// ── Gold category ──
|
||||
PackDef {
|
||||
id: 5,
|
||||
name: "Gold Pack",
|
||||
price: 5000,
|
||||
count: 7,
|
||||
gold: true,
|
||||
special_chance: 0.03,
|
||||
n_bronze: 0,
|
||||
n_silver: 2,
|
||||
n_gold: 10,
|
||||
rares: 1,
|
||||
category: "gold",
|
||||
special_chance: 0.04,
|
||||
owned_only: false,
|
||||
},
|
||||
PackDef {
|
||||
id: 6,
|
||||
name: "Premium Gold",
|
||||
price: 15000,
|
||||
count: 11,
|
||||
gold: true,
|
||||
special_chance: 0.08,
|
||||
owned_only: false,
|
||||
},
|
||||
PackDef {
|
||||
id: 7,
|
||||
name: "Special Players Pack",
|
||||
price: 25000,
|
||||
count: 11,
|
||||
gold: true,
|
||||
special_chance: 1.0,
|
||||
name: "Premium Gold Pack",
|
||||
price: 7500,
|
||||
n_bronze: 0,
|
||||
n_silver: 2,
|
||||
n_gold: 10,
|
||||
rares: 3,
|
||||
category: "gold",
|
||||
special_chance: 0.06,
|
||||
owned_only: false,
|
||||
},
|
||||
// ── Reward (owned-only; opened from My Packs, never coin-purchasable) ──
|
||||
PackDef {
|
||||
id: 70,
|
||||
name: "Reward Special Players Pack",
|
||||
name: "Reward Gold Pack",
|
||||
price: 0,
|
||||
count: 11,
|
||||
gold: true,
|
||||
n_bronze: 0,
|
||||
n_silver: 0,
|
||||
n_gold: 11,
|
||||
rares: 11,
|
||||
category: "gold",
|
||||
special_chance: 1.0,
|
||||
owned_only: true,
|
||||
},
|
||||
PackDef {
|
||||
id: 71,
|
||||
name: "Bronze Pack",
|
||||
price: 0,
|
||||
n_bronze: 10,
|
||||
n_silver: 2,
|
||||
n_gold: 0,
|
||||
rares: 1,
|
||||
category: "bronze",
|
||||
special_chance: 0.01,
|
||||
owned_only: true,
|
||||
},
|
||||
PackDef {
|
||||
id: 72,
|
||||
name: "Silver Pack",
|
||||
price: 0,
|
||||
n_bronze: 1,
|
||||
n_silver: 11,
|
||||
n_gold: 0,
|
||||
rares: 1,
|
||||
category: "silver",
|
||||
special_chance: 0.02,
|
||||
owned_only: true,
|
||||
},
|
||||
PackDef {
|
||||
id: 73,
|
||||
name: "Gold Pack",
|
||||
price: 0,
|
||||
n_bronze: 0,
|
||||
n_silver: 2,
|
||||
n_gold: 10,
|
||||
rares: 1,
|
||||
category: "gold",
|
||||
special_chance: 0.05,
|
||||
owned_only: true,
|
||||
},
|
||||
PackDef {
|
||||
id: 74,
|
||||
name: "Rare Gold Pack",
|
||||
price: 0,
|
||||
n_bronze: 0,
|
||||
n_silver: 2,
|
||||
n_gold: 10,
|
||||
rares: 3,
|
||||
category: "gold",
|
||||
special_chance: 0.10,
|
||||
owned_only: true,
|
||||
},
|
||||
PackDef {
|
||||
id: 75,
|
||||
name: "Icon Pack",
|
||||
price: 0,
|
||||
n_bronze: 0,
|
||||
n_silver: 0,
|
||||
n_gold: 12,
|
||||
rares: 12,
|
||||
category: "gold",
|
||||
special_chance: 1.0,
|
||||
owned_only: true,
|
||||
},
|
||||
@@ -97,27 +222,88 @@ pub fn pack_by_id(id: u64) -> Option<&'static PackDef> {
|
||||
PACK_CATALOG.iter().find(|p| p.id == id)
|
||||
}
|
||||
|
||||
/// The FIFA17 StoreFront category token for a NORMAL pack tile (`utas_server.py:3579`):
|
||||
/// one of the six hard-coded tokens the client resolves.
|
||||
fn category(p: &PackDef) -> &'static str {
|
||||
if p.special_chance >= 1.0 {
|
||||
"special"
|
||||
} else if p.gold {
|
||||
"gold"
|
||||
} else {
|
||||
"bronze"
|
||||
/// Resolve a Core entitlement's opaque `definition_id` to the FIFA 17 numeric
|
||||
/// owned-only pack it renders and opens as. Accepts a numeric id (imported
|
||||
/// entitlements, e.g. `"70"`) or one of the symbolic reward-pack names Core's
|
||||
/// reward services grant (SBC, draft, season, check-in, FUT Champions). Only
|
||||
/// owned-only packs qualify, so an unknown or non-reward entitlement resolves to
|
||||
/// `None` and is simply not shown as an openable pack rather than faked.
|
||||
pub fn owned_pack_id_for_definition(definition_id: &str) -> Option<u64> {
|
||||
if let Ok(numeric) = definition_id.parse::<u64>() {
|
||||
return pack_by_id(numeric)
|
||||
.filter(|pack| pack.owned_only)
|
||||
.map(|pack| pack.id);
|
||||
}
|
||||
let id = match definition_id {
|
||||
"bronze_pack" => 71,
|
||||
"silver_pack" => 72,
|
||||
"gold_pack" => 73,
|
||||
"rare_gold_pack" => 74,
|
||||
"icon_pack" => 75,
|
||||
_ => return None,
|
||||
};
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// The FIFA 17 StoreFront category token for a pack tile (`displayGroup.value`):
|
||||
/// one of the six hard-coded tokens the client resolves. Each catalogue pack
|
||||
/// carries its own token; owned/reward packs take `mypacks` instead (see
|
||||
/// [`pack_body`]).
|
||||
fn category(p: &PackDef) -> &'static str {
|
||||
p.category
|
||||
}
|
||||
|
||||
/// One `purchase[]` entry — the faithful `_pack_body` port (`utas_server.py:3474`) at
|
||||
/// production flag defaults. `owned` packs (My Packs / reward / sentinel) drop the
|
||||
/// purchase fields and take the `mypacks` display group.
|
||||
pub fn pack_body(p: &PackDef, idx: u64, owned: bool) -> Value {
|
||||
let mtx = std::cmp::max(1, p.price / 100);
|
||||
let pack_type = match p.category {
|
||||
"gold" => "GOLD",
|
||||
"silver" => "SILVER",
|
||||
_ => "BRONZE",
|
||||
};
|
||||
// Group background texture: FIFA 17's all-groups landing (shown on first store
|
||||
// entry) renders one tile per group whose art is the client-bundled
|
||||
// `packs_backgrounds_%d.dds` selected by this field. LIVE-PROBED 2026-08-18:
|
||||
// index 0 is blank; 1/2/3 render real pack art — so assign non-zero per
|
||||
// category and that landing shows native art instead of blank shields. The
|
||||
// persistent tabbed store draws its pack art from the packs themselves and
|
||||
// does not depend on this.
|
||||
let group_bg: u64 = if owned {
|
||||
3
|
||||
} else {
|
||||
match p.category {
|
||||
"gold" => 3,
|
||||
"silver" => 2,
|
||||
_ => 1,
|
||||
}
|
||||
};
|
||||
// My Packs cover art. LIVE-MAPPED on the retail client 2026-08-18 across all
|
||||
// three store tabs and two reward tiles:
|
||||
// * `assetId` only gates whether art renders AT ALL. A reward pack's own id
|
||||
// (70-75) is not a known client asset, so its tile renders BLANK; any valid
|
||||
// catalogue asset (1-6) makes art appear.
|
||||
// * WHICH art is drawn comes from `packType` + `packContentInfo.rareQuantity`,
|
||||
// not from `assetId`: BRONZE+1rare -> bronze card, BRONZE+3 -> silver,
|
||||
// SILVER+1 -> gold, SILVER+3 -> silver trio, GOLD+1 -> blue special,
|
||||
// GOLD+3 -> red inform. (Remapping assetId 5->3 and 3->2 left both frames
|
||||
// unchanged and only rotated the featured player, which proves this.)
|
||||
// So a reward tile automatically shows the same art as the equivalent
|
||||
// purchasable pack; we only need a valid asset, and we use the tier's own store
|
||||
// pack for clarity. `id` stays the pack's own id (the open packId / SERVER_ID).
|
||||
let art_asset: u64 = if owned {
|
||||
match p.category {
|
||||
"gold" => 5,
|
||||
"silver" => 3,
|
||||
_ => 1,
|
||||
}
|
||||
} else {
|
||||
p.id
|
||||
};
|
||||
let mut body = json!({
|
||||
"assetId": p.id,
|
||||
"assetId": art_asset,
|
||||
"id": p.id,
|
||||
"packType": if p.gold { "GOLD" } else { "BRONZE" },
|
||||
"packType": pack_type,
|
||||
"description": p.name,
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
@@ -127,26 +313,37 @@ pub fn pack_body(p: &PackDef, idx: u64, owned: bool) -> Value {
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": idx,
|
||||
"displayGroupAssetId": group_bg,
|
||||
// The tile renders `finalFunds` as its coin price; the HUD balance reads
|
||||
// `funds` from the /credits currencies array. We keep the pair equal.
|
||||
//
|
||||
// NO `extPrice`. Its parser has a SIDE EFFECT: `finalPrice`/`originalPrice`
|
||||
// both CREATE an `"mtx"` currency row, which the tile adapter reads as "has
|
||||
// a real-money price" and switches on the broken `or %1s` label — the
|
||||
// Origin/Dime commerce catalogue that would fill it no longer exists
|
||||
// offline, so every string stays at its constructor default. Omitting the
|
||||
// key is the documented fix (plan-2026-08-05-store-subsystem.md §3.4 /
|
||||
// experiment #4): it strictly reduces executed client code and leaves every
|
||||
// tile buyable. LIVE-observed `or %1s` on the Store tiles, 2026-08-18.
|
||||
"currencies": [{ "name": "coins", "funds": p.price, "finalFunds": p.price }],
|
||||
"extPrice": {
|
||||
"finalPrice": { "amount": mtx, "currency": "mtx" },
|
||||
"originalPrice": { "amount": mtx, "currency": "mtx" },
|
||||
},
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": if p.gold { 0 } else { p.count },
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": if p.gold { p.count } else { 0 },
|
||||
"rareQuantity": if p.gold { p.count } else { 0 },
|
||||
"itemQuantity": p.count,
|
||||
"bronzeQuantity": p.n_bronze,
|
||||
"silverQuantity": p.n_silver,
|
||||
"goldQuantity": p.n_gold,
|
||||
"rareQuantity": p.rares,
|
||||
"itemQuantity": p.count(),
|
||||
},
|
||||
"unopened": owned,
|
||||
});
|
||||
let obj = body.as_object_mut().expect("pack body is a JSON object");
|
||||
if owned {
|
||||
// Reward/My-Packs tiles have no purchase path; leaving zero-value coin/mtx
|
||||
// objects makes the client render the price label as literal "undefined".
|
||||
obj.remove("currencies");
|
||||
obj.remove("extPrice");
|
||||
// Reward/My-Packs tiles KEEP the coins currency at the pack price (0 for
|
||||
// reward packs). My Packs opens through the store purchase flow, so a tile
|
||||
// with no currency row is not actionable — clicking navigates instead of
|
||||
// opening. With a free coin row the client sends POST /purchased and the
|
||||
// server (owned-only) opens it for free. No extPrice (that is the `or %1s`
|
||||
// mtx bug), so the free coin row formats as "0", not an unavailable label.
|
||||
// LIVE-PROVEN 2026-08-18: a reward Silver Pack opened and revealed cards.
|
||||
obj.insert(
|
||||
"displayGroup".into(),
|
||||
json!({ "value": "mypacks", "priority": idx }),
|
||||
@@ -165,8 +362,11 @@ pub fn sentinel_body(idx: u64) -> Value {
|
||||
id: SENTINEL_PACK_ID,
|
||||
name: "",
|
||||
price: 0,
|
||||
count: 0,
|
||||
gold: true,
|
||||
n_bronze: 0,
|
||||
n_silver: 0,
|
||||
n_gold: 0,
|
||||
rares: 0,
|
||||
category: "gold",
|
||||
special_chance: 0.0,
|
||||
owned_only: true,
|
||||
};
|
||||
@@ -176,13 +376,19 @@ pub fn sentinel_body(idx: u64) -> Value {
|
||||
.expect("sentinel body is a JSON object");
|
||||
obj.insert("state".into(), json!("active"));
|
||||
obj.insert("unopened".into(), json!(false));
|
||||
// The sentinel must stay non-openable (it is only a resolve-without-crash shim
|
||||
// for an empty My Packs), so it keeps no purchase path.
|
||||
obj.remove("currencies");
|
||||
// Keep the sentinel's own id as its asset: it must render as an inert blank
|
||||
// placeholder, never borrow a real pack's cover.
|
||||
obj.insert("assetId".into(), json!(SENTINEL_PACK_ID));
|
||||
body
|
||||
}
|
||||
|
||||
/// Build the full `/store/purchasegroup` body from the authoritative unopened-pack ids
|
||||
/// and the frozen empty-My-Packs mode. Pure — mirrors `store_catalog` (`3627`):
|
||||
/// normal packs (1,5,6,7) first, then any owned packs, then the empty-My-Packs shim
|
||||
/// (sentinel for [`StoreMode::Sentinel`], nothing for [`StoreMode::CleanV1`]).
|
||||
/// Build the full `/store/purchasegroup` body from the authoritative unopened-pack
|
||||
/// ids and the frozen empty-My-Packs mode. Pure: the six regular packs (ids 1–6)
|
||||
/// first, then any owned packs, then the empty-My-Packs shim (sentinel for
|
||||
/// [`StoreMode::Sentinel`], nothing for [`StoreMode::CleanV1`]).
|
||||
pub fn build_purchasegroup(unopened_ids: &[u64], mode: StoreMode) -> Value {
|
||||
let mut packs: Vec<Value> = PACK_CATALOG
|
||||
.iter()
|
||||
@@ -203,10 +409,11 @@ pub fn build_purchasegroup(unopened_ids: &[u64], mode: StoreMode) -> Value {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Differential parity against the Python oracle. The fixtures under
|
||||
//! `tests/fixtures/purchasegroup_*.json` are generated by calling the oracle's
|
||||
//! `_pack_body`/`store_catalog` at production flag defaults; Rust must match
|
||||
//! them semantically (object key order is irrelevant to `serde_json::Value` eq).
|
||||
//! Golden tests pinning the authoritative Rust `/store/purchasegroup` body. The
|
||||
//! fixtures under `tests/fixtures/purchasegroup_*.json` are Rust's own output
|
||||
//! (object key order is irrelevant to `serde_json::Value` eq). They track the
|
||||
//! RE-driven divergence from the Python oracle — notably no `extPrice` (see the
|
||||
//! module header).
|
||||
use super::*;
|
||||
|
||||
fn parse(s: &str) -> Value {
|
||||
@@ -214,7 +421,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purchasegroup_zero_sentinel_matches_oracle() {
|
||||
fn purchasegroup_zero_sentinel_matches_golden() {
|
||||
let got = build_purchasegroup(&[], StoreMode::Sentinel);
|
||||
let want = parse(include_str!(
|
||||
"../../tests/fixtures/purchasegroup_zero_sentinel.json"
|
||||
@@ -223,7 +430,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purchasegroup_zero_clean_matches_oracle() {
|
||||
fn purchasegroup_zero_clean_matches_golden() {
|
||||
let got = build_purchasegroup(&[], StoreMode::CleanV1);
|
||||
let want = parse(include_str!(
|
||||
"../../tests/fixtures/purchasegroup_zero_clean.json"
|
||||
@@ -232,7 +439,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purchasegroup_pack70_matches_oracle() {
|
||||
fn purchasegroup_pack70_matches_golden() {
|
||||
// Owned pack present -> no sentinel regardless of mode.
|
||||
let got = build_purchasegroup(&[70], StoreMode::Sentinel);
|
||||
let want = parse(include_str!(
|
||||
@@ -247,6 +454,68 @@ mod tests {
|
||||
assert!(PACK_CATALOG.iter().all(|p| p.id != SENTINEL_PACK_ID));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reward_pack_definitions_resolve_to_openable_owned_packs() {
|
||||
// Core reward services grant symbolic pack names; each must resolve to an
|
||||
// owned-only catalogue pack so it renders as an openable My Packs tile.
|
||||
for (def, want) in [
|
||||
("bronze_pack", 71),
|
||||
("silver_pack", 72),
|
||||
("gold_pack", 73),
|
||||
("rare_gold_pack", 74),
|
||||
("icon_pack", 75),
|
||||
] {
|
||||
let id = owned_pack_id_for_definition(def).expect("reward def resolves");
|
||||
assert_eq!(id, want);
|
||||
assert!(
|
||||
pack_by_id(id).unwrap().owned_only,
|
||||
"a reward pack must be owned-only"
|
||||
);
|
||||
}
|
||||
// Imported numeric owned-pack ids resolve to themselves.
|
||||
assert_eq!(owned_pack_id_for_definition("70"), Some(70));
|
||||
// Purchasable (non-owned) numeric ids and unknown names never resolve.
|
||||
assert_eq!(owned_pack_id_for_definition("5"), None);
|
||||
assert_eq!(owned_pack_id_for_definition("mystery_pack"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reward_tiles_carry_a_renderable_cover_asset() {
|
||||
// A reward pack's own id is not a client art asset, so its My Packs tile
|
||||
// renders blank. Each reward tile must therefore carry a valid catalogue
|
||||
// assetId (its tier's store pack) while `id` stays the open packId.
|
||||
for (reward_id, want_asset) in [(71, 1), (72, 3), (73, 5), (74, 5), (75, 5)] {
|
||||
let pack = pack_by_id(reward_id).expect("reward pack in catalogue");
|
||||
let tile = pack_body(pack, 1, true);
|
||||
assert_eq!(tile["id"], reward_id, "open packId stays the pack's own id");
|
||||
assert_eq!(
|
||||
tile["assetId"], want_asset,
|
||||
"reward tile borrows its tier's store-pack cover asset"
|
||||
);
|
||||
assert!(
|
||||
pack_by_id(tile["assetId"].as_u64().unwrap()).is_some_and(|p| !p.owned_only),
|
||||
"the cover asset must be a real purchasable catalogue pack"
|
||||
);
|
||||
}
|
||||
// The sentinel must NOT borrow a real cover — it stays an inert placeholder.
|
||||
assert_eq!(sentinel_body(1)["assetId"], SENTINEL_PACK_ID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbolic_reward_pack_renders_as_openable_my_packs_tile() {
|
||||
// A granted silver reward pack (resolved to id 72) must appear as an owned
|
||||
// My Packs tile, not the non-openable sentinel shim.
|
||||
let got = build_purchasegroup(&[72], StoreMode::Sentinel);
|
||||
let packs = got["purchase"].as_array().unwrap();
|
||||
let reward = packs
|
||||
.iter()
|
||||
.find(|p| p["id"] == 72)
|
||||
.expect("reward pack tile present");
|
||||
assert_eq!(reward["displayGroup"]["value"], "mypacks");
|
||||
assert!(reward["unopened"].as_bool().unwrap());
|
||||
assert!(packs.iter().all(|p| p["id"] != SENTINEL_PACK_ID));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_v1_empty_emits_no_mypacks_group() {
|
||||
let got = build_purchasegroup(&[], StoreMode::CleanV1);
|
||||
@@ -256,13 +525,13 @@ mod tests {
|
||||
.iter()
|
||||
.map(|e| e["id"].as_u64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(ids, vec![1, 5, 6, 7]);
|
||||
assert_eq!(ids, vec![1, 2, 3, 4, 5, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_tokens_are_canonical() {
|
||||
assert_eq!(category(pack_by_id(1).unwrap()), "bronze");
|
||||
assert_eq!(category(pack_by_id(3).unwrap()), "silver");
|
||||
assert_eq!(category(pack_by_id(5).unwrap()), "gold");
|
||||
assert_eq!(category(pack_by_id(7).unwrap()), "special");
|
||||
}
|
||||
}
|
||||
|
||||
+188
-150
@@ -2,197 +2,235 @@
|
||||
"purchase": [
|
||||
{
|
||||
"assetId": 1,
|
||||
"id": 1,
|
||||
"packType": "BRONZE",
|
||||
"description": "Bronze Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 400,
|
||||
"name": "coins",
|
||||
"funds": 400,
|
||||
"name": "coins"
|
||||
"finalFunds": 400
|
||||
}
|
||||
],
|
||||
"description": "Bronze Pack",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 1,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 5,
|
||||
"goldQuantity": 0,
|
||||
"itemQuantity": 5,
|
||||
"rareQuantity": 0,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 2,
|
||||
"id": 2,
|
||||
"packType": "BRONZE",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 1,
|
||||
"description": "Premium Bronze Pack",
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 2,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 750,
|
||||
"finalFunds": 750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 3,
|
||||
"id": 3,
|
||||
"packType": "SILVER",
|
||||
"description": "Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 3,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 2500,
|
||||
"finalFunds": 2500
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 4,
|
||||
"id": 4,
|
||||
"packType": "SILVER",
|
||||
"description": "Premium Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 4,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 3750,
|
||||
"finalFunds": 3750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 5,
|
||||
"id": 5,
|
||||
"packType": "GOLD",
|
||||
"description": "Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 5,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 5000,
|
||||
"name": "coins",
|
||||
"funds": 5000,
|
||||
"name": "coins"
|
||||
"finalFunds": 5000
|
||||
}
|
||||
],
|
||||
"description": "Gold Pack",
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 5,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 7,
|
||||
"itemQuantity": 7,
|
||||
"rareQuantity": 7,
|
||||
"silverQuantity": 0
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 2,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 6,
|
||||
"id": 6,
|
||||
"packType": "GOLD",
|
||||
"description": "Premium Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 6,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 15000,
|
||||
"funds": 15000,
|
||||
"name": "coins"
|
||||
"name": "coins",
|
||||
"funds": 7500,
|
||||
"finalFunds": 7500
|
||||
}
|
||||
],
|
||||
"description": "Premium Gold",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 6,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"itemQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 3,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 7,
|
||||
"assetId": 5,
|
||||
"id": 70,
|
||||
"packType": "GOLD",
|
||||
"description": "Reward Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 25000,
|
||||
"funds": 25000,
|
||||
"name": "coins"
|
||||
"name": "coins",
|
||||
"funds": 0,
|
||||
"finalFunds": 0
|
||||
}
|
||||
],
|
||||
"description": "Special Players Pack",
|
||||
"displayGroup": {
|
||||
"value": "special"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 7,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"itemQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"silverQuantity": 0
|
||||
"itemQuantity": 11
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 4,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
},
|
||||
{
|
||||
"assetId": 70,
|
||||
"description": "Reward Special Players Pack",
|
||||
"unopened": true,
|
||||
"displayGroup": {
|
||||
"priority": 1,
|
||||
"value": "mypacks"
|
||||
},
|
||||
"id": 70,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"itemQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 1,
|
||||
"state": "active",
|
||||
"unopened": true
|
||||
"value": "mypacks",
|
||||
"priority": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"timestamp": 1596326400
|
||||
|
||||
+166
-136
@@ -2,171 +2,201 @@
|
||||
"purchase": [
|
||||
{
|
||||
"assetId": 1,
|
||||
"id": 1,
|
||||
"packType": "BRONZE",
|
||||
"description": "Bronze Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 400,
|
||||
"name": "coins",
|
||||
"funds": 400,
|
||||
"name": "coins"
|
||||
"finalFunds": 400
|
||||
}
|
||||
],
|
||||
"description": "Bronze Pack",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 1,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 5,
|
||||
"goldQuantity": 0,
|
||||
"itemQuantity": 5,
|
||||
"rareQuantity": 0,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 2,
|
||||
"id": 2,
|
||||
"packType": "BRONZE",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 1,
|
||||
"description": "Premium Bronze Pack",
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 2,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 750,
|
||||
"finalFunds": 750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 3,
|
||||
"id": 3,
|
||||
"packType": "SILVER",
|
||||
"description": "Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 3,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 2500,
|
||||
"finalFunds": 2500
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 4,
|
||||
"id": 4,
|
||||
"packType": "SILVER",
|
||||
"description": "Premium Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 4,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 3750,
|
||||
"finalFunds": 3750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 5,
|
||||
"id": 5,
|
||||
"packType": "GOLD",
|
||||
"description": "Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 5,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 5000,
|
||||
"name": "coins",
|
||||
"funds": 5000,
|
||||
"name": "coins"
|
||||
"finalFunds": 5000
|
||||
}
|
||||
],
|
||||
"description": "Gold Pack",
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 5,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 7,
|
||||
"itemQuantity": 7,
|
||||
"rareQuantity": 7,
|
||||
"silverQuantity": 0
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 2,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 6,
|
||||
"id": 6,
|
||||
"packType": "GOLD",
|
||||
"description": "Premium Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 6,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 15000,
|
||||
"funds": 15000,
|
||||
"name": "coins"
|
||||
"name": "coins",
|
||||
"funds": 7500,
|
||||
"finalFunds": 7500
|
||||
}
|
||||
],
|
||||
"description": "Premium Gold",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 6,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"itemQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 3,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
},
|
||||
{
|
||||
"assetId": 7,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 25000,
|
||||
"funds": 25000,
|
||||
"name": "coins"
|
||||
}
|
||||
],
|
||||
"description": "Special Players Pack",
|
||||
"displayGroup": {
|
||||
"value": "special"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 7,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"itemQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 4,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"timestamp": 1596326400
|
||||
|
||||
+183
-152
@@ -2,197 +2,228 @@
|
||||
"purchase": [
|
||||
{
|
||||
"assetId": 1,
|
||||
"id": 1,
|
||||
"packType": "BRONZE",
|
||||
"description": "Bronze Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 400,
|
||||
"name": "coins",
|
||||
"funds": 400,
|
||||
"name": "coins"
|
||||
"finalFunds": 400
|
||||
}
|
||||
],
|
||||
"description": "Bronze Pack",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 1,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 5,
|
||||
"goldQuantity": 0,
|
||||
"itemQuantity": 5,
|
||||
"rareQuantity": 0,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 2,
|
||||
"id": 2,
|
||||
"packType": "BRONZE",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 1,
|
||||
"description": "Premium Bronze Pack",
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 2,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 750,
|
||||
"finalFunds": 750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 3,
|
||||
"id": 3,
|
||||
"packType": "SILVER",
|
||||
"description": "Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 3,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 2500,
|
||||
"finalFunds": 2500
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 4,
|
||||
"id": 4,
|
||||
"packType": "SILVER",
|
||||
"description": "Premium Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 4,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 3750,
|
||||
"finalFunds": 3750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 5,
|
||||
"id": 5,
|
||||
"packType": "GOLD",
|
||||
"description": "Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 5,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 5000,
|
||||
"name": "coins",
|
||||
"funds": 5000,
|
||||
"name": "coins"
|
||||
"finalFunds": 5000
|
||||
}
|
||||
],
|
||||
"description": "Gold Pack",
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 5,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 7,
|
||||
"itemQuantity": 7,
|
||||
"rareQuantity": 7,
|
||||
"silverQuantity": 0
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 2,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 6,
|
||||
"id": 6,
|
||||
"packType": "GOLD",
|
||||
"description": "Premium Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 6,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 15000,
|
||||
"funds": 15000,
|
||||
"name": "coins"
|
||||
"name": "coins",
|
||||
"funds": 7500,
|
||||
"finalFunds": 7500
|
||||
}
|
||||
],
|
||||
"description": "Premium Gold",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 6,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"itemQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 3,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
},
|
||||
{
|
||||
"assetId": 7,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 25000,
|
||||
"funds": 25000,
|
||||
"name": "coins"
|
||||
}
|
||||
],
|
||||
"description": "Special Players Pack",
|
||||
"displayGroup": {
|
||||
"value": "special"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 7,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"itemQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 4,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 65534,
|
||||
"description": "",
|
||||
"displayGroup": {
|
||||
"priority": 1,
|
||||
"value": "mypacks"
|
||||
},
|
||||
"id": 65534,
|
||||
"isPremium": false,
|
||||
"packType": "GOLD",
|
||||
"description": "",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"displayGroupAssetId": 3,
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 0,
|
||||
"itemQuantity": 0,
|
||||
"rareQuantity": 0,
|
||||
"silverQuantity": 0
|
||||
"itemQuantity": 0
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 1,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "mypacks",
|
||||
"priority": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"timestamp": 1596326400
|
||||
|
||||
+1
-1
Submodule openfut-bridge updated: 0f581ebef3...c58e7326a1
+1
-1
Submodule openfut-core updated: 637a21eac1...a034e74c16
+1
-1
Submodule openfut-launcher updated: 1cd4f18e92...8d5bb6202a
@@ -3,6 +3,18 @@
|
||||
//! collide with the live oracle). `core_url` defaults to Bridge's convention.
|
||||
|
||||
use std::env;
|
||||
const SBC_FAULT_ACK: &str = "staging-only-sbc-receipt-loss";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum SbcPostCommitFault {
|
||||
#[default]
|
||||
Off,
|
||||
Drop,
|
||||
Malformed,
|
||||
Delay {
|
||||
millis: u64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HostConfig {
|
||||
@@ -44,6 +56,9 @@ pub struct HostConfig {
|
||||
/// `OPENFUT_ACCOUNT_PATH`, then existing `FUT_ACCOUNT_PATH`, then the
|
||||
/// identity store's parent directory + `active_account.json`.
|
||||
pub account_path: String,
|
||||
/// Disabled by default. Non-off values require three explicit staging guards;
|
||||
/// see [`parse_sbc_post_commit_fault`].
|
||||
pub sbc_post_commit_fault: SbcPostCommitFault,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -76,9 +91,68 @@ fn required_i64_nonzero(key: &str) -> Result<i64, ConfigError> {
|
||||
Ok(val)
|
||||
}
|
||||
|
||||
fn parse_sbc_post_commit_fault(
|
||||
raw: Option<&str>,
|
||||
environment: Option<&str>,
|
||||
ack: Option<&str>,
|
||||
listen_addr: &str,
|
||||
) -> Result<SbcPostCommitFault, ConfigError> {
|
||||
let mode = match raw.filter(|value| !value.is_empty()).unwrap_or("off") {
|
||||
"off" => return Ok(SbcPostCommitFault::Off),
|
||||
"drop" => SbcPostCommitFault::Drop,
|
||||
"malformed" => SbcPostCommitFault::Malformed,
|
||||
value if value.starts_with("delay:") => {
|
||||
let millis = value["delay:".len()..].parse::<u64>().map_err(|_| {
|
||||
ConfigError(
|
||||
"OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT delay must be delay:<milliseconds>"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
if !(1..=30_000).contains(&millis) {
|
||||
return Err(ConfigError(
|
||||
"OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT delay must be 1..=30000 ms".into(),
|
||||
));
|
||||
}
|
||||
SbcPostCommitFault::Delay { millis }
|
||||
}
|
||||
value => {
|
||||
return Err(ConfigError(format!(
|
||||
"OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT must be off, drop, malformed, or delay:<milliseconds>; got {value:?}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
if environment != Some("staging") {
|
||||
return Err(ConfigError(
|
||||
"SBC post-commit faults require OPENFUT_ENVIRONMENT=staging".into(),
|
||||
));
|
||||
}
|
||||
if ack != Some(SBC_FAULT_ACK) {
|
||||
return Err(ConfigError(format!(
|
||||
"SBC post-commit faults require OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT_ACK={SBC_FAULT_ACK}"
|
||||
)));
|
||||
}
|
||||
if listen_addr.rsplit_once(':').map(|(_, port)| port) == Some("8099") {
|
||||
return Err(ConfigError(
|
||||
"SBC post-commit faults refuse the production UTAS port 8099".into(),
|
||||
));
|
||||
}
|
||||
Ok(mode)
|
||||
}
|
||||
|
||||
impl HostConfig {
|
||||
pub fn from_env() -> Result<Self, ConfigError> {
|
||||
let identity_store_path = required("OPENFUT_IDENTITY_STORE")?;
|
||||
let listen_addr = required("OPENFUT_UTAS_HOST_ADDR")?;
|
||||
let sbc_post_commit_fault = parse_sbc_post_commit_fault(
|
||||
env::var("OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT")
|
||||
.ok()
|
||||
.as_deref(),
|
||||
env::var("OPENFUT_ENVIRONMENT").ok().as_deref(),
|
||||
env::var("OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT_ACK")
|
||||
.ok()
|
||||
.as_deref(),
|
||||
&listen_addr,
|
||||
)?;
|
||||
let clientdata_path = env::var("OPENFUT_CLIENTDATA_DB")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
@@ -89,7 +163,7 @@ impl HostConfig {
|
||||
.or_else(|| env::var("FUT_ACCOUNT_PATH").ok().filter(|v| !v.is_empty()))
|
||||
.unwrap_or_else(|| default_account_path(&identity_store_path));
|
||||
Ok(HostConfig {
|
||||
listen_addr: required("OPENFUT_UTAS_HOST_ADDR")?,
|
||||
listen_addr,
|
||||
python_upstream: required("OPENFUT_UTAS_PYTHON_URL")?,
|
||||
core_url: env::var("OPENFUT_CORE_URL")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:8080".into()),
|
||||
@@ -102,6 +176,7 @@ impl HostConfig {
|
||||
identity_store_path,
|
||||
clientdata_path,
|
||||
account_path,
|
||||
sbc_post_commit_fault,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -125,3 +200,57 @@ fn default_account_path(identity_store_path: &str) -> String {
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sbc_post_commit_faults_require_all_staging_guards() {
|
||||
assert_eq!(
|
||||
parse_sbc_post_commit_fault(None, None, None, "0.0.0.0:8099").unwrap(),
|
||||
SbcPostCommitFault::Off
|
||||
);
|
||||
assert!(parse_sbc_post_commit_fault(
|
||||
Some("drop"),
|
||||
None,
|
||||
Some(SBC_FAULT_ACK),
|
||||
"127.0.0.1:18199"
|
||||
)
|
||||
.is_err());
|
||||
assert!(parse_sbc_post_commit_fault(
|
||||
Some("drop"),
|
||||
Some("staging"),
|
||||
None,
|
||||
"127.0.0.1:18199"
|
||||
)
|
||||
.is_err());
|
||||
assert!(parse_sbc_post_commit_fault(
|
||||
Some("drop"),
|
||||
Some("staging"),
|
||||
Some(SBC_FAULT_ACK),
|
||||
"0.0.0.0:8099"
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
parse_sbc_post_commit_fault(
|
||||
Some("drop"),
|
||||
Some("staging"),
|
||||
Some(SBC_FAULT_ACK),
|
||||
"0.0.0.0:18199"
|
||||
)
|
||||
.unwrap(),
|
||||
SbcPostCommitFault::Drop
|
||||
);
|
||||
assert_eq!(
|
||||
parse_sbc_post_commit_fault(
|
||||
Some("delay:25"),
|
||||
Some("staging"),
|
||||
Some(SBC_FAULT_ACK),
|
||||
"0.0.0.0:18199"
|
||||
)
|
||||
.unwrap(),
|
||||
SbcPostCommitFault::Delay { millis: 25 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ use openfut_adapter_fifa17::fut::pack_content::{
|
||||
generate_pack_contents, GeneratedCandidate, GeneratedCard,
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::squad::SquadWireResolver;
|
||||
use openfut_adapter_fifa17::fut::store_catalog::{pack_by_id, PackDef};
|
||||
use openfut_adapter_fifa17::fut::store_catalog::{
|
||||
owned_pack_id_for_definition, pack_by_id, PackDef,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
error_response, json_response, json_status, CoreAccess, CoreEconomy, CoreError,
|
||||
@@ -244,7 +246,11 @@ fn pack_open_body(pid: u64, pack: &PackDef) -> WireResponse {
|
||||
"firstPartyStoreId": 0,
|
||||
"groupName": "fifa17",
|
||||
"productId": pid.to_string(),
|
||||
"purchasePackType": if pack.gold { "GOLD" } else { "BRONZE" },
|
||||
"purchasePackType": match pack.category {
|
||||
"gold" => "GOLD",
|
||||
"silver" => "SILVER",
|
||||
_ => "BRONZE",
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -269,11 +275,12 @@ pub fn handle_pack_open(body: &[u8], deps: &StoreDeps<'_>, rng: &mut impl Rng) -
|
||||
Ok(e) => e,
|
||||
Err(_) => return error_response(503, "core_unavailable"),
|
||||
};
|
||||
// The unopened pack instance is an entitlement whose definition id is the
|
||||
// pack id. Absent → already consumed / never granted: honest empty reveal.
|
||||
// The unopened pack instance is an entitlement whose definition id resolves
|
||||
// to this owned-only pack id (a numeric id or a symbolic reward-pack name).
|
||||
// Absent → already consumed / never granted: honest empty reveal.
|
||||
let ent = match ents
|
||||
.into_iter()
|
||||
.find(|e| e.definition_id.parse::<u64>().ok() == Some(pid))
|
||||
.find(|e| owned_pack_id_for_definition(&e.definition_id) == Some(pid))
|
||||
{
|
||||
Some(e) => e,
|
||||
None => return json_response(&json!({ "itemData": [] })),
|
||||
@@ -695,7 +702,7 @@ mod tests {
|
||||
// ── Store BUY ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn buy_pack1_debits_mints_and_reveals_five_cards() {
|
||||
fn buy_pack1_debits_mints_and_reveals_twelve_cards() {
|
||||
let econ = RecEcon::new(10_000);
|
||||
let pool = pool();
|
||||
let assets = FakeAssets::for_pool(&pool);
|
||||
@@ -705,23 +712,23 @@ mod tests {
|
||||
assert_eq!(resp.status, 200);
|
||||
let b: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
let cpr = &b["createPackResponse"];
|
||||
assert_eq!(cpr["numberItems"], 5); // pack 1 count
|
||||
assert_eq!(cpr["itemList"].as_array().unwrap().len(), 5);
|
||||
assert_eq!(cpr["numberItems"], 12); // pack 1 count (10 bronze + 2 silver)
|
||||
assert_eq!(cpr["itemList"].as_array().unwrap().len(), 12);
|
||||
assert_eq!(cpr["purchasedPackId"], 1);
|
||||
assert_eq!(cpr["duplicateItemIdList"], json!([]));
|
||||
// Exactly one atomic debit of the pack price (400) minting 5 items.
|
||||
// Exactly one atomic debit of the pack price (400) minting 12 items.
|
||||
assert_eq!(econ.coins(), 10_000 - 400);
|
||||
let purchased = econ.purchased.lock();
|
||||
assert_eq!(purchased.len(), 1);
|
||||
assert_eq!(purchased[0].0, 400);
|
||||
assert_eq!(purchased[0].1.len(), 5);
|
||||
assert_eq!(purchased[0].1.len(), 12);
|
||||
for g in &purchased[0].1 {
|
||||
assert!(pool.iter().any(|c| c.card_id == g.card_id));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buy_pack5_debits_gold_price_and_mints_seven() {
|
||||
fn buy_pack5_debits_gold_price_and_mints_twelve() {
|
||||
let econ = RecEcon::new(10_000);
|
||||
let pool = pool();
|
||||
let assets = FakeAssets::for_pool(&pool);
|
||||
@@ -729,7 +736,7 @@ mod tests {
|
||||
let deps = store_deps(&econ, &assets, &ent, &pool);
|
||||
let resp = handle_store_buy(&body(json!({ "packId": 5 })), &deps, &mut rng(2));
|
||||
let b: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
assert_eq!(b["createPackResponse"]["numberItems"], 7); // pack 5 count
|
||||
assert_eq!(b["createPackResponse"]["numberItems"], 12); // pack 5 count (10 gold + 2 silver)
|
||||
assert_eq!(econ.coins(), 10_000 - 5000);
|
||||
}
|
||||
|
||||
@@ -860,7 +867,7 @@ mod tests {
|
||||
assert_eq!(b["firstPartyStoreId"], 0);
|
||||
assert_eq!(b["purchasePackType"], "GOLD"); // pack 5 is gold
|
||||
assert_eq!(econ.coins(), 10_000 - 5000);
|
||||
assert_eq!(econ.purchased.lock()[0].1.len(), 7);
|
||||
assert_eq!(econ.purchased.lock()[0].1.len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -886,6 +893,31 @@ mod tests {
|
||||
assert_eq!(redeemed[0].1.len(), 11); // pack 70 count
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_symbolic_silver_reward_redeems_entitlement_without_debit() {
|
||||
// A Core reward grant ("silver_pack") resolves to owned-only pack 72 and
|
||||
// opens for free by consuming its entitlement — the SBC reward-pack fix.
|
||||
let econ = RecEcon::with_entitlements(4600, &["silver_pack"]);
|
||||
let pool = pool();
|
||||
let assets = FakeAssets::for_pool(&pool);
|
||||
let ent = Fifa17Entities::default();
|
||||
let deps = store_deps(&econ, &assets, &ent, &pool);
|
||||
let resp = handle_pack_open(&body(json!({ "packId": 72 })), &deps, &mut rng(12));
|
||||
assert_eq!(resp.status, 200);
|
||||
let b: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
assert_eq!(b["packId"], 72);
|
||||
assert_eq!(b["purchasePackType"], "SILVER");
|
||||
assert_eq!(econ.coins(), 4600, "a reward pack opens for free");
|
||||
assert!(
|
||||
econ.entitlements.lock().is_empty(),
|
||||
"the reward entitlement is consumed once"
|
||||
);
|
||||
let redeemed = econ.redeemed.lock();
|
||||
assert_eq!(redeemed.len(), 1);
|
||||
assert_eq!(redeemed[0].0, "e0");
|
||||
assert_eq!(redeemed[0].1.len(), 12); // 1 bronze + 11 silver
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_owned_70_twice_is_consume_once() {
|
||||
let econ = RecEcon::with_entitlements(4600, &["70"]);
|
||||
|
||||
+1151
-32
File diff suppressed because it is too large
Load Diff
@@ -36,7 +36,7 @@ use crate::economy_store::OwnedItemLookup;
|
||||
use crate::market_store::{now_secs, Listing, MarketError, MarketStore};
|
||||
use crate::pile_store::PileStore;
|
||||
use crate::sold_experiment::{CountMode, SoldExperiment};
|
||||
use crate::{CoreEconomy, CoreError, WireResponse};
|
||||
use crate::{CoreEconomy, CoreError, ResponseTransport, WireResponse};
|
||||
|
||||
/// FIFA trade-id numbering base (mirrors the oracle's `_TRADE_ID_BASE`).
|
||||
const TRADE_ID_BASE: i64 = 900_000_000;
|
||||
@@ -47,6 +47,7 @@ fn json_body(status: u16, body: &Value) -> WireResponse {
|
||||
status,
|
||||
headers: vec![("Content-Type".to_string(), "application/json".to_string())],
|
||||
body: bytes,
|
||||
transport: ResponseTransport::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -697,6 +697,18 @@ impl MarketStore {
|
||||
.map_err(db)?
|
||||
.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn has_active_for_core_item(&self, core_item_id: &str) -> Result<bool, MarketError> {
|
||||
sqlx::query_scalar(
|
||||
"SELECT EXISTS( \
|
||||
SELECT 1 FROM listings WHERE core_item_id = ? AND state = 'active' \
|
||||
)",
|
||||
)
|
||||
.bind(core_item_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(db)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -159,6 +159,16 @@ impl PileStore {
|
||||
.map(|r| r.get::<String, _>("core_item_id"))
|
||||
.collect())
|
||||
}
|
||||
/// Remove stale presentation metadata after Core has consumed an item.
|
||||
/// Projection paths still intersect Core ownership, so failure is safe.
|
||||
pub async fn remove(&self, core_item_id: &str) -> Result<(), PileError> {
|
||||
sqlx::query("DELETE FROM item_pile WHERE core_item_id = ?")
|
||||
.bind(core_item_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -311,7 +311,7 @@ fn case_a_two_buys(h: &Harness) -> String {
|
||||
refs, 1,
|
||||
"exactly one BUY refused 461 (statuses {statuses:?})"
|
||||
);
|
||||
// The winner minted 5 cards; the loser minted nothing.
|
||||
// The winner minted 12 cards (real Bronze Pack); the loser minted nothing.
|
||||
for r in &rs {
|
||||
if r.status == 200 {
|
||||
assert_eq!(
|
||||
@@ -319,7 +319,7 @@ fn case_a_two_buys(h: &Harness) -> String {
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
5
|
||||
12
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,15 +26,15 @@
|
||||
//! | userMassInfo economy | PARITY | `userInfo.currencies[coins].funds == credits coins`; both |
|
||||
//! | | | carry `unopenedPacks.recoveredPacks==1`. Coins consistent |
|
||||
//! | | | across the credits & massinfo surfaces on each side. |
|
||||
//! | purchasegroup pack70 | PARITY | owned pack present -> id set {1,5,6,7,70}, NO 65534 sentinel. |
|
||||
//! | purchasegroup sentinel | PARITY | empty My Packs + unverified session -> {1,5,6,7,65534}, |
|
||||
//! | | | sentinel `state:"active"`. |
|
||||
//! | purchasegroup clean-v1 | PARITY | empty My Packs + verified capability session -> {1,5,6,7}, |
|
||||
//! | purchasegroup pack70 | DIFFERENT-BY-DESIGN| STORE DIVERGED: Rust serves the real 6-pack catalogue |
|
||||
//! | | | {1,2,3,4,5,6,70}; oracle (rollback) keeps {1,5,6,7,70}. |
|
||||
//! | purchasegroup sentinel | DIFFERENT-BY-DESIGN| empty My Packs + unverified -> rust {1..6,65534} vs oracle |
|
||||
//! | | | {1,5,6,7,65534}; sentinel `state:"active"` on both. |
|
||||
//! | purchasegroup clean-v1 | DIFFERENT-BY-DESIGN| empty + verified capability -> rust {1..6}, oracle {1,5,6,7};|
|
||||
//! | | | sentinel stripped. Rust drives the REAL `SessionStore` state |
|
||||
//! | | | machine (register_capability+open_session+freeze) exactly as |
|
||||
//! | | | the oracle's launcher/auth handshake does. |
|
||||
//! | Store BUY (pack 1) | PARITY | 200; `createPackResponse{itemList(5),numberItems:5, |
|
||||
//! | | | purchasedPackId,duplicateItemIdList}`; coin delta -400. |
|
||||
//! | | | machine (register_capability+open_session+freeze). |
|
||||
//! | Store BUY (pack 1) | DIFFERENT-BY-DESIGN| 200; rust real Bronze Pack -> itemList(12),numberItems:12; |
|
||||
//! | | | oracle placeholder -> 5; both debit 400 + purchasedPackId 1. |
|
||||
//! | POST /purchased open70 | PARITY | 200; envelope `{packId:70,firstPartyStoreId,productId:"70", |
|
||||
//! | | | purchasePackType:"GOLD"}`; coin delta 0; entitlement -1. |
|
||||
//! | GET /purchased reveal | PARITY | Durable single-profile purchased pile on BOTH (Rust reveal |
|
||||
@@ -95,6 +95,7 @@
|
||||
//! killed on guard drop. Never touches the production Core DB/ports or `.105`.
|
||||
|
||||
use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog;
|
||||
use openfut_adapter_fifa17::fut::club_response::ItemIdentityResolver;
|
||||
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
||||
use openfut_adapter_fifa17::fut::non_economy::PERSONA_DISPLAY_NAME;
|
||||
use openfut_adapter_fifa17::fut::store_session::{SessionStore, StoreMode, SENTINEL_PACK_ID};
|
||||
@@ -310,8 +311,12 @@ fn core_post(http: &reqwest::blocking::Client, base: &str, path: &str, body: Val
|
||||
|
||||
/// Build a real `Server` with economy authority wired against the seeded Core.
|
||||
/// Returns the server, a direct Core client for balance/entitlement assertions,
|
||||
/// and a valid wire `resourceId` (20000) that reverse-maps to a real Core card.
|
||||
fn build_econ_server(base: &str, dir: &std::path::Path) -> (Server, HttpCoreClient, i64) {
|
||||
/// the resolver used to obtain stable wire instance ids, and a valid wire
|
||||
/// `resourceId` (20000) that reverse-maps to a real Core card.
|
||||
fn build_econ_server(
|
||||
base: &str,
|
||||
dir: &std::path::Path,
|
||||
) -> (Server, HttpCoreClient, Arc<Fifa17IdentityResolver>, i64) {
|
||||
let probe = HttpCoreClient::new(base, "fifa17");
|
||||
let owned = probe.all_owned().expect("core collection");
|
||||
assert!(!owned.is_empty(), "seed must grant a starter collection");
|
||||
@@ -374,7 +379,7 @@ fn build_econ_server(base: &str, dir: &std::path::Path) -> (Server, HttpCoreClie
|
||||
PERSONA_ID,
|
||||
)
|
||||
.with_economy(services);
|
||||
(server, probe, 20000)
|
||||
(server, probe, resolver, 20000)
|
||||
}
|
||||
|
||||
// ─────────────────────────── differential comparison ────────────────────────
|
||||
@@ -409,13 +414,31 @@ fn pack_ids(pg: &Value) -> Vec<u64> {
|
||||
v
|
||||
}
|
||||
|
||||
/// Preserve every object key, array position, and JSON scalar kind while discarding
|
||||
/// wire-insignificant values such as translated labels and live completion counts.
|
||||
fn json_shape(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Null => json!("null"),
|
||||
Value::Bool(_) => json!("bool"),
|
||||
Value::Number(_) => json!("number"),
|
||||
Value::String(_) => json!("string"),
|
||||
Value::Array(values) => Value::Array(values.iter().map(json_shape).collect()),
|
||||
Value::Object(values) => Value::Object(
|
||||
values
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), json_shape(value)))
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every op runs on a plain OS thread with NO ambient Tokio runtime (the blocking
|
||||
/// Core client + `reqwest::blocking` require this), exactly like the
|
||||
/// thread-per-connection server — the bridge takes its direct `block_on` path.
|
||||
fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
wait_ready(core_base);
|
||||
let http = reqwest::blocking::Client::new();
|
||||
let (server, client, _sample_resource) = build_econ_server(core_base, dir);
|
||||
let (server, client, _resolver, _sample_resource) = build_econ_server(core_base, dir);
|
||||
|
||||
// ── Fixture alignment: both sides own exactly one pack-70 entitlement. ──
|
||||
// Oracle: fresh profile already owns pack 70. Core: grant the "70" entitlement
|
||||
@@ -527,29 +550,43 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
None,
|
||||
)
|
||||
.1;
|
||||
// STORE DIVERGED: Rust is the authoritative store owner and serves the real
|
||||
// 6-pack regular catalogue (ids 1..6 + owned 70). The Python oracle (rollback
|
||||
// baseline, never modified) still serves the old placeholder set {1,5,6,7,70},
|
||||
// so this is Rust-authoritative, NOT oracle parity.
|
||||
assert_eq!(
|
||||
pack_ids(&opg),
|
||||
vec![1, 5, 6, 7, 70],
|
||||
"oracle owned pack70 id set"
|
||||
"oracle (rollback) placeholder id set"
|
||||
);
|
||||
assert_eq!(
|
||||
pack_ids(&rpg),
|
||||
vec![1, 5, 6, 7, 70],
|
||||
"rust owned pack70 id set"
|
||||
vec![1, 2, 3, 4, 5, 6, 70],
|
||||
"rust authoritative real-catalogue id set"
|
||||
);
|
||||
for b in [&opg, &rpg] {
|
||||
assert!(
|
||||
!pack_ids(b).contains(&SENTINEL_PACK_ID),
|
||||
"no 65534 sentinel while a pack is owned"
|
||||
);
|
||||
// packType parity per id (BRONZE for 1, GOLD for the rest).
|
||||
for p in b["purchase"].as_array().unwrap() {
|
||||
let id = p["id"].as_u64().unwrap();
|
||||
let want = if id == 1 { "BRONZE" } else { "GOLD" };
|
||||
assert_eq!(p["packType"], want, "packType parity for pack {id}");
|
||||
}
|
||||
}
|
||||
matrix.push(("purchasegroup pack70", "PARITY"));
|
||||
// packType by side: oracle BRONZE for 1 else GOLD; rust by real category
|
||||
// (1,2 bronze / 3,4 silver / 5,6,70 gold).
|
||||
for p in opg["purchase"].as_array().unwrap() {
|
||||
let id = p["id"].as_u64().unwrap();
|
||||
let want = if id == 1 { "BRONZE" } else { "GOLD" };
|
||||
assert_eq!(p["packType"], want, "oracle packType for pack {id}");
|
||||
}
|
||||
for p in rpg["purchase"].as_array().unwrap() {
|
||||
let id = p["id"].as_u64().unwrap();
|
||||
let want = match id {
|
||||
1 | 2 => "BRONZE",
|
||||
3 | 4 => "SILVER",
|
||||
_ => "GOLD",
|
||||
};
|
||||
assert_eq!(p["packType"], want, "rust packType for pack {id}");
|
||||
}
|
||||
matrix.push(("purchasegroup pack70", "DIFFERENT-BY-DESIGN"));
|
||||
|
||||
// ── OP 4: Store BUY (pack 1 Bronze, price 400, 5 cards) ────────────────
|
||||
let o_bal0 = oracle.coins();
|
||||
@@ -577,11 +614,22 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
.as_array()
|
||||
.expect("rust itemList")
|
||||
.clone();
|
||||
assert_eq!(o_items.len(), 5, "oracle pack1 -> 5 cards");
|
||||
assert_eq!(r_items.len(), 5, "rust pack1 -> 5 cards");
|
||||
// STORE DIVERGED: rust serves the real Bronze Pack (10+2 = 12 cards); the
|
||||
// oracle placeholder awards 5. Both debit the same 400-coin price.
|
||||
assert_eq!(o_items.len(), 5, "oracle (rollback) pack1 -> 5 cards");
|
||||
assert_eq!(r_items.len(), 12, "rust pack1 real Bronze Pack -> 12 cards");
|
||||
assert_eq!(
|
||||
o_buy["createPackResponse"]["numberItems"].as_i64().unwrap(),
|
||||
5,
|
||||
"oracle numberItems==5"
|
||||
);
|
||||
assert_eq!(
|
||||
r_buy["createPackResponse"]["numberItems"].as_i64().unwrap(),
|
||||
12,
|
||||
"rust numberItems==12"
|
||||
);
|
||||
for b in [&o_buy, &r_buy] {
|
||||
let cpr = &b["createPackResponse"];
|
||||
assert_eq!(cpr["numberItems"].as_i64().unwrap(), 5, "numberItems==5");
|
||||
assert_eq!(
|
||||
cpr["purchasedPackId"].as_i64().unwrap(),
|
||||
1,
|
||||
@@ -596,9 +644,9 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
assert_eq!(
|
||||
client.balance().unwrap() - r_bal0,
|
||||
-400,
|
||||
"rust BUY debits 400"
|
||||
"rust BUY debits 400 (price parity)"
|
||||
);
|
||||
matrix.push(("Store BUY (pack1)", "PARITY"));
|
||||
matrix.push(("Store BUY (pack1)", "DIFFERENT-BY-DESIGN"));
|
||||
// Minted wire ids for the item ops that follow (both sides put them in the
|
||||
// pending purchased pile).
|
||||
let o_wire: Vec<i64> = o_items.iter().map(|i| i["id"].as_i64().unwrap()).collect();
|
||||
@@ -1153,8 +1201,8 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
);
|
||||
assert_eq!(
|
||||
pack_ids(&r_pg_s),
|
||||
vec![1, 5, 6, 7, SENTINEL_PACK_ID],
|
||||
"rust empty (unknown SID) -> sentinel"
|
||||
vec![1, 2, 3, 4, 5, 6, SENTINEL_PACK_ID],
|
||||
"rust empty (unknown SID) -> real catalogue + sentinel"
|
||||
);
|
||||
for b in [&o_pg_s, &r_pg_s] {
|
||||
let s = b["purchase"]
|
||||
@@ -1165,7 +1213,7 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
.unwrap();
|
||||
assert_eq!(s["state"], "active", "sentinel state active");
|
||||
}
|
||||
matrix.push(("purchasegroup sentinel", "PARITY"));
|
||||
matrix.push(("purchasegroup sentinel", "DIFFERENT-BY-DESIGN"));
|
||||
|
||||
// ── OP 3c: purchasegroup — clean-v1 (empty + verified capability session) ─
|
||||
// Oracle: register the launcher capability, open a session (auth), present the
|
||||
@@ -1217,11 +1265,11 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
let r_pg_c: Value = serde_json::from_slice(&r_pg_c_resp.body).unwrap();
|
||||
assert_eq!(
|
||||
pack_ids(&r_pg_c),
|
||||
vec![1, 5, 6, 7],
|
||||
"rust clean-v1 strips the sentinel"
|
||||
vec![1, 2, 3, 4, 5, 6],
|
||||
"rust clean-v1 real catalogue, sentinel stripped"
|
||||
);
|
||||
assert!(!pack_ids(&r_pg_c).contains(&SENTINEL_PACK_ID));
|
||||
matrix.push(("purchasegroup clean-v1", "PARITY"));
|
||||
matrix.push(("purchasegroup clean-v1", "DIFFERENT-BY-DESIGN"));
|
||||
|
||||
// ── Emit the matrix for the run log. ────────────────────────────────────
|
||||
eprintln!("\n===== economy_differential PARITY / DIFFERENT-BY-DESIGN matrix =====");
|
||||
@@ -1240,6 +1288,202 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
assert_eq!(matrix.len(), 16, "all economy ops classified");
|
||||
}
|
||||
|
||||
/// Compare the complete reversed `/sbs/*` response family against the Python oracle.
|
||||
/// Listing labels and counters deliberately come from Core, so parity is defined as the
|
||||
/// exact key/container/scalar-kind graph. Submission is the one semantic deviation:
|
||||
/// Python acknowledges any body without consuming cards; Rust rejects an empty squad.
|
||||
fn run_sbc_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
wait_ready(core_base);
|
||||
let (server, client, resolver, _sample_resource) = build_econ_server(core_base, dir);
|
||||
let cases = [
|
||||
("GET", "/ut/game/fifa17/sbs/sets", None),
|
||||
("GET", "/ut/game/fifa17/sbs/setId/1/challenges", None),
|
||||
("GET", "/ut/game/fifa17/sbs/setId/2/challenges", None),
|
||||
("GET", "/ut/game/fifa17/sbs/setId/999/challenges", None),
|
||||
("POST", "/ut/game/fifa17/sbs/sets/tag", Some(json!({}))),
|
||||
("PUT", "/ut/game/fifa17/sbs/sets/tag", Some(json!({}))),
|
||||
("POST", "/ut/game/fifa17/sbs/challenge/101", None),
|
||||
("GET", "/ut/game/fifa17/sbs/challenge/101/squad", None),
|
||||
(
|
||||
"PUT",
|
||||
"/ut/game/fifa17/sbs/challenge/101/squad",
|
||||
Some(json!({ "squad": [] })),
|
||||
),
|
||||
];
|
||||
|
||||
for (method, path, body) in cases {
|
||||
let oracle_result = oracle.req(method, path, body.clone(), None);
|
||||
let bytes = body
|
||||
.as_ref()
|
||||
.map(|value| serde_json::to_vec(value).unwrap())
|
||||
.unwrap_or_default();
|
||||
let rust_result = rust(&server, method, path, &bytes, None);
|
||||
assert_eq!(
|
||||
rust_result.0, oracle_result.0,
|
||||
"{method} {path} status parity"
|
||||
);
|
||||
assert_eq!(
|
||||
json_shape(&rust_result.1),
|
||||
json_shape(&oracle_result.1),
|
||||
"{method} {path} wire shape parity\nRust: {}\nOracle: {}",
|
||||
rust_result.1,
|
||||
oracle_result.1
|
||||
);
|
||||
match (method, path) {
|
||||
("GET", "/ut/game/fifa17/sbs/sets") => {
|
||||
assert_eq!(rust_result.1["categories"][0]["categoryId"], 1);
|
||||
assert_eq!(oracle_result.1["categories"][0]["categoryId"], 1);
|
||||
let rust_set_ids: Vec<i64> = rust_result.1["categories"][0]["sets"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|set| set["setId"].as_i64().unwrap())
|
||||
.collect();
|
||||
let oracle_set_ids: Vec<i64> = oracle_result.1["categories"][0]["sets"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|set| set["setId"].as_i64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(rust_set_ids, [1, 2]);
|
||||
assert_eq!(oracle_set_ids, [1, 2]);
|
||||
}
|
||||
("GET", "/ut/game/fifa17/sbs/setId/1/challenges") => {
|
||||
for body in [&rust_result.1, &oracle_result.1] {
|
||||
assert_eq!(body["challenges"][0]["challengeId"], 101);
|
||||
assert_eq!(body["challenges"][0]["setId"], 1);
|
||||
assert_eq!(body["challenges"][0]["categoryId"], 1);
|
||||
}
|
||||
}
|
||||
("GET", "/ut/game/fifa17/sbs/setId/2/challenges") => {
|
||||
for body in [&rust_result.1, &oracle_result.1] {
|
||||
assert_eq!(body["challenges"][0]["challengeId"], 201);
|
||||
assert_eq!(body["challenges"][0]["setId"], 2);
|
||||
assert_eq!(body["challenges"][0]["categoryId"], 1);
|
||||
}
|
||||
}
|
||||
("GET", "/ut/game/fifa17/sbs/setId/999/challenges") => {
|
||||
assert_eq!(rust_result.1["challenges"], json!([]));
|
||||
assert_eq!(oracle_result.1["challenges"], json!([]));
|
||||
}
|
||||
("POST", "/ut/game/fifa17/sbs/challenge/101") => {
|
||||
assert_eq!(rust_result.1["challengeId"], 101);
|
||||
assert_eq!(oracle_result.1["challengeId"], 101);
|
||||
}
|
||||
(method, "/ut/game/fifa17/sbs/challenge/101/squad") => {
|
||||
assert!(method == "GET" || method == "PUT");
|
||||
assert_eq!(rust_result.1["id"], 101);
|
||||
assert_eq!(oracle_result.1["id"], 101);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let submit = json!({ "squad": [] });
|
||||
let oracle_submit = oracle.req(
|
||||
"PUT",
|
||||
"/ut/game/fifa17/sbs/challenge/101",
|
||||
Some(submit.clone()),
|
||||
None,
|
||||
);
|
||||
let rust_submit = rust(
|
||||
&server,
|
||||
"PUT",
|
||||
"/ut/game/fifa17/sbs/challenge/101",
|
||||
&serde_json::to_vec(&submit).unwrap(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(oracle_submit.0, 200, "oracle preserves its no-op submit");
|
||||
assert_eq!(
|
||||
json_shape(&oracle_submit.1),
|
||||
json_shape(&json!({
|
||||
"challengeId": 0,
|
||||
"setId": 0,
|
||||
"credits": 0,
|
||||
"preOrderPacks": 0,
|
||||
"recoveredPacks": 0,
|
||||
"grantedChallengeAwards": [],
|
||||
"grantedSetAwards": []
|
||||
})),
|
||||
"oracle submit response remains freeze-safe"
|
||||
);
|
||||
assert_eq!(
|
||||
rust_submit.0, 400,
|
||||
"Rust must validate instead of copying the oracle's no-op acceptance"
|
||||
);
|
||||
|
||||
let owned = client.all_owned().expect("SBC differential inventory");
|
||||
let mut selected = Vec::new();
|
||||
for nation in ["Argentina", "Brazil"] {
|
||||
selected.push(
|
||||
owned
|
||||
.iter()
|
||||
.find(|item| item.rating >= 70 && item.nation == nation)
|
||||
.unwrap_or_else(|| panic!("missing {nation} SBC fixture")),
|
||||
);
|
||||
}
|
||||
for item in &owned {
|
||||
if selected.len() == 11 {
|
||||
break;
|
||||
}
|
||||
if item.rating >= 70
|
||||
&& !selected
|
||||
.iter()
|
||||
.any(|existing| existing.owned_card_id == item.owned_card_id)
|
||||
{
|
||||
selected.push(item);
|
||||
}
|
||||
}
|
||||
assert_eq!(selected.len(), 11);
|
||||
let successful = json!({
|
||||
"squad": selected
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| json!({
|
||||
"index": index,
|
||||
"itemData": {
|
||||
"id": i64::from(
|
||||
resolver.resolve(item).expect("SBC wire identity").item_id
|
||||
)
|
||||
}
|
||||
}))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let before_balance = client.balance().unwrap();
|
||||
let before_packs = client.entitlements().unwrap().len();
|
||||
let oracle_success = oracle.req(
|
||||
"POST",
|
||||
"/ut/game/fifa17/sbs/challenge/201",
|
||||
Some(successful.clone()),
|
||||
None,
|
||||
);
|
||||
let rust_success = rust(
|
||||
&server,
|
||||
"POST",
|
||||
"/ut/game/fifa17/sbs/challenge/201",
|
||||
&serde_json::to_vec(&successful).unwrap(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(oracle_success.0, 200);
|
||||
assert_eq!(rust_success.0, 200);
|
||||
assert_eq!(
|
||||
json_shape(&rust_success.1),
|
||||
json_shape(&oracle_success.1),
|
||||
"successful Rust submit preserves the oracle response class"
|
||||
);
|
||||
assert_eq!(rust_success.1["challengeId"], 201);
|
||||
assert_eq!(rust_success.1["setId"], 2);
|
||||
assert!(
|
||||
client.balance().unwrap() > before_balance,
|
||||
"Core applies challenge and achievement coin rewards"
|
||||
);
|
||||
assert_eq!(
|
||||
client.entitlements().unwrap().len(),
|
||||
before_packs + 1,
|
||||
"Core grants one Hybrid Nations pack"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn economy_differential_python_oracle() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
@@ -1281,3 +1525,35 @@ async fn economy_differential_python_oracle() {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
outcome.expect("differential thread panicked");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn sbc_differential_python_oracle() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"openfut-sbc-diff-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let db_url = format!("sqlite://{}/sbc.db", dir.display());
|
||||
let (core_handle, core_base) = start_core_seeded(&db_url, true).await;
|
||||
|
||||
let core_base_run = core_base.clone();
|
||||
let dir_run = dir.clone();
|
||||
let outcome = tokio::task::spawn_blocking(move || {
|
||||
std::thread::spawn(move || {
|
||||
let mut oracle = Oracle::spawn(&dir_run);
|
||||
oracle.wait_ready();
|
||||
run_sbc_differential(&core_base_run, &oracle, &dir_run);
|
||||
})
|
||||
.join()
|
||||
})
|
||||
.await
|
||||
.expect("join spawn_blocking");
|
||||
|
||||
core_handle.abort();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
outcome.expect("SBC differential thread panicked");
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! touches the production Core DB, production ports/containers, or `.105`.
|
||||
|
||||
use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog;
|
||||
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
||||
use openfut_adapter_fifa17::fut::entities::{Fifa17Entities, ReverseEntityResolver};
|
||||
use openfut_adapter_fifa17::fut::store_session::StoreMode;
|
||||
use openfut_identity::JsonIdentityStore;
|
||||
use openfut_utas_host::async_bridge::AsyncBridge;
|
||||
@@ -373,8 +373,8 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
||||
.as_array()
|
||||
.expect("itemList")
|
||||
.clone();
|
||||
assert_eq!(items.len(), 5, "pack 1 awards 5 cards");
|
||||
assert_eq!(bv["createPackResponse"]["numberItems"], 5);
|
||||
assert_eq!(items.len(), 12, "pack 1 (real Bronze Pack) awards 12 cards");
|
||||
assert_eq!(bv["createPackResponse"]["numberItems"], 12);
|
||||
assert!(
|
||||
items[0]["id"].as_i64().unwrap() >= 100_000_000,
|
||||
"minted wire id above the FIFA floor"
|
||||
@@ -769,6 +769,7 @@ fn from_config(base: &str, dir: &std::path::Path) -> openfut_utas_host::config::
|
||||
.join("active_account.json")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
sbc_post_commit_fault: openfut_utas_host::config::SbcPostCommitFault::Off,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -908,6 +909,212 @@ async fn from_config_constructs_and_serves_economy() {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn sbc_survives_complete_core_and_host_restart() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"openfut-sbc-restart-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let db_url = format!("sqlite://{}/sbc.db", dir.display());
|
||||
|
||||
let (h1, base1) = start_core_seeded(&db_url, true).await;
|
||||
let first_base = base1.clone();
|
||||
let first_dir = dir.clone();
|
||||
let (mut cfg, selected_core_ids, selected_wire_ids) = tokio::task::spawn_blocking(move || {
|
||||
let cfg = from_config(&first_base, &first_dir);
|
||||
let server = Server::from_config(&cfg).expect("first complete host");
|
||||
let club = server.handle("GET", "/ut/game/fifa17/club?count=200", &[], b"");
|
||||
assert_eq!(club.status, 200);
|
||||
let club: Value = serde_json::from_slice(&club.body).unwrap();
|
||||
let items = club["itemData"].as_array().expect("club itemData");
|
||||
let entities =
|
||||
Fifa17Entities::from_tables_dir(std::path::Path::new(&cfg.tables_dir)).unwrap();
|
||||
let argentina = i64::from(entities.nation_id("Argentina").unwrap());
|
||||
let brazil = i64::from(entities.nation_id("Brazil").unwrap());
|
||||
let mut selected = Vec::new();
|
||||
for nation in [argentina, brazil] {
|
||||
selected.push(
|
||||
items
|
||||
.iter()
|
||||
.find(|item| {
|
||||
item["rating"].as_i64().unwrap_or_default() >= 70
|
||||
&& item["nation"].as_i64() == Some(nation)
|
||||
})
|
||||
.expect("required hybrid nation")
|
||||
.clone(),
|
||||
);
|
||||
}
|
||||
for item in items {
|
||||
if selected.len() == 11 {
|
||||
break;
|
||||
}
|
||||
if item["rating"].as_i64().unwrap_or_default() >= 70
|
||||
&& !selected.iter().any(|existing| existing["id"] == item["id"])
|
||||
{
|
||||
selected.push(item.clone());
|
||||
}
|
||||
}
|
||||
assert_eq!(selected.len(), 11, "deterministic Hybrid Nations squad");
|
||||
let selected_wire_ids: Vec<i64> = selected
|
||||
.iter()
|
||||
.map(|item| item["id"].as_i64().expect("wire id"))
|
||||
.collect();
|
||||
let catalog = Fifa17CardCatalog::from_file(std::path::Path::new(&cfg.catalog_path))
|
||||
.expect("catalog reload");
|
||||
let store = JsonIdentityStore::open(&cfg.identity_store_path).expect("identity reload");
|
||||
let resolver = Fifa17IdentityResolver::new(catalog, Arc::new(store));
|
||||
let selected_core_ids: Vec<String> = selected_wire_ids
|
||||
.iter()
|
||||
.map(|wire| {
|
||||
resolver
|
||||
.owned_id_for_wire(*wire)
|
||||
.expect("wire mapping persisted")
|
||||
})
|
||||
.collect();
|
||||
let squad = json!({
|
||||
"squad": selected_wire_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, id)| json!({
|
||||
"index": index,
|
||||
"itemData": { "id": id },
|
||||
"kitNumber": 0
|
||||
}))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let squad_bytes = serde_json::to_vec(&squad).unwrap();
|
||||
assert_eq!(
|
||||
server
|
||||
.handle(
|
||||
"PUT",
|
||||
"/ut/game/fifa17/sbs/challenge/201/squad",
|
||||
&[],
|
||||
&squad_bytes,
|
||||
)
|
||||
.status,
|
||||
200
|
||||
);
|
||||
let submitted = server.handle("PUT", "/ut/game/fifa17/sbs/challenge/201", &[], br#"{}"#);
|
||||
assert_eq!(submitted.status, 200);
|
||||
let submitted: Value = serde_json::from_slice(&submitted.body).unwrap();
|
||||
assert_eq!(submitted["challengeId"], 201);
|
||||
assert_eq!(submitted["credits"], 102_750);
|
||||
assert_eq!(submitted["recoveredPacks"], 1);
|
||||
(cfg, selected_core_ids, selected_wire_ids)
|
||||
})
|
||||
.await
|
||||
.expect("initial complete-host phase");
|
||||
|
||||
h1.abort();
|
||||
let _ = h1.await;
|
||||
let (h2, base2) = start_core_seeded(&db_url, false).await;
|
||||
cfg.core_url = base2.clone();
|
||||
let selected_core_ids_check = selected_core_ids.clone();
|
||||
let selected_wire_ids_check = selected_wire_ids.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let server = Server::from_config(&cfg).expect("restarted complete host");
|
||||
let client = HttpCoreClient::new(&base2, "fifa17");
|
||||
assert_eq!(client.balance().unwrap(), 102_750);
|
||||
assert_eq!(client.entitlements().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
client
|
||||
.sbc_completion_counts()
|
||||
.unwrap()
|
||||
.get("sbc_hybrid_nations")
|
||||
.copied(),
|
||||
Some(1)
|
||||
);
|
||||
let owned = client.all_owned().unwrap();
|
||||
assert!(selected_core_ids_check
|
||||
.iter()
|
||||
.all(|id| { owned.iter().all(|item| item.owned_card_id != *id) }));
|
||||
|
||||
let club = server.handle("GET", "/ut/game/fifa17/club?count=200", &[], b"");
|
||||
let club: Value = serde_json::from_slice(&club.body).unwrap();
|
||||
assert!(selected_wire_ids_check.iter().all(|id| {
|
||||
club["itemData"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|item| item["id"].as_i64() != Some(*id))
|
||||
}));
|
||||
let saved = server.handle("GET", "/ut/game/fifa17/sbs/challenge/201/squad", &[], b"");
|
||||
let saved: Value = serde_json::from_slice(&saved.body).unwrap();
|
||||
assert!(saved["squad"].as_array().unwrap().is_empty());
|
||||
let purchased = server.handle("GET", "/ut/v2/game/fifa17/purchased/items", &[], b"");
|
||||
let purchased: Value = serde_json::from_slice(&purchased.body).unwrap();
|
||||
assert!(purchased["itemData"].as_array().unwrap().is_empty());
|
||||
for path in ["/ut/game/fifa17/tradePile", "/ut/game/fifa17/watchList"] {
|
||||
let pile = server.handle("GET", path, &[], b"");
|
||||
let pile: Value = serde_json::from_slice(&pile.body).unwrap();
|
||||
assert!(pile["auctionInfo"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|auction| {
|
||||
selected_wire_ids_check
|
||||
.iter()
|
||||
.all(|id| auction["itemData"]["id"].as_i64() != Some(*id))
|
||||
}));
|
||||
}
|
||||
let active = server.handle("GET", "/ut/game/fifa17/squad/active", &[], b"");
|
||||
let active: Value = serde_json::from_slice(&active.body).unwrap();
|
||||
assert!(selected_wire_ids_check.iter().all(|id| {
|
||||
active["players"].as_array().is_none_or(|players| {
|
||||
players
|
||||
.iter()
|
||||
.all(|slot| slot["itemData"]["id"].as_i64() != Some(*id))
|
||||
})
|
||||
}));
|
||||
|
||||
let replay_body = json!({
|
||||
"squad": selected_wire_ids_check
|
||||
.iter()
|
||||
.map(|id| json!({ "itemData": { "id": id } }))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
assert_eq!(
|
||||
server
|
||||
.handle(
|
||||
"PUT",
|
||||
"/ut/game/fifa17/sbs/challenge/201",
|
||||
&[],
|
||||
&serde_json::to_vec(&replay_body).unwrap(),
|
||||
)
|
||||
.status,
|
||||
404,
|
||||
"host rejects consumed wire ids before a duplicate effect"
|
||||
);
|
||||
assert!(matches!(
|
||||
client.submit_sbc("sbc_hybrid_nations", &selected_core_ids_check),
|
||||
Err(openfut_utas_host::CoreError::Status(409))
|
||||
));
|
||||
assert_eq!(client.balance().unwrap(), 102_750);
|
||||
assert_eq!(client.entitlements().unwrap().len(), 1);
|
||||
|
||||
let catalog = Fifa17CardCatalog::from_file(std::path::Path::new(&cfg.catalog_path))
|
||||
.expect("restart catalog");
|
||||
let store = JsonIdentityStore::open(&cfg.identity_store_path).expect("restart identity");
|
||||
let resolver = Fifa17IdentityResolver::new(catalog, Arc::new(store));
|
||||
for (wire, core_id) in selected_wire_ids_check.iter().zip(&selected_core_ids_check) {
|
||||
assert_eq!(
|
||||
resolver.owned_id_for_wire(*wire).as_deref(),
|
||||
Some(core_id.as_str())
|
||||
);
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("restart verification");
|
||||
h2.abort();
|
||||
let _ = h2.await;
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// ─────────────── Post-barrier authority proofs (NEVER BOTH / no fallback / ────
|
||||
// stale reader), through the REAL handle_with_ip dispatch ──────
|
||||
|
||||
@@ -1040,6 +1247,35 @@ fn pure_economy_routes() -> Vec<(&'static str, String, Vec<u8>)> {
|
||||
"/ut/game/fifa17/tradePile/counts".into(),
|
||||
b"".to_vec(),
|
||||
),
|
||||
// ── FIFA 17 SBC family. Reads, tag acknowledgement, durable squad
|
||||
// saves, challenge start, and submission are all Rust-owned. ──
|
||||
("GET", "/ut/game/fifa17/sbs/sets".into(), b"".to_vec()),
|
||||
(
|
||||
"GET",
|
||||
"/ut/game/fifa17/sbs/setId/1/challenges".into(),
|
||||
b"".to_vec(),
|
||||
),
|
||||
(
|
||||
"GET",
|
||||
"/ut/game/fifa17/sbs/challenge/101/squad".into(),
|
||||
b"".to_vec(),
|
||||
),
|
||||
("PUT", "/ut/game/fifa17/sbs/sets/tag".into(), b"{}".to_vec()),
|
||||
(
|
||||
"PUT",
|
||||
"/ut/game/fifa17/sbs/challenge/101/squad".into(),
|
||||
br#"{"squad":[]}"#.to_vec(),
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
"/ut/game/fifa17/sbs/challenge/101".into(),
|
||||
b"".to_vec(),
|
||||
),
|
||||
(
|
||||
"PUT",
|
||||
"/ut/game/fifa17/sbs/challenge/101".into(),
|
||||
br#"{"squad":[]}"#.to_vec(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
Executable
+146
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Authoritative Core state snapshot for FIFA17 FUT loop verification.
|
||||
|
||||
Queries openfut-core's authoritative read API directly (NOT the FIFA UI, NOT the
|
||||
host projection, NOT Python) and emits a machine-readable JSON snapshot with the
|
||||
integrity fields needed to detect, across any economy operation or a full stack
|
||||
restart:
|
||||
|
||||
* coin drift (balance vs club.coins cross-check + absolute value)
|
||||
* duplicate ownership (repeated owned_card_id)
|
||||
* missing ownership (squad references an owned_card_id not in the collection)
|
||||
* resurrection (owned-set hash changes when it must not)
|
||||
* entitlement dup/count (unopened pack entitlements)
|
||||
* stale squad mappings (squad player ids absent from ownership)
|
||||
|
||||
This is verification/oracle tooling, not production behavior. It reads only.
|
||||
|
||||
Usage:
|
||||
core-snapshot.py [--core URL] [--game fifa17] [--squad-ns fifa17.squad]
|
||||
[--out FILE] [--label NAME]
|
||||
Env fallbacks: OPENFUT_CORE_URL, OPENFUT_GAME, OPENFUT_SQUAD_NS.
|
||||
Exit code 0 always for a successful read; integrity problems are reported IN the
|
||||
snapshot (`integrity.ok` / `integrity.problems`) so callers can diff/assert.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def fetch(core, game, path):
|
||||
req = urllib.request.Request(core + path, headers={"X-OpenFUT-Game": game})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as f:
|
||||
return f.status, f.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read()
|
||||
except Exception as e: # noqa: BLE001 - surface transport errors in the snapshot
|
||||
return None, str(e).encode()
|
||||
|
||||
|
||||
def fetch_json(core, game, path):
|
||||
st, body = fetch(core, game, path)
|
||||
if st != 200:
|
||||
raise SystemExit(f"Core read {path} failed: status={st} body={body[:200]!r}")
|
||||
return json.loads(body)
|
||||
|
||||
|
||||
def sha(items):
|
||||
h = hashlib.sha256()
|
||||
for it in items:
|
||||
h.update(str(it).encode())
|
||||
h.update(b"\x00")
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--core", default=os.environ.get("OPENFUT_CORE_URL", "http://127.0.0.1:18101"))
|
||||
ap.add_argument("--game", default=os.environ.get("OPENFUT_GAME", "fifa17"))
|
||||
ap.add_argument("--squad-ns", default=os.environ.get("OPENFUT_SQUAD_NS", "fifa17.squad"))
|
||||
ap.add_argument("--out")
|
||||
ap.add_argument("--label", default="")
|
||||
args = ap.parse_args()
|
||||
|
||||
balance = fetch_json(args.core, args.game, "/economy/balance")
|
||||
entitlements = fetch_json(args.core, args.game, "/economy/entitlements")
|
||||
profile = fetch_json(args.core, args.game, "/profile")
|
||||
club = fetch_json(args.core, args.game, "/club")
|
||||
collection = fetch_json(args.core, args.game, "/collection")["collection"]
|
||||
squad = fetch_json(args.core, args.game, f"/squad/ext?namespace={args.squad_ns}")
|
||||
|
||||
coins_balance = balance.get("balance")
|
||||
coins_club = club.get("coins")
|
||||
|
||||
owned_ids = sorted(x["owned_card_id"] for x in collection)
|
||||
card_ids = sorted(x["card"]["id"] for x in collection)
|
||||
dup_owned = sorted({i for i in owned_ids if owned_ids.count(i) > 1}) if len(owned_ids) != len(set(owned_ids)) else []
|
||||
loans = [x["owned_card_id"] for x in collection if x.get("is_loan")]
|
||||
|
||||
# Squad player ownership references (from the opaque extension payload).
|
||||
squad_player_ids = []
|
||||
ext = squad.get("extension") or {}
|
||||
payload_raw = ext.get("payload")
|
||||
kit_numbers = {}
|
||||
if payload_raw:
|
||||
try:
|
||||
payload = json.loads(payload_raw)
|
||||
kit_numbers = payload.get("kit_numbers", {}) or {}
|
||||
squad_player_ids = sorted(kit_numbers.keys())
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
owned_set = set(owned_ids)
|
||||
squad_missing = sorted(pid for pid in squad_player_ids if pid not in owned_set)
|
||||
|
||||
problems = []
|
||||
if coins_balance != coins_club:
|
||||
problems.append(f"coin cross-check mismatch: balance={coins_balance} club={coins_club}")
|
||||
if dup_owned:
|
||||
problems.append(f"duplicate owned_card_id: {dup_owned[:10]} (+{max(0,len(dup_owned)-10)} more)")
|
||||
if squad_missing:
|
||||
problems.append(f"squad references non-owned ids: {squad_missing}")
|
||||
|
||||
snap = {
|
||||
"label": args.label,
|
||||
"captured_at": datetime.now(timezone.utc).isoformat(),
|
||||
"core": args.core,
|
||||
"game": args.game,
|
||||
"coins": {"balance": coins_balance, "club": coins_club},
|
||||
"profile": {"username": profile.get("username"), "level": profile.get("level"), "xp": profile.get("xp")},
|
||||
"entitlements": {"count": len(entitlements), "ids": entitlements},
|
||||
"collection": {
|
||||
"owned": len(collection),
|
||||
"distinct_owned_card_id": len(set(owned_ids)),
|
||||
"distinct_card_id": len(set(card_ids)),
|
||||
"loans": len(loans),
|
||||
"duplicate_owned_card_id": dup_owned,
|
||||
"owned_set_sha256": sha(owned_ids),
|
||||
"card_multiset_sha256": sha(card_ids),
|
||||
},
|
||||
"squad": {
|
||||
"namespace": args.squad_ns,
|
||||
"verdict": squad.get("verdict"),
|
||||
"player_count": len(squad_player_ids),
|
||||
"player_ids": squad_player_ids,
|
||||
"missing_from_ownership": squad_missing,
|
||||
"ext_schema_version": ext.get("schema_version"),
|
||||
},
|
||||
"integrity": {"ok": not problems, "problems": problems},
|
||||
}
|
||||
|
||||
text = json.dumps(snap, indent=2, sort_keys=True)
|
||||
if args.out:
|
||||
with open(args.out, "w") as f:
|
||||
f.write(text + "\n")
|
||||
print(text)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -278,7 +278,7 @@ def cmd_parse(args):
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
out_path = os.path.join(out_dir, "transactions.jsonl")
|
||||
|
||||
total, skipped = 0, 0
|
||||
total, skipped, filtered = 0, 0, 0
|
||||
with open(out_path, "w") as out:
|
||||
for conn_id in sorted(conns):
|
||||
c = conns[conn_id]
|
||||
@@ -298,6 +298,9 @@ def cmd_parse(args):
|
||||
m = re.match(r"(\S+)\s+(\S+)\s+(HTTP/\d\.\d)", rl)
|
||||
method, target, ver = (m.group(1), m.group(2), m.group(3)) if m else ("?", rl, "?")
|
||||
path, _, query = target.partition("?")
|
||||
if args.path_prefix and not path.startswith(args.path_prefix):
|
||||
filtered += 1
|
||||
continue
|
||||
st = re.match(r"HTTP/\d\.\d\s+(\d+)", sl)
|
||||
req_t, req_unix = time_at(c["marks"]["c2s"], rend - 1)
|
||||
res_t, res_unix = time_at(c["marks"]["s2c"], max(send - 1, 0))
|
||||
@@ -342,9 +345,14 @@ def cmd_parse(args):
|
||||
total += 1
|
||||
|
||||
os.chmod(out_path, 0o644)
|
||||
detail = []
|
||||
if skipped:
|
||||
detail.append("%d unpaired messages reported above" % skipped)
|
||||
if filtered:
|
||||
detail.append("%d transactions excluded by path prefix" % filtered)
|
||||
print("wrote %s (%d transactions across %d connections%s)"
|
||||
% (out_path, total, len(conns),
|
||||
"; %d unpaired messages reported above" % skipped if skipped else ""))
|
||||
"; " + "; ".join(detail) if detail else ""))
|
||||
return 0
|
||||
|
||||
|
||||
@@ -587,6 +595,8 @@ def main():
|
||||
# fixture for the whole response.
|
||||
p.add_argument("--max-body", type=int, default=0,
|
||||
help="bytes; 0 = keep every body in full")
|
||||
p.add_argument("--path-prefix", default="",
|
||||
help="emit only transactions whose request path starts with this prefix")
|
||||
p.set_defaults(fn=cmd_parse)
|
||||
|
||||
s = sub.add_parser("snapshot")
|
||||
|
||||
@@ -123,8 +123,8 @@ def main():
|
||||
|
||||
body = json.dumps({"squad": [1, 2, 3], "note": "x" * 300}).encode()
|
||||
reqs = [
|
||||
b"GET /ut/game/fifa17/userMassInfo HTTP/1.1\r\nHost: t\r\n\r\n",
|
||||
b"POST /ut/game/fifa17/purchased/items HTTP/1.1\r\nHost: t\r\n"
|
||||
b"GET /ut/game/fifa17/sbs/sets HTTP/1.1\r\nHost: t\r\n\r\n",
|
||||
b"POST /ut/game/fifa17/sbs/challenge/101/squad HTTP/1.1\r\nHost: t\r\n"
|
||||
b"Content-Type: application/json\r\nContent-Length: %d\r\n\r\n" % len(body) + body,
|
||||
b"GET /chunked?deviceId=DEADBEEFCAFE&keep=yes HTTP/1.1\r\nHost: t\r\n\r\n",
|
||||
b"GET /ut/game/fifa17/hub HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n",
|
||||
@@ -180,8 +180,8 @@ def main():
|
||||
if len(txs) == 4:
|
||||
check("methods and paths in order",
|
||||
[(t["request"]["method"], t["request"]["path"]) for t in txs] ==
|
||||
[("GET", "/ut/game/fifa17/userMassInfo"),
|
||||
("POST", "/ut/game/fifa17/purchased/items"),
|
||||
[("GET", "/ut/game/fifa17/sbs/sets"),
|
||||
("POST", "/ut/game/fifa17/sbs/challenge/101/squad"),
|
||||
("GET", "/chunked"),
|
||||
("GET", "/ut/game/fifa17/hub")])
|
||||
check("request body preserved byte-for-byte",
|
||||
@@ -210,6 +210,20 @@ def main():
|
||||
qtx is not None and "keep=yes" in qtx["request"]["query"],
|
||||
qtx["request"]["query"] if qtx else "")
|
||||
|
||||
print("== path-scoped fixture ==")
|
||||
r = subprocess.run(
|
||||
[sys.executable, TOOL, "parse", "--session", SESSION,
|
||||
"--path-prefix", "/ut/game/fifa17/sbs/"],
|
||||
capture_output=True, text=True)
|
||||
print(" " + r.stdout.strip().replace("\n", "\n "))
|
||||
filtered = [json.loads(l) for l in
|
||||
open(os.path.join(SESSION, "sanitized", "transactions.jsonl"))]
|
||||
check("SBC prefix emits only the two SBC transactions", len(filtered) == 2,
|
||||
"got %d" % len(filtered))
|
||||
check("SBC save body remains byte-exact after scoped parse",
|
||||
len(filtered) == 2 and
|
||||
base64.b64decode(filtered[1]["request"]["body_b64"]) == body)
|
||||
|
||||
srv.shutdown()
|
||||
print()
|
||||
print("all checks passed" if not fails else "%d FAILED: %s" % (len(fails), fails))
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Attribute captured UTAS requests to labelled UI actions, and diff them.
|
||||
|
||||
utas-filter-diff.py --session <dir> [--baseline NO_FILTER]
|
||||
|
||||
The My Squad filter investigation needs to answer "which wire field changed
|
||||
when I changed exactly one thing in the UI". That is a diff between labelled
|
||||
groups of requests, so this tool needs both halves:
|
||||
|
||||
raw/utas.ofcap what the client sent
|
||||
labels.txt MARKER <label> <HH:MM:SS> UTC lines, written when the human
|
||||
said a search was done
|
||||
|
||||
Requests are attributed to the label whose marker most recently PRECEDES them.
|
||||
Anything before the first marker is 'boot'.
|
||||
|
||||
Why a separate tool: the observer must stay a dumb, byte-faithful tee. Anything
|
||||
that interprets traffic belongs outside it, so a mistake here can never affect
|
||||
what was recorded.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import collections
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
|
||||
def load(session):
|
||||
"""Rebuild per-connection streams, keeping the wall time of each chunk."""
|
||||
path = os.path.join(session, "raw", "utas.ofcap")
|
||||
conns = collections.defaultdict(
|
||||
lambda: {"c2s": bytearray(), "s2c": bytearray(), "marks": []}
|
||||
)
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
r = json.loads(line)
|
||||
if "b64" not in r:
|
||||
continue
|
||||
c = conns[r["conn"]]
|
||||
data = base64.b64decode(r["b64"])
|
||||
if r["dir"] == "c2s":
|
||||
c["marks"].append((len(c["c2s"]), r["unix"]))
|
||||
c[r["dir"]].extend(data)
|
||||
return conns
|
||||
|
||||
|
||||
def labels(session):
|
||||
out = []
|
||||
p = os.path.join(session, "labels.txt")
|
||||
if not os.path.exists(p):
|
||||
return out
|
||||
import datetime
|
||||
for line in open(p):
|
||||
m = re.match(r"MARKER\s+(\S+)\s+(\d\d):(\d\d):(\d\d)\s+UTC", line.strip())
|
||||
if m:
|
||||
name, h, mi, s = m.group(1), *map(int, m.groups()[1:])
|
||||
out.append((name, h * 3600 + mi * 60 + s))
|
||||
return out
|
||||
|
||||
|
||||
def split_requests(buf):
|
||||
"""(offset, method, target, headers, body) per request in a stream."""
|
||||
out, i = [], 0
|
||||
while True:
|
||||
sep = buf.find(b"\r\n\r\n", i)
|
||||
if sep < 0:
|
||||
break
|
||||
head = bytes(buf[i:sep]).decode("latin1")
|
||||
line0 = head.split("\r\n")[0]
|
||||
m = re.match(r"(\S+)\s+(\S+)\s+HTTP/", line0)
|
||||
if not m:
|
||||
break
|
||||
hdrs = [h.split(":", 1) for h in head.split("\r\n")[1:] if ":" in h]
|
||||
hdrs = [(k.strip(), v.strip()) for k, v in hdrs]
|
||||
cl = next((int(v) for k, v in hdrs if k.lower() == "content-length" and v.isdigit()), 0)
|
||||
start = sep + 4
|
||||
body = bytes(buf[start:start + cl])
|
||||
out.append((i, m.group(1), m.group(2), hdrs, body))
|
||||
i = start + cl
|
||||
return out
|
||||
|
||||
|
||||
def split_responses(buf):
|
||||
out, i = [], 0
|
||||
while True:
|
||||
sep = buf.find(b"\r\n\r\n", i)
|
||||
if sep < 0:
|
||||
break
|
||||
head = bytes(buf[i:sep]).decode("latin1")
|
||||
st = re.match(r"HTTP/\d\.\d\s+(\d+)", head)
|
||||
cl = re.search(r"(?im)^content-length:\s*(\d+)\s*$", head)
|
||||
te = re.search(r"(?im)^transfer-encoding:.*chunked", head)
|
||||
start = sep + 4
|
||||
if te:
|
||||
j, body = start, bytearray()
|
||||
while True:
|
||||
nl = buf.find(b"\r\n", j)
|
||||
if nl < 0:
|
||||
return out
|
||||
try:
|
||||
n = int(bytes(buf[j:nl]).split(b";")[0], 16)
|
||||
except ValueError:
|
||||
return out
|
||||
j = nl + 2
|
||||
if n == 0:
|
||||
j = buf.find(b"\r\n", j)
|
||||
j = j + 2 if j >= 0 else len(buf)
|
||||
break
|
||||
body.extend(buf[j:j + n])
|
||||
j += n + 2
|
||||
out.append((int(st.group(1)) if st else None, bytes(body)))
|
||||
i = j
|
||||
elif cl:
|
||||
n = int(cl.group(1))
|
||||
out.append((int(st.group(1)) if st else None, bytes(buf[start:start + n])))
|
||||
i = start + n
|
||||
else:
|
||||
out.append((int(st.group(1)) if st else None, b""))
|
||||
i = start
|
||||
return out
|
||||
|
||||
|
||||
def time_at(marks, off):
|
||||
t = marks[0][1] if marks else 0
|
||||
for pos, unix in marks:
|
||||
if pos > off:
|
||||
break
|
||||
t = unix
|
||||
return t
|
||||
|
||||
|
||||
def item_ids(body):
|
||||
"""Item ids in a response, if it looks like an item list."""
|
||||
try:
|
||||
d = json.loads(body)
|
||||
except Exception:
|
||||
return None
|
||||
ids = []
|
||||
|
||||
def walk(o):
|
||||
if isinstance(o, dict):
|
||||
if "id" in o and isinstance(o["id"], int):
|
||||
ids.append(o["id"])
|
||||
for v in o.values():
|
||||
walk(v)
|
||||
elif isinstance(o, list):
|
||||
for v in o:
|
||||
walk(v)
|
||||
|
||||
walk(d)
|
||||
return ids
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--session", required=True)
|
||||
ap.add_argument("--baseline", default="NO_FILTER")
|
||||
a = ap.parse_args()
|
||||
|
||||
import datetime
|
||||
conns = load(a.session)
|
||||
marks = labels(a.session)
|
||||
|
||||
rows = []
|
||||
for cid in sorted(conns):
|
||||
c = conns[cid]
|
||||
reqs = split_requests(c["c2s"])
|
||||
resps = split_responses(c["s2c"])
|
||||
for i, (off, method, target, hdrs, body) in enumerate(reqs):
|
||||
unix = time_at(c["marks"], off)
|
||||
secs = datetime.datetime.utcfromtimestamp(unix)
|
||||
secs = secs.hour * 3600 + secs.minute * 60 + secs.second
|
||||
label = "boot"
|
||||
for name, at in marks:
|
||||
if at <= secs:
|
||||
label = name
|
||||
status, rbody = resps[i] if i < len(resps) else (None, b"")
|
||||
path, _, query = target.partition("?")
|
||||
ids = item_ids(rbody)
|
||||
rows.append({
|
||||
"label": label,
|
||||
"time": datetime.datetime.utcfromtimestamp(unix).strftime("%H:%M:%S"),
|
||||
"conn": cid, "method": method, "path": path,
|
||||
"query": dict(parse_qsl(query, keep_blank_values=True)) if query else {},
|
||||
"query_raw": query,
|
||||
"req_body": body.decode("latin1") if len(body) < 2000 else "<%dB>" % len(body),
|
||||
"status": status,
|
||||
"resp_len": len(rbody),
|
||||
"resp_sha": hashlib.sha256(rbody).hexdigest()[:12],
|
||||
"item_count": len(ids) if ids is not None else None,
|
||||
"item_ids": ids[:40] if ids else None,
|
||||
})
|
||||
|
||||
print("=== requests by label ===")
|
||||
for r in rows:
|
||||
if r["label"] == "boot":
|
||||
continue
|
||||
print("%-18s %s conn%-3d %-5s %-46s %s %5dB sha=%s items=%s"
|
||||
% (r["label"], r["time"], r["conn"], r["method"], r["path"][:46],
|
||||
r["status"], r["resp_len"], r["resp_sha"],
|
||||
r["item_count"] if r["item_count"] is not None else "-"))
|
||||
if r["query"]:
|
||||
print("%-18s query: %s" % ("", r["query"]))
|
||||
if r["req_body"].strip():
|
||||
print("%-18s body : %s" % ("", r["req_body"][:200]))
|
||||
|
||||
# Field-level diff against the baseline label.
|
||||
base = [r for r in rows if r["label"] == a.baseline]
|
||||
if not base:
|
||||
print("\n(no %s rows yet; skipping diff)" % a.baseline)
|
||||
return 0
|
||||
print("\n=== query-field diff vs %s ===" % a.baseline)
|
||||
bq = base[-1]["query"]
|
||||
bpath = base[-1]["path"]
|
||||
seen = set()
|
||||
for r in rows:
|
||||
if r["label"] in ("boot", a.baseline) or r["label"] in seen:
|
||||
continue
|
||||
seen.add(r["label"])
|
||||
added = {k: v for k, v in r["query"].items() if bq.get(k) != v}
|
||||
removed = {k: v for k, v in bq.items() if k not in r["query"]}
|
||||
note = "" if r["path"] == bpath else " PATH DIFFERS: %s" % r["path"]
|
||||
print(" %-18s +%s -%s%s" % (r["label"], added or "{}", removed or "{}", note))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,5 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# OpenFUT Setup Script
|
||||
# OpenFUT Setup Script — ⚠️ LEGACY (FIFA 23 core+bridge lineage)
|
||||
#
|
||||
# SUPERSEDED. The working target is FIFA 17. This script builds/starts the old
|
||||
# FIFA 23 core+bridge stack and redirects EA domains for FIFA 23. Do NOT use it
|
||||
# for the current server. Canonical server bring-up (FIFA 17):
|
||||
# cd fifa17-recon/docker/fifa17-python && docker compose up -d
|
||||
# Status: docs/PROJECT_STATE.md · Runbook: fifa17-recon/FUT-RUNBOOK.md
|
||||
#
|
||||
# Builds, configures, and starts the OpenFUT offline FUT emulator for FIFA 23.
|
||||
# Run as a regular user; the script will sudo only for hosts/cert/iptables steps.
|
||||
|
||||
@@ -407,6 +414,11 @@ EOF
|
||||
}
|
||||
|
||||
# ── Dispatch ───────────────────────────────────────────────────────────────────
|
||||
# --- Legacy guard: this is the superseded FIFA 23 flow (see header banner). ---
|
||||
echo "[LEGACY] setup.sh drives the superseded FIFA 23 core+bridge stack." >&2
|
||||
echo "[LEGACY] Canonical FIFA 17 server: cd fifa17-recon/docker/fifa17-python && docker compose up -d" >&2
|
||||
echo "[LEGACY] Status: docs/PROJECT_STATE.md · Runbook: fifa17-recon/FUT-RUNBOOK.md" >&2
|
||||
|
||||
case "${1:-help}" in
|
||||
quickstart) cmd_quickstart ;;
|
||||
build) cmd_build ;;
|
||||
|
||||
Reference in New Issue
Block a user