Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a6355cad5 | |||
| 770029f207 | |||
| 802f0f580f | |||
| 6c7d0856b6 | |||
| dcd470cddc | |||
| 8f98e6adda | |||
| 106cb83988 | |||
| 33300f2ad1 | |||
| 12ad04c9d4 | |||
| 9c2edc4eee | |||
| de747b79e6 | |||
| dddcfb917c | |||
| ff915a306e | |||
| d6aa704b01 | |||
| 054a912357 | |||
| 3442eac6f0 | |||
| db743ffd1f | |||
| ab62440dbf | |||
| 92520de6c3 | |||
| be4c52ae89 | |||
| 967a808d73 | |||
| f60dd4da31 | |||
| c59c7d88f7 | |||
| b24e96b7e6 | |||
| a8078e1d8e | |||
| c6abc435cb | |||
| 9f1fc1b47c | |||
| d8d704d441 | |||
| 07d4a92309 | |||
| afa5f620bd | |||
| 56bd9ddc85 | |||
| 9ddd80993c | |||
| 25f4ad12bc | |||
| d37a9d5b5e | |||
| 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:
|
||||
@@ -1,11 +1,37 @@
|
||||
# Copy to .env in this directory. Required for remote deployment.
|
||||
#
|
||||
# OPENFUT_ADVERTISE — the address of THIS host as seen from the game machine
|
||||
# (105). The responders advertise it to the client for every next hop (Blaze,
|
||||
# roster, UTAS, POW). Compose refuses to start without it.
|
||||
# OPENFUT_ADVERTISE — the IP address of THIS host as seen from the game machine
|
||||
# (105). Responders advertise it for Blaze, UTAS, telemetry, and QoS.
|
||||
OPENFUT_ADVERTISE=203.0.113.10 # <- REPLACE with this host's LAN IP
|
||||
|
||||
# OPENFUT_BIND — address the listeners bind inside the container.
|
||||
# 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
|
||||
|
||||
# FIFA17's roster verifier accepts dNSName SANs but ignores iPAddress SANs.
|
||||
# Advertise the certificate's DNS identity, then resolve that one hostname to
|
||||
# OPENFUT_ADVERTISE on the client without changing the URL or certificate.
|
||||
OPENFUT_ROSTER_HOST=winter15.gosredirector.ea.com:8081
|
||||
|
||||
# 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
|
||||
|
||||
@@ -37,18 +37,14 @@ RUN set -eu; \
|
||||
|
||||
COPY data/ /app/data/
|
||||
|
||||
# Redirector/roster TLS cert (CN/SAN = winter15.gosredirector.ea.com). ProtoSSL
|
||||
# cert-verify is patched client-side, so a self-signed cert is fine — but the
|
||||
# client dials the roster and redirector BY IP, and that path still checks the
|
||||
# SAN against the dialed address (it is NOT covered by the two patched gates), so
|
||||
# a cert without a matching IP SAN is rejected with fatal certificate_unknown
|
||||
# (docs/FIFA17_FUT_SQUAD_UPDATE_TLS.md). The advertised LAN IP is a RUNTIME value,
|
||||
# unknown here, so this bakes only a loopback-IP baseline and the entrypoint
|
||||
# reissues with IP:$OPENFUT_ADVERTISE at start.
|
||||
# Redirector/roster TLS certificate. FIFA17's roster verifier compares only
|
||||
# dNSName SAN entries, so deployment advertises winter15.gosredirector.ea.com
|
||||
# through OPENFUT_ROSTER_HOST and resolves that hostname on the client. The
|
||||
# entrypoint validates this stable certificate; it never reissues it for an IP
|
||||
# SAN that the verifier ignores.
|
||||
#
|
||||
# openssl therefore has to remain in the image for the entrypoint, not be dropped
|
||||
# with the apt lists. The pair is git-ignored (*.pem/*.key); regenerate if absent
|
||||
# so a fresh checkout builds without extra steps.
|
||||
# OpenSSL remains in the image both to create the git-ignored keypair on a fresh
|
||||
# checkout and to validate the configured DNS identity at startup.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends openssl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN if [ ! -s tools/redir_cert.pem ] || [ ! -s tools/redir_key.pem ]; then \
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
# cp .env.example .env # set OPENFUT_ADVERTISE to THIS host's LAN IP
|
||||
# docker compose up -d --build
|
||||
#
|
||||
# Brings up the 5 responders the game dials. OPENFUT_ADVERTISE is the address
|
||||
# the servers hand the client (105) for every next hop (Blaze, roster, UTAS,
|
||||
# POW) and is required — there is no silent loopback fallback in remote mode.
|
||||
# Brings up the 5 responders the game dials. OPENFUT_ADVERTISE is the server IP
|
||||
# handed out for Blaze, UTAS, telemetry, and QoS; OPENFUT_ROSTER_HOST is the
|
||||
# certificate DNS identity handed out for roster HTTPS.
|
||||
#
|
||||
# The client (105) still needs its first-hop redirect (hook or DNAT) plus
|
||||
# autopatch.py running locally; see client_arm.sh and the FIFARUNBOOK.
|
||||
@@ -25,6 +25,9 @@ services:
|
||||
# Address advertised to the client for the next hop. MUST be this host's
|
||||
# LAN IP as seen from the game machine (105). Required (see .env.example).
|
||||
OPENFUT_ADVERTISE: "${OPENFUT_ADVERTISE:?set OPENFUT_ADVERTISE in .env to this host's LAN IP, e.g. 203.0.113.10}"
|
||||
# FIFA17 roster TLS matches only certificate dNSName SANs. The client must
|
||||
# resolve this hostname to OPENFUT_ADVERTISE.
|
||||
OPENFUT_ROSTER_HOST: "${OPENFUT_ROSTER_HOST:-winter15.gosredirector.ea.com:8081}"
|
||||
# POW content advertises port 8080 by default, which collides with the
|
||||
# openfut-core publish on this host. Remap it to 8085 on the host and
|
||||
# advertise the remapped endpoint.
|
||||
@@ -36,10 +39,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
|
||||
|
||||
@@ -8,45 +8,39 @@
|
||||
# autopatch.py is NOT run here: it patches the FIFA17.exe process memory and must
|
||||
# run on the box the game runs on.
|
||||
#
|
||||
# Address behaviour is driven by two env vars (see each responder):
|
||||
# Address behaviour is driven by three env vars (see each responder):
|
||||
# OPENFUT_BIND bind address for every listener (container: 0.0.0.0)
|
||||
# OPENFUT_ADVERTISE address handed to the client for the next hop
|
||||
# (the server's LAN IP, e.g. 203.0.113.10)
|
||||
# OPENFUT_ADVERTISE IP address handed out for Blaze, UTAS, telemetry, and QoS
|
||||
# OPENFUT_ROSTER_HOST certificate DNS host:port handed out for roster HTTPS
|
||||
# ============================================================================
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$(readlink -f "$0")")/tools"
|
||||
|
||||
BIND="${OPENFUT_BIND:-0.0.0.0}"
|
||||
ADV="${OPENFUT_ADVERTISE:?OPENFUT_ADVERTISE must be set to the server LAN IP (e.g. 203.0.113.10)}"
|
||||
ROSTER_HOST="${OPENFUT_ROSTER_HOST:-winter15.gosredirector.ea.com:8081}"
|
||||
export OPENFUT_BIND="$BIND"
|
||||
export OPENFUT_ADVERTISE="$ADV"
|
||||
export OPENFUT_ROSTER_HOST="$ROSTER_HOST"
|
||||
# POW keys advertised by blaze must also point at the server, not loopback.
|
||||
export POW_HOST="${POW_HOST:-$ADV:8094}"
|
||||
export POW_CONTENT_HOST="${POW_CONTENT_HOST:-$ADV:8080}"
|
||||
export POW_ADDR="${POW_ADDR:-$BIND:8094}"
|
||||
export POW_CONTENT_ADDR="${POW_CONTENT_ADDR:-$BIND:8080}"
|
||||
|
||||
echo "[openfut] bind=$BIND advertise=$ADV"
|
||||
echo "[openfut] bind=$BIND advertise=$ADV roster=$ROSTER_HOST"
|
||||
|
||||
# The TLS cert every responder serves must carry the ADVERTISED IP in its SAN.
|
||||
# The client dials the roster (:8081) and redirector by that IP, and that path
|
||||
# validates the cert's SAN against the dialed address — it is NOT covered by the
|
||||
# two client-side ProtoSSL gates autopatch patches, so a cert lacking IP:$ADV is
|
||||
# rejected with fatal certificate_unknown and the FUT hub fails with "An error
|
||||
# occurred downloading the FUT Squad Update" (docs/FIFA17_FUT_SQUAD_UPDATE_TLS.md).
|
||||
# The advertised IP is unknown at image-build time, so reconcile it here: reissue
|
||||
# only when the current cert does not already carry it, so a restart reuses the
|
||||
# same cert (no per-start fingerprint churn) and this self-heals if $ADV changes.
|
||||
CERT=redir_cert.pem KEY=redir_key.pem
|
||||
if ! openssl x509 -in "$CERT" -noout -ext subjectAltName 2>/dev/null | grep -qF "IP Address:$ADV"; then
|
||||
echo "[openfut] reissuing TLS cert with SAN IP:$ADV (was missing it)"
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -keyout "$KEY" -out "$CERT" -days 3650 \
|
||||
-subj "/CN=winter15.gosredirector.ea.com" \
|
||||
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com,IP:$ADV,IP:127.0.0.1" \
|
||||
>/dev/null 2>&1 \
|
||||
&& echo "[openfut] cert SAN now: $(openssl x509 -in "$CERT" -noout -ext subjectAltName 2>/dev/null | tail -1 | tr -s ' ')" \
|
||||
|| { echo "[openfut] FATAL: could not reissue TLS cert" >&2; exit 1; }
|
||||
# FIFA17's roster verifier compares only dNSName SAN entries. It ignores a valid
|
||||
# iPAddress SAN when the advertised URL contains an IP literal, so certificate
|
||||
# regeneration cannot fix that URL. Keep the certificate stable and fail startup
|
||||
# if the configured roster hostname is not already one of its DNS identities.
|
||||
CERT=redir_cert.pem
|
||||
ROSTER_NAME="${ROSTER_HOST%%:*}"
|
||||
if ! openssl x509 -in "$CERT" -noout -checkhost "$ROSTER_NAME" >/dev/null 2>&1; then
|
||||
echo "[openfut] FATAL: TLS cert does not cover roster hostname $ROSTER_NAME" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[openfut] roster certificate matches $ROSTER_NAME; fingerprint: $(openssl x509 -in "$CERT" -noout -fingerprint -sha256)"
|
||||
|
||||
# name script extra-env
|
||||
declare -a SERVERS=(
|
||||
@@ -57,10 +51,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 +93,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.**
|
||||
|
||||
@@ -147,14 +147,13 @@ def refresh_account_identity():
|
||||
|
||||
# ================================================================== config
|
||||
#
|
||||
# Client/server split support (OpenFUT dev-container): two env vars, both
|
||||
# defaulting to loopback so the original all-on-localhost flow is byte-identical.
|
||||
# OPENFUT_BIND — the address the listeners bind (0.0.0.0 in a container).
|
||||
# OPENFUT_ADVERTISE — the address this server hands back to the client for the
|
||||
# NEXT hop (Blaze host, roster/UTAS/telemetry/QoS URLs). On
|
||||
# 105-local this is 127.0.0.1; on the 120 server it is the
|
||||
# server's LAN IP so the game dials 120 directly after the
|
||||
# first (hook/DNAT-redirected) contact.
|
||||
# Client/server split support (OpenFUT dev-container): bind and advertise default
|
||||
# to loopback so the original all-on-localhost flow is byte-identical.
|
||||
# OPENFUT_BIND — address the listeners bind (0.0.0.0 in a container).
|
||||
# OPENFUT_ADVERTISE — address handed back for Blaze, UTAS, telemetry, QoS,
|
||||
# and (unless overridden) the roster service.
|
||||
# OPENFUT_ROSTER_HOST — optional roster host:port advertised in HTTPS URLs.
|
||||
# Use a certificate dNSName and resolve it on the client.
|
||||
import os as _os_cfg
|
||||
_ADVERTISE = _os_cfg.environ.get("OPENFUT_ADVERTISE", "127.0.0.1")
|
||||
_BIND = _os_cfg.environ.get("OPENFUT_BIND", "127.0.0.1")
|
||||
@@ -563,9 +562,11 @@ OSDK_TICKER = []
|
||||
# never gets advance/back -> the silent FUT loading-screen hang. The store is the
|
||||
# MERGED '_all' section (getSection @0x14719e050), so any fetched CFID works; this
|
||||
# branch does NOT wrap the value ("https://%s" is only the ini path) -> ABSOLUTE url.
|
||||
# Serve HTTPS (EA's production value is https; the DirtySDK download mgr may reject
|
||||
# http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK.
|
||||
ROSTER_HOST = "%s:8081" % _ADVERTISE
|
||||
# Serve HTTPS (EA's production value is https; the DirtySDK download manager may
|
||||
# reject http). FIFA17's roster verifier accepts dNSName SANs but ignores
|
||||
# iPAddress SANs, so an IP-literal URL fails with certificate_unknown. A remote
|
||||
# deployment can advertise a certificate DNS name without changing other hosts.
|
||||
ROSTER_HOST = os.environ.get("OPENFUT_ROSTER_HOST") or "%s:8081" % _ADVERTISE
|
||||
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
|
||||
OSDK_ROSTER = [
|
||||
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST),
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+100
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Read back the MANAGER-ONLY chemistry slots the client resolved, and prove
|
||||
whether the server's `nation`/`leagueId` actually land in the record.
|
||||
|
||||
READ-ONLY. /proc/PID/mem is opened 'rb'; there is no write path in this file.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
`card_identity_probe` reads the PLAYER slots (F_NATION = 0x148, F_LEAGUE =
|
||||
0x154). A manager does not use those, so grading a manager with that tool
|
||||
reports nation=0 / leagueId=0 and looks like a server bug when it is only the
|
||||
wrong offsets.
|
||||
|
||||
`fifa17-recon/tools/fut_staff.py` records the manager layout from Ghidra:
|
||||
|
||||
rec+0x94 teamid (read by the card view-model)
|
||||
rec+0xde nation MANAGER-ONLY slot, u16
|
||||
rec+0xe0 leagueId MANAGER-ONLY slot, u16
|
||||
rec+0xe2 talkrating written by the managercards merge
|
||||
rec+0xe3 negotiation written by the managercards merge
|
||||
|
||||
The merge (FUN_1801356c0) NEVER writes +0xde or +0xe0, so whatever sits there
|
||||
came from OUR JSON and nowhere else. That makes those two u16s a direct,
|
||||
unambiguous test of the server's manager chemistry fields: if they read back as
|
||||
the values we served, the wire contract is PROVEN rather than inferred; if they
|
||||
read zero, the client discarded them and manager chemistry cannot be rendering.
|
||||
|
||||
Usage: python3 manager_chem_probe.py # grade every manager in the map
|
||||
"""
|
||||
import sys
|
||||
|
||||
import watch_club_model as W
|
||||
import card_identity_probe as P
|
||||
|
||||
MANAGER_CARDTYPE = 2 # FUN_1800d8330: cardsubtypeid 4 -> cardtype 2
|
||||
F_CARDTYPE = 0x4C
|
||||
F_RESOURCE = 0x18
|
||||
F_TEAMID = 0x94
|
||||
F_NATION_MGR = 0xDE
|
||||
F_LEAGUE_MGR = 0xE0
|
||||
F_TALKRATING = 0xE2
|
||||
F_NEGOTIATION = 0xE3
|
||||
REC_SIZE = 0x158
|
||||
|
||||
|
||||
def main():
|
||||
pid = W.find_pid()
|
||||
if pid is None:
|
||||
print("FIFA17.exe is not running.")
|
||||
return 1
|
||||
base = W.dll_base(pid)
|
||||
if base is None:
|
||||
print("pid %d is up but %s is not mapped yet." % (pid, W.DLL))
|
||||
return 1
|
||||
mem = W.Mem(pid)
|
||||
obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE))
|
||||
if not obj:
|
||||
print("CardsDb singleton is NULL (no FUT session loaded).")
|
||||
return 1
|
||||
|
||||
ns = W.nodes(mem, obj) if hasattr(W, "nodes") else P.nodes(mem, obj)
|
||||
print("pid=%d CardsDb=%#x walked=%d" % (pid, obj, len(ns)))
|
||||
print()
|
||||
print("%-10s %-8s %-8s %-8s %-10s %-10s %s"
|
||||
% ("resource", "teamid", "nation", "league", "talkrating", "negot", "verdict"))
|
||||
|
||||
found = 0
|
||||
for n in ns:
|
||||
rec = n + 0x28
|
||||
buf = mem.read(rec, REC_SIZE)
|
||||
if not buf or len(buf) < REC_SIZE:
|
||||
continue
|
||||
if P.u8(buf, F_CARDTYPE) != MANAGER_CARDTYPE:
|
||||
continue
|
||||
found += 1
|
||||
resource = P.u32(buf, F_RESOURCE)
|
||||
teamid = P.u32(buf, F_TEAMID)
|
||||
nation = P.u16(buf, F_NATION_MGR)
|
||||
league = P.u16(buf, F_LEAGUE_MGR)
|
||||
talk = P.u8(buf, F_TALKRATING)
|
||||
negot = P.u8(buf, F_NEGOTIATION)
|
||||
# +0xde and +0xe0 are never written by the merge, so a non-zero value
|
||||
# can only have come from the server's JSON.
|
||||
if nation and league:
|
||||
verdict = "SERVER FIELDS LANDED"
|
||||
elif nation or league:
|
||||
verdict = "PARTIAL -- one slot empty"
|
||||
else:
|
||||
verdict = "EMPTY -- client kept nothing we sent"
|
||||
print("%-10d %-8d %-8d %-8d %-10d %-10d %s"
|
||||
% (resource, teamid, nation, league, talk, negot, verdict))
|
||||
|
||||
if not found:
|
||||
print("(no manager record in the map -- the client has not been served one)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Dump the CLASSIFICATION fields the client stored for every card it holds, so
|
||||
the subtype->cardtype map and the itemState runtime values are read from the
|
||||
running game instead of inferred.
|
||||
|
||||
READ-ONLY. /proc/PID/mem is opened 'rb'; there is no write path in this file.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
Two things this project has repeatedly had to treat as INFERRED:
|
||||
|
||||
1. `FUN_1800d8330`'s cardsubtypeid -> cardtype map. It is read out of Ghidra
|
||||
(0..3->1 players, 4->2 manager, 5->3 headcoach, 6->10 gkcoach, 7->5 physio,
|
||||
8->4 fitnesscoach, 9..b->7), and the kit selector gate `FUN_1801c3480`
|
||||
branches on cardtype == 7. Serving a subtype whose cardtype we guessed
|
||||
wrong fails SILENTLY, because cardtype 9 has no arm in the merge.
|
||||
2. The itemState enum. The table at 0x180229d20 gives the tokens; the RUNTIME
|
||||
values the strings deserialize to (notably activeHomeKit/activeAwayKit ->
|
||||
101/102) have been carried as inferred.
|
||||
|
||||
Both are directly observable: the parser writes cardsubtypeid to rec+0x50, the
|
||||
derived cardtype to rec+0x4c, and the decoded itemState to rec+0x5c. Reading
|
||||
those back for every record turns the pair into measurements.
|
||||
|
||||
rec+0x18 resourceId
|
||||
rec+0x4c cardtype (derived by FUN_1800d8330 from cardsubtypeid)
|
||||
rec+0x50 cardsubtypeid (as sent)
|
||||
rec+0x5c itemState (decoded enum value)
|
||||
|
||||
Usage: python3 record_vocab_probe.py
|
||||
"""
|
||||
import collections
|
||||
import sys
|
||||
|
||||
import watch_club_model as W
|
||||
import card_identity_probe as P
|
||||
|
||||
F_RESOURCE = 0x18
|
||||
F_CARDTYPE = 0x4C
|
||||
F_SUBTYPE = 0x50
|
||||
F_ITEMSTATE = 0x5C
|
||||
REC_SIZE = 0x158
|
||||
|
||||
# What the Ghidra read of FUN_1800d8330 predicts, so a disagreement is loud.
|
||||
EXPECTED_CARDTYPE = {0: 1, 1: 1, 2: 1, 3: 1, 4: 2, 5: 3, 6: 10, 7: 5, 8: 4,
|
||||
9: 7, 10: 7, 11: 7}
|
||||
|
||||
|
||||
def main():
|
||||
pid = W.find_pid()
|
||||
if pid is None:
|
||||
print("FIFA17.exe is not running.")
|
||||
return 1
|
||||
base = W.dll_base(pid)
|
||||
if base is None:
|
||||
print("pid %d is up but %s is not mapped yet." % (pid, W.DLL))
|
||||
return 1
|
||||
mem = W.Mem(pid)
|
||||
obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE))
|
||||
if not obj:
|
||||
print("CardsDb singleton is NULL (no FUT session loaded).")
|
||||
return 1
|
||||
|
||||
ns = W.nodes(mem, obj) if hasattr(W, "nodes") else P.nodes(mem, obj)
|
||||
print("pid=%d CardsDb=%#x walked=%d\n" % (pid, obj, len(ns)))
|
||||
|
||||
pairs = collections.Counter()
|
||||
states = collections.Counter()
|
||||
rows = []
|
||||
for n in ns:
|
||||
buf = mem.read(n + 0x28, REC_SIZE)
|
||||
if not buf or len(buf) < REC_SIZE:
|
||||
continue
|
||||
resource = P.u32(buf, F_RESOURCE)
|
||||
cardtype = P.u8(buf, F_CARDTYPE)
|
||||
subtype = P.u8(buf, F_SUBTYPE)
|
||||
state = P.u8(buf, F_ITEMSTATE)
|
||||
pairs[(subtype, cardtype)] += 1
|
||||
states[state] += 1
|
||||
rows.append((resource, subtype, cardtype, state))
|
||||
|
||||
print("%-12s %-9s %-9s %s" % ("resource", "subtype", "cardtype", "itemState"))
|
||||
for r in sorted(rows):
|
||||
print("%-12d %-9d %-9d %d" % r)
|
||||
|
||||
print("\n--- MEASURED cardsubtypeid -> cardtype ---")
|
||||
for (sub, ct), n in sorted(pairs.items()):
|
||||
want = EXPECTED_CARDTYPE.get(sub)
|
||||
if want is None:
|
||||
verdict = "no Ghidra prediction for this subtype"
|
||||
elif want == ct:
|
||||
verdict = "agrees with FUN_1800d8330"
|
||||
else:
|
||||
verdict = "DISAGREES -- Ghidra said %d" % want
|
||||
print(" subtype %-4d -> cardtype %-4d (%d record(s)) %s" % (sub, ct, n, verdict))
|
||||
|
||||
print("\n--- MEASURED itemState runtime values ---")
|
||||
for st, n in sorted(states.items()):
|
||||
print(" %-5d %d record(s)" % (st, n))
|
||||
print("\nNOTE: a runtime value only appears here if the client was actually")
|
||||
print("served an item in that state. Absence is not evidence of absence.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Standalone contract test for Blaze roster-host advertisement."""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
TOOLS = os.path.dirname(os.path.abspath(__file__))
|
||||
if TOOLS not in sys.path:
|
||||
sys.path.insert(0, TOOLS)
|
||||
|
||||
ADVERTISE = "192.0.2.10"
|
||||
DNS_HOST = "winter15.gosredirector.ea.com:8081"
|
||||
|
||||
|
||||
def assert_roster_config(blaze, host):
|
||||
config = dict(blaze.OSDK_ROSTER)
|
||||
assert blaze.ROSTER_HOST == host
|
||||
assert config["ROSTERUPDATE_URL"] == (
|
||||
f"https://{host}/fifa17/fut/rosterupdate.xml"
|
||||
)
|
||||
assert config["ROSTER_URL"] == f"https://{host}/fifa17/roster/"
|
||||
assert config["ROSTER_VER"] == "0"
|
||||
assert config["ROSTER_CSUM"] == ""
|
||||
|
||||
|
||||
def main():
|
||||
old_advertise = os.environ.get("OPENFUT_ADVERTISE")
|
||||
old_roster_host = os.environ.get("OPENFUT_ROSTER_HOST")
|
||||
try:
|
||||
os.environ["OPENFUT_ADVERTISE"] = ADVERTISE
|
||||
os.environ.pop("OPENFUT_ROSTER_HOST", None)
|
||||
|
||||
import blaze_responder_v3b as blaze
|
||||
|
||||
blaze = importlib.reload(blaze)
|
||||
assert_roster_config(blaze, f"{ADVERTISE}:8081")
|
||||
|
||||
os.environ["OPENFUT_ROSTER_HOST"] = DNS_HOST
|
||||
blaze = importlib.reload(blaze)
|
||||
assert_roster_config(blaze, DNS_HOST)
|
||||
|
||||
os.environ["OPENFUT_ROSTER_HOST"] = ""
|
||||
blaze = importlib.reload(blaze)
|
||||
assert_roster_config(blaze, f"{ADVERTISE}:8081")
|
||||
finally:
|
||||
if old_advertise is None:
|
||||
os.environ.pop("OPENFUT_ADVERTISE", None)
|
||||
else:
|
||||
os.environ["OPENFUT_ADVERTISE"] = old_advertise
|
||||
if old_roster_host is None:
|
||||
os.environ.pop("OPENFUT_ROSTER_HOST", None)
|
||||
else:
|
||||
os.environ["OPENFUT_ROSTER_HOST"] = old_roster_host
|
||||
|
||||
print("PASS: roster host defaults, override, and URLs")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -38,6 +38,38 @@ pub struct Fifa17CardIdentity {
|
||||
/// FIFA `cardsubtypeid` for a non-player definition (consumable family /
|
||||
/// staff role), `0` for a player or when absent.
|
||||
pub subtype: i64,
|
||||
/// FIFA card-art class. Players default to `asset_id`; kit definitions carry
|
||||
/// the verified `fcc_kitcards.cardassetid` value (`35`).
|
||||
pub card_asset_id: u32,
|
||||
/// Source team id for a club kit, or a manager's real club. Zero for content
|
||||
/// kinds that do not use it.
|
||||
pub team_id: i64,
|
||||
/// Manager chemistry nation (`managercards.nation`), zero when unused.
|
||||
///
|
||||
/// The client NEVER supplies this: the managercards merge (`FUN_1801356c0`)
|
||||
/// leaves the manager-only record slot `rec+0xde` untouched, so the server is
|
||||
/// its only source. See `fifa17-recon/tools/fut_staff.py`.
|
||||
pub nation: i64,
|
||||
/// Manager chemistry league, zero when unused. Derived upstream through
|
||||
/// `manager.teamid` → `leagueteamlinks.leagueid`, because `managercards` has
|
||||
/// no league column. Lands in the equally untouched slot `rec+0xe0`.
|
||||
pub league_id: i64,
|
||||
/// EA's authored `rating` for a NON-PLAYER definition (`fcc_*.rating`), which
|
||||
/// Core does not model: an imported consumable's Core `overall` is 0, while
|
||||
/// the client's own copies carry 55..95 and the value drives the card level
|
||||
/// (`rec+0x54`) and therefore its quick-sell price. `None` → the caller falls
|
||||
/// back to Core's rating, which stays authoritative for players.
|
||||
pub rating: Option<u8>,
|
||||
/// `amount` (atom 0x1b) for a consumable definition — the bonus magnitude EA
|
||||
/// authored in the `fcc_*` row (+5 / +10 / +15 …). MANDATORY for the
|
||||
/// training, healing, fitness, play-style and manager-league families:
|
||||
/// omitting the key draws "-1" on the card, not "0".
|
||||
pub amount: Option<i64>,
|
||||
/// `contract` (atom 0xb8) for a contract-card definition (`cardsubtypeid`
|
||||
/// 201/202) — the number of matches the card grants. `fcc_contractcards` has
|
||||
/// no amount column, so this value comes from observed data; it is never
|
||||
/// defaulted here.
|
||||
pub contract: Option<i64>,
|
||||
}
|
||||
|
||||
/// The FIFA 17 numeric namespace policy for owned-item wire ids.
|
||||
@@ -58,6 +90,29 @@ impl Fifa17WireItemIdPolicy {
|
||||
pub fn owned_item_base_floor() -> i64 {
|
||||
Self::OWNED_ITEM_BASE + 1
|
||||
}
|
||||
|
||||
/// Identity scope for MATCH session ids.
|
||||
///
|
||||
/// A match id is deliberately NOT drawn from the owned-item scope. The
|
||||
/// oracle mints both from one counter, which is why an observed match id
|
||||
/// looks like an item id — but that is an artifact of a single-counter save
|
||||
/// file, not a client requirement. Here the identity store keeps a real
|
||||
/// reverse map, so an item-scoped match id would make
|
||||
/// `owned_id_for_wire` resolve a match to a bogus owned card and corrupt
|
||||
/// quick-sell and move. The store is generic over `(game, kind)`, so a
|
||||
/// separate scope costs one constant and cannot collide with, or advance,
|
||||
/// the owned-item watermark.
|
||||
pub const MATCH_KIND: &'static str = "match";
|
||||
|
||||
/// Base for match session ids. Clear of the owned-item range
|
||||
/// (`100_000_000+`) and of every synthetic overlay range the responder
|
||||
/// reserves (`≥ 9e8`). The client only requires a non-zero int.
|
||||
pub const MATCH_BASE: i64 = 200_000_000;
|
||||
|
||||
/// First match wire id (`200_000_001`).
|
||||
pub fn match_base_floor() -> i64 {
|
||||
Self::MATCH_BASE + 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Highest representable asset id (24 bits); above this `version` would be
|
||||
@@ -131,6 +186,27 @@ struct RawCard {
|
||||
/// FIFA `cardsubtypeid` for a non-player entry; absent → `0`.
|
||||
#[serde(default)]
|
||||
subtype: i64,
|
||||
/// Separate card-art id for non-player definitions; absent → `asset_id`.
|
||||
#[serde(default)]
|
||||
card_asset_id: Option<u32>,
|
||||
/// Source team id for a kit or manager definition; absent → `0`.
|
||||
#[serde(default)]
|
||||
team_id: Option<i64>,
|
||||
/// Manager chemistry nation; absent → `0`.
|
||||
#[serde(default)]
|
||||
nation: Option<i64>,
|
||||
/// Manager chemistry league; absent → `0`.
|
||||
#[serde(default)]
|
||||
league_id: Option<i64>,
|
||||
/// EA-authored rating for a non-player definition; absent → Core's rating.
|
||||
#[serde(default)]
|
||||
rating: Option<u8>,
|
||||
/// Consumable bonus magnitude (atom 0x1b); absent → key omitted.
|
||||
#[serde(default)]
|
||||
amount: Option<i64>,
|
||||
/// Contract-card grant (atom 0xb8); absent → key omitted.
|
||||
#[serde(default)]
|
||||
contract: Option<i64>,
|
||||
}
|
||||
|
||||
fn default_rareflag() -> i64 {
|
||||
@@ -187,6 +263,13 @@ impl Fifa17CardCatalog {
|
||||
rareflag: rc.rareflag,
|
||||
kind: ContentKind::from_str(&rc.kind),
|
||||
subtype: rc.subtype,
|
||||
card_asset_id: rc.card_asset_id.unwrap_or(rc.asset_id),
|
||||
team_id: rc.team_id.unwrap_or(0),
|
||||
nation: rc.nation.unwrap_or(0),
|
||||
league_id: rc.league_id.unwrap_or(0),
|
||||
rating: rc.rating,
|
||||
amount: rc.amount,
|
||||
contract: rc.contract,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -272,6 +355,52 @@ mod tests {
|
||||
assert_eq!(cat.lookup("card_missing"), None);
|
||||
}
|
||||
|
||||
/// The non-player definition fields a consumable needs, and the ABSENCE that
|
||||
/// must stay an absence: a defaulted `amount` would draw "-1" on the card and
|
||||
/// a defaulted `contract` would invent the number of matches a card grants.
|
||||
#[test]
|
||||
fn consumable_definition_fields_are_carried_and_never_defaulted() {
|
||||
let cat = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||
"fifa17_5003012":{"asset_id":5003012,"kind":"consumable","subtype":54,
|
||||
"card_asset_id":3,"rareflag":0,"rating":85,"amount":15},
|
||||
"fifa17_5001004":{"asset_id":5001004,"kind":"consumable","subtype":201,
|
||||
"card_asset_id":7,"rareflag":0,"rating":60,"contract":7},
|
||||
"fifa17_5003059":{"asset_id":5003059,"kind":"consumable","subtype":91,
|
||||
"card_asset_id":34,"rareflag":0,"rating":95},
|
||||
"fifa17_20801":{"asset_id":20801}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
// A training card: art id 3 (NOT the carddbid), EA's rating, amount 15.
|
||||
let training = cat.lookup("fifa17_5003012").unwrap();
|
||||
assert_eq!(training.kind, ContentKind::Consumable);
|
||||
assert_eq!(training.subtype, 54);
|
||||
assert_eq!(training.card_asset_id, 3);
|
||||
assert_eq!(training.rating, Some(85));
|
||||
assert_eq!(training.amount, Some(15));
|
||||
assert_eq!(training.contract, None);
|
||||
// A contract card takes its number from `contract`, not `amount`.
|
||||
let contract = cat.lookup("fifa17_5001004").unwrap();
|
||||
assert_eq!(contract.contract, Some(7));
|
||||
assert_eq!(contract.amount, None);
|
||||
// A position modifier needs neither.
|
||||
let position = cat.lookup("fifa17_5003059").unwrap();
|
||||
assert_eq!(position.amount, None);
|
||||
assert_eq!(position.contract, None);
|
||||
assert_eq!(position.card_asset_id, 34);
|
||||
// A player carries none of them and keeps Core's authoritative rating.
|
||||
let player = cat.lookup("fifa17_20801").unwrap();
|
||||
assert_eq!(player.kind, ContentKind::Player);
|
||||
assert_eq!(player.rating, None);
|
||||
assert_eq!(player.amount, None);
|
||||
assert_eq!(player.contract, None);
|
||||
assert_eq!(
|
||||
player.card_asset_id, player.asset_id,
|
||||
"a player's card art IS its asset id"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_cards_same_resource_is_a_conflict() {
|
||||
let err = Fifa17CardCatalog::from_json_str(
|
||||
@@ -397,7 +526,9 @@ mod tests {
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||
"fifa17_20801":{"asset_id":20801,"kind":"player","subtype":0},
|
||||
"fifa17_5003012":{"asset_id":5003012,"kind":"consumable","subtype":54,"rareflag":0},
|
||||
"fifa17_3000083":{"asset_id":3000083,"kind":"staff","subtype":8,"rareflag":0}
|
||||
"fifa17_3000083":{"asset_id":3000083,"kind":"staff","subtype":8,"rareflag":0},
|
||||
"fifa17_6300006":{"asset_id":6300006,"kind":"kit","subtype":9,
|
||||
"card_asset_id":35,"team_id":21,"rareflag":0}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -407,5 +538,10 @@ mod tests {
|
||||
assert_eq!(cat.kind_of("fifa17_3000083"), ContentKind::Staff);
|
||||
assert_eq!(cat.subtype_of("fifa17_3000083"), 8);
|
||||
assert_eq!(cat.lookup("fifa17_5003012").unwrap().rareflag, 0);
|
||||
let kit = cat.lookup("fifa17_6300006").unwrap();
|
||||
assert_eq!(kit.kind, ContentKind::Kit);
|
||||
assert_eq!(kit.subtype, 9);
|
||||
assert_eq!(kit.card_asset_id, 35);
|
||||
assert_eq!(kit.team_id, 21);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,33 +11,100 @@ use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::content_taxonomy::ContentKind;
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
use crate::fut::item::shape_item;
|
||||
use crate::fut::item::{shape_item, shape_kit_item, shape_staff_item, STAFF_CONTRACT};
|
||||
use crate::fut::item_state;
|
||||
// Re-exported so existing `club_response::{…}` callers keep working; the types
|
||||
// are now defined once in `fut::item`.
|
||||
pub use crate::fut::item::{CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats};
|
||||
pub use crate::fut::item::{
|
||||
CoreOwnedItem, Fifa17ConsumableIdentity, Fifa17Identity, Fifa17KitIdentity,
|
||||
Fifa17StaffIdentity, ItemIdentityResolver, ShapeStats,
|
||||
};
|
||||
|
||||
/// Shape the whole `/club` response. Items without a resolvable real asset id
|
||||
/// are dropped (counted in `ShapeStats`), never emitted with a fabricated id.
|
||||
/// Active club-level kit roles, keyed by Core owned-instance id.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct ActiveKitAssignments<'a> {
|
||||
pub home: Option<&'a str>,
|
||||
pub away: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Shape the player portion of `/club` (the historical/default query).
|
||||
pub fn shape_club_response<I: ItemIdentityResolver + ?Sized>(
|
||||
items: &[CoreOwnedItem],
|
||||
ent: &impl ReverseEntityResolver,
|
||||
ident: &I,
|
||||
) -> (Value, ShapeStats) {
|
||||
shape_club_response_with_kits(items, ent, ident, ActiveKitAssignments::default())
|
||||
}
|
||||
|
||||
/// Shape `/club` items, including ownership-backed active kit designations.
|
||||
///
|
||||
/// This envelope carries the two families whose record shape it can carry:
|
||||
/// players and kits, plus the staff family (manager + the four coach families).
|
||||
/// Consumables have their own route and their own STACK envelope, and the
|
||||
/// club-customisation families are counted and withheld — see each arm.
|
||||
pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
|
||||
items: &[CoreOwnedItem],
|
||||
ent: &impl ReverseEntityResolver,
|
||||
ident: &I,
|
||||
active_kits: ActiveKitAssignments<'_>,
|
||||
) -> (Value, ShapeStats) {
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
let mut stats = ShapeStats::default();
|
||||
for item in items {
|
||||
// Exclude non-player content (consumables/staff): a `/club` player list
|
||||
// must never render them as 0-rated players. Counted, never emitted.
|
||||
if ident.kind_of(item) != ContentKind::Player {
|
||||
stats.excluded_non_player += 1;
|
||||
continue;
|
||||
}
|
||||
match ident.resolve(item) {
|
||||
match ident.kind_of(item) {
|
||||
ContentKind::Player => match ident.resolve(item) {
|
||||
Some(id) => {
|
||||
out.push(shape_item(item, id, ent));
|
||||
stats.emitted += 1;
|
||||
}
|
||||
None => stats.dropped_no_asset += 1,
|
||||
},
|
||||
ContentKind::Kit => match ident.resolve_kit(item) {
|
||||
Some(id) => {
|
||||
let state = if active_kits.home == Some(item.owned_card_id.as_str()) {
|
||||
item_state::ACTIVE_HOME_KIT
|
||||
} else if active_kits.away == Some(item.owned_card_id.as_str()) {
|
||||
item_state::ACTIVE_AWAY_KIT
|
||||
} else {
|
||||
item_state::FREE
|
||||
};
|
||||
out.push(shape_kit_item(id, state));
|
||||
stats.emitted += 1;
|
||||
}
|
||||
None => stats.dropped_no_asset += 1,
|
||||
},
|
||||
// A manager is a staff card: both Core kinds resolve through the one
|
||||
// staff record shape, discriminated on the wire by `cardsubtypeid`
|
||||
// (the same set as `ContentKind::is_staff_family`, spelled out here
|
||||
// because a guard arm would not prove exhaustiveness).
|
||||
ContentKind::Manager | ContentKind::Staff => match ident.resolve_staff(item) {
|
||||
Some(id) => {
|
||||
out.push(shape_staff_item(id, STAFF_CONTRACT));
|
||||
stats.emitted += 1;
|
||||
}
|
||||
None => stats.dropped_no_asset += 1,
|
||||
},
|
||||
// Consumables have their OWN route and their own envelope:
|
||||
// `GET club/consumables/<category>`, whose element is a stack
|
||||
// wrapper, not an item (see [`crate::fut::consumables`]). A bare
|
||||
// consumable item in THIS envelope is accepted by the client and
|
||||
// silently discarded, so emitting one here would be a 200 that does
|
||||
// nothing — the worst failure shape in this project. Counted.
|
||||
ContentKind::Consumable => {
|
||||
stats.excluded_non_player += 1;
|
||||
}
|
||||
// Club customisation. The SUBTYPES are settled (kit 9, stadium 10,
|
||||
// badge 11, ball 30, league logo 31), but the RECORD is not: a
|
||||
// badge/stadium still needs the narrow `teamid`/`assetId` test that
|
||||
// the 2026-08-05 crash denied us, and a ball has no display name at
|
||||
// all except `localizedName`, which this project's own rule scores as
|
||||
// "the parser reads it" and NOT "sending it is safe". Counted and
|
||||
// withheld rather than guessed — ownership stays authoritative in
|
||||
// Core either way, and club/stats still counts these families so the
|
||||
// screen's own numbers are right.
|
||||
ContentKind::Badge | ContentKind::Ball | ContentKind::Stadium | ContentKind::Misc => {
|
||||
stats.excluded_non_player += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
(json!({ "itemData": out }), stats)
|
||||
@@ -207,11 +274,19 @@ mod tests {
|
||||
struct KindMapIdentity {
|
||||
ids: HashMap<String, Fifa17Identity>,
|
||||
kinds: HashMap<String, ContentKind>,
|
||||
kits: HashMap<String, Fifa17KitIdentity>,
|
||||
staff: HashMap<String, Fifa17StaffIdentity>,
|
||||
}
|
||||
impl ItemIdentityResolver for KindMapIdentity {
|
||||
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||
self.ids.get(&it.card_id).copied()
|
||||
}
|
||||
fn resolve_kit(&self, it: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
|
||||
self.kits.get(&it.card_id).copied()
|
||||
}
|
||||
fn resolve_staff(&self, it: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
|
||||
self.staff.get(&it.card_id).copied()
|
||||
}
|
||||
fn kind_of(&self, it: &CoreOwnedItem) -> ContentKind {
|
||||
self.kinds
|
||||
.get(&it.card_id)
|
||||
@@ -221,7 +296,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_and_staff_are_excluded_from_club_players() {
|
||||
fn consumables_are_excluded_but_staff_is_shaped() {
|
||||
let ent = entities();
|
||||
let id = |item_id: u32, asset: u32| Fifa17Identity {
|
||||
item_id,
|
||||
@@ -233,8 +308,19 @@ mod tests {
|
||||
ids: HashMap::from([
|
||||
("card_player".to_string(), id(100000001, 20801)),
|
||||
("card_consumable".to_string(), id(100000002, 5003012)),
|
||||
("card_staff".to_string(), id(100000003, 3000083)),
|
||||
]),
|
||||
kits: HashMap::new(),
|
||||
staff: HashMap::from([(
|
||||
"card_staff".to_string(),
|
||||
Fifa17StaffIdentity {
|
||||
item_id: 100000003,
|
||||
resource_id: 3000083,
|
||||
subtype: 8,
|
||||
nation: 0,
|
||||
league_id: 0,
|
||||
team_id: 0,
|
||||
},
|
||||
)]),
|
||||
kinds: HashMap::from([
|
||||
("card_consumable".to_string(), ContentKind::Consumable),
|
||||
("card_staff".to_string(), ContentKind::Staff),
|
||||
@@ -254,12 +340,129 @@ mod tests {
|
||||
item("oc3", "card_staff", 0, "", "", "", ""),
|
||||
];
|
||||
let (body, stats) = shape_club_response(&items, &ent, &ident);
|
||||
assert_eq!(stats.emitted, 1, "only the player is emitted");
|
||||
assert_eq!(stats.excluded_non_player, 2, "consumable + staff excluded");
|
||||
assert_eq!(
|
||||
stats.emitted, 2,
|
||||
"the player and the staff card are emitted"
|
||||
);
|
||||
assert_eq!(
|
||||
stats.excluded_non_player, 1,
|
||||
"only the consumable is excluded; staff has a wire envelope of its own"
|
||||
);
|
||||
assert_eq!(stats.dropped_no_asset, 0);
|
||||
let arr = body["itemData"].as_array().unwrap();
|
||||
assert_eq!(arr.len(), 1);
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0]["id"], 100000001, "the player survives");
|
||||
assert_eq!(arr[0]["itemType"], "player");
|
||||
let coach = &arr[1];
|
||||
assert_eq!(coach["id"], 100000003);
|
||||
assert_eq!(coach["resourceId"], 3000083);
|
||||
assert_eq!(coach["cardsubtypeid"], 8);
|
||||
assert_eq!(coach["itemType"], "staff");
|
||||
assert_eq!(coach["contract"], STAFF_CONTRACT);
|
||||
assert!(
|
||||
coach.get("nation").is_none()
|
||||
&& coach.get("leagueId").is_none()
|
||||
&& coach.get("teamid").is_none(),
|
||||
"a COACH has no nation/league/team column in the client's tables, so \
|
||||
those keys must be absent rather than invented as zeroes"
|
||||
);
|
||||
assert!(
|
||||
coach.get("attributeList").is_none() && coach.get("preferredPosition").is_none(),
|
||||
"both survive the client's merge and are read by the card view-model"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_carries_the_chemistry_fields_only_the_server_can_supply() {
|
||||
let ent = entities();
|
||||
let ident = KindMapIdentity {
|
||||
ids: HashMap::new(),
|
||||
kits: HashMap::new(),
|
||||
staff: HashMap::from([(
|
||||
"card_manager".to_string(),
|
||||
Fifa17StaffIdentity {
|
||||
item_id: 100004871,
|
||||
resource_id: 1000509,
|
||||
subtype: 4,
|
||||
nation: 45,
|
||||
league_id: 53,
|
||||
team_id: 241,
|
||||
},
|
||||
)]),
|
||||
kinds: HashMap::from([("card_manager".to_string(), ContentKind::Staff)]),
|
||||
};
|
||||
let items = vec![item("oc-mgr", "card_manager", 0, "", "", "", "")];
|
||||
let (body, stats) = shape_club_response(&items, &ent, &ident);
|
||||
assert_eq!(stats.emitted, 1);
|
||||
let mgr = &body["itemData"][0];
|
||||
assert_eq!(
|
||||
mgr["cardsubtypeid"], 4,
|
||||
"subtype alone selects managercards"
|
||||
);
|
||||
assert_eq!(
|
||||
mgr["resourceId"], 1000509,
|
||||
"the merge key is read RAW: it must equal the carddbid with no version byte"
|
||||
);
|
||||
// rec+0xde / rec+0xe0 / rec+0x94 — the merge never writes these, so an
|
||||
// omission here is an unrecoverable blank flag and zero chemistry.
|
||||
assert_eq!(mgr["nation"], 45);
|
||||
assert_eq!(mgr["leagueId"], 53);
|
||||
assert_eq!(mgr["teamid"], 241);
|
||||
assert_eq!(mgr["contract"], STAFF_CONTRACT);
|
||||
assert_eq!(mgr["itemState"], "free");
|
||||
assert_eq!(mgr["owners"], 1);
|
||||
let keys: Vec<&String> = mgr.as_object().unwrap().keys().collect();
|
||||
assert_eq!(
|
||||
keys.len(),
|
||||
11,
|
||||
"exactly the 11 justified keys, no more: {keys:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kits_project_with_owned_active_home_and_away_states() {
|
||||
let ent = entities();
|
||||
let kit = |item_id, resource_id, team_id| Fifa17KitIdentity {
|
||||
item_id,
|
||||
asset_id: resource_id,
|
||||
resource_id,
|
||||
card_asset_id: 35,
|
||||
subtype: 9,
|
||||
team_id,
|
||||
};
|
||||
let ident = KindMapIdentity {
|
||||
ids: HashMap::new(),
|
||||
staff: HashMap::new(),
|
||||
kits: HashMap::from([
|
||||
("kit-home".into(), kit(100000010, 6300006, 21)),
|
||||
("kit-away".into(), kit(100000011, 6400003, 21)),
|
||||
]),
|
||||
kinds: HashMap::from([
|
||||
("kit-home".into(), ContentKind::Kit),
|
||||
("kit-away".into(), ContentKind::Kit),
|
||||
]),
|
||||
};
|
||||
let items = vec![
|
||||
item("owned-home", "kit-home", 0, "", "", "", ""),
|
||||
item("owned-away", "kit-away", 0, "", "", "", ""),
|
||||
];
|
||||
let (body, stats) = shape_club_response_with_kits(
|
||||
&items,
|
||||
&ent,
|
||||
&ident,
|
||||
ActiveKitAssignments {
|
||||
home: Some("owned-home"),
|
||||
away: Some("owned-away"),
|
||||
},
|
||||
);
|
||||
assert_eq!(stats.emitted, 2);
|
||||
assert_eq!(body["itemData"][0]["resourceId"], 6300006);
|
||||
assert_eq!(body["itemData"][0]["cardassetid"], 35);
|
||||
assert_eq!(body["itemData"][0]["cardsubtypeid"], 9);
|
||||
assert_eq!(body["itemData"][0]["teamid"], 21);
|
||||
assert_eq!(body["itemData"][0]["itemState"], "activeHomeKit");
|
||||
assert_eq!(body["itemData"][1]["itemState"], "activeAwayKit");
|
||||
assert!(body["itemData"][0].get("attributeList").is_none());
|
||||
assert!(body["itemData"][0].get("itemType").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
//! (`FUN_18012fd40` atom table). The body is `{"stat":[{contextId,contextValue,
|
||||
//! type,typeValue}, …]}`:
|
||||
//! * a GLOBAL bucket (contextId 1, contextValue 0) with player tier counts,
|
||||
//! staff-by-family, consumables-by-family, and honest zeros for club items;
|
||||
//! staff/consumable families, owned-kit count, and honest zeros for other
|
||||
//! club-item families;
|
||||
//! * per-NATION buckets (contextId 3, contextValue = nation id) with the tier
|
||||
//! counts the MY CLUB summary panel sums into PLAYERS_EMPLOYED.
|
||||
//!
|
||||
@@ -23,14 +24,18 @@ use serde_json::{json, Value};
|
||||
use crate::fut::content_taxonomy::{consumable_family, ContentKind};
|
||||
|
||||
/// One owned item, already classified from the catalog + entity tables by the
|
||||
/// host. `subtype`/`rare` come from the FIFA catalog; `nation_id`/`league_id`/
|
||||
/// `team_id` from the reverse entity resolver (None = unresolved, bucket skipped).
|
||||
/// host. `subtype`/`rare`/`asset_id` come from the FIFA catalog; `nation_id`/
|
||||
/// `league_id`/`team_id` from the reverse entity resolver for players and from
|
||||
/// the kit table for kits (None = unresolved, bucket skipped).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClubStatInput {
|
||||
pub kind: ContentKind,
|
||||
pub subtype: i64,
|
||||
pub rating: i64,
|
||||
pub rare: bool,
|
||||
/// Base FIFA asset id. For a kit this is the `fcc_kitcards.assetid` family
|
||||
/// discriminator, which is what splits the home/away kit counters.
|
||||
pub asset_id: i64,
|
||||
pub nation_id: Option<i64>,
|
||||
pub league_id: Option<i64>,
|
||||
pub team_id: Option<i64>,
|
||||
@@ -57,7 +62,26 @@ const S_RARE: i64 = 0x05;
|
||||
const S_STAFF: i64 = 0x0A;
|
||||
const S_CONSUMABLES: i64 = 0x3C;
|
||||
const S_KITS: i64 = 0x28;
|
||||
const S_KITS_HOME: i64 = 0x29;
|
||||
const S_KITS_AWAY: i64 = 0x2A;
|
||||
const S_BADGES: i64 = 0x2D;
|
||||
const S_STADIA: i64 = 0x14;
|
||||
const S_BALLS: i64 = 0x1E;
|
||||
|
||||
/// First `carddbid` of the AWAY kit family. `fcc_kitcards` is split into a
|
||||
/// `63xxxxx` home family and a `64xxxxx` away family, and the table's own
|
||||
/// `assetid` column agrees exactly: across all 1482 rows, assetid 14 covers
|
||||
/// precisely the 828 `63xxxxx` ids and assetid 15 precisely the 654 `64xxxxx`
|
||||
/// ids, with no exceptions either way. A kit's catalog `asset_id` IS its
|
||||
/// carddbid, so the id itself is the family key -- the `assetid` column is not
|
||||
/// carried on the wire and would be a second source of truth for the same fact.
|
||||
const KIT_AWAY_FLOOR: i64 = 6_400_000;
|
||||
|
||||
/// Which kit family an owned kit belongs to. Only meaningful for
|
||||
/// [`ContentKind::Kit`]; the caller filters first.
|
||||
fn is_home_kit(kit: &ClubStatInput) -> bool {
|
||||
kit.asset_id < KIT_AWAY_FLOOR
|
||||
}
|
||||
|
||||
/// cardsubtypeid (staff family) -> stat id (STAFF_SUBTYPE_STAT).
|
||||
fn staff_stat(subtype: i64) -> Option<i64> {
|
||||
@@ -184,10 +208,10 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
|
||||
g.insert(sid, 0);
|
||||
}
|
||||
let mut staff_total = 0i64;
|
||||
for it in items
|
||||
.iter()
|
||||
.filter(|i| matches!(i.kind, ContentKind::Staff))
|
||||
{
|
||||
// A manager counts INSIDE the staff total (`staffManager` is a bucket within
|
||||
// it), so this selects the whole staff FAMILY, not `ContentKind::Staff`
|
||||
// alone — a `manager`-classified row would otherwise vanish from the panel.
|
||||
for it in items.iter().filter(|i| i.kind.is_staff_family()) {
|
||||
if let Some(sid) = staff_stat(it.subtype) {
|
||||
*g.get_mut(&sid).unwrap() += 1;
|
||||
staff_total += 1;
|
||||
@@ -215,12 +239,31 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
|
||||
}
|
||||
g.insert(S_CONSUMABLES, cons_total);
|
||||
|
||||
// club items: honest zeros (Core holds none; each is read by some panel).
|
||||
for sid in [
|
||||
0x14, 0x1E, 0x28, 0x29, 0x2A, 0x2D, 0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
|
||||
] {
|
||||
// Club items. THESE COUNTS ARE THE GATE: the client does not ask for a
|
||||
// family's items until club/stats reports a non-zero count for it (proven by
|
||||
// the consumables round, where two rounds of item work sat unrequested
|
||||
// because this panel answered zero). They are plain ints read by the same
|
||||
// getter/publisher shape as the live-proven PLAYERS_EMPLOYED rows, so every
|
||||
// family Core can own is counted here — including the ones whose ITEM record
|
||||
// shape is still withheld, because a count cannot desync a parser and a zero
|
||||
// guarantees the family is never even asked about. Unowned families stay
|
||||
// honest zeros.
|
||||
for sid in [0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38] {
|
||||
g.entry(sid).or_insert(0);
|
||||
}
|
||||
let count_kind =
|
||||
|want: ContentKind| items.iter().filter(|item| item.kind == want).count() as i64;
|
||||
g.insert(S_STADIA, count_kind(ContentKind::Stadium));
|
||||
g.insert(S_BALLS, count_kind(ContentKind::Ball));
|
||||
g.insert(S_BADGES, count_kind(ContentKind::Badge));
|
||||
let kits: Vec<&ClubStatInput> = items
|
||||
.iter()
|
||||
.filter(|item| matches!(item.kind, ContentKind::Kit))
|
||||
.collect();
|
||||
let home = kits.iter().filter(|kit| is_home_kit(kit)).count() as i64;
|
||||
g.insert(S_KITS, kits.len() as i64);
|
||||
g.insert(S_KITS_HOME, home);
|
||||
g.insert(S_KITS_AWAY, kits.len() as i64 - home);
|
||||
|
||||
let mut stat: Vec<Value> = g.iter().map(|(sid, v)| row(1, 0, *sid, *v)).collect();
|
||||
|
||||
@@ -238,10 +281,30 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
|
||||
by_ctx.entry(id).or_default().push(p);
|
||||
}
|
||||
}
|
||||
|
||||
// A kit belongs to the team that wears it and has no nation/league of its
|
||||
// own, so it only buckets on the team screen -- and it buckets there even if
|
||||
// the club owns no player from that team, which is the normal case for a kit
|
||||
// won from a pack.
|
||||
let mut kits_by_team: BTreeMap<i64, i64> = BTreeMap::new();
|
||||
if ctx == ContextField::Team {
|
||||
for kit in &kits {
|
||||
if let Some(id) = kit.team_id {
|
||||
*kits_by_team.entry(id).or_insert(0) += 1;
|
||||
by_ctx.entry(id).or_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (cid, sel) in &by_ctx {
|
||||
if ctx == ContextField::Team {
|
||||
stat.push(row(3, *cid, S_PLAYERS, sel.len() as i64));
|
||||
stat.push(row(3, *cid, S_KITS, 0));
|
||||
stat.push(row(
|
||||
3,
|
||||
*cid,
|
||||
S_KITS,
|
||||
kits_by_team.get(cid).copied().unwrap_or(0),
|
||||
));
|
||||
stat.push(row(3, *cid, 0x2E, 0)); // badgeDBid
|
||||
} else {
|
||||
let gold = sel.iter().filter(|i| i.rating >= 75).count() as i64;
|
||||
@@ -270,6 +333,7 @@ mod tests {
|
||||
subtype: 0,
|
||||
rating,
|
||||
rare,
|
||||
asset_id: 158023,
|
||||
nation_id: nation,
|
||||
league_id: None,
|
||||
team_id: None,
|
||||
@@ -281,6 +345,7 @@ mod tests {
|
||||
subtype,
|
||||
rating: 0,
|
||||
rare: false,
|
||||
asset_id: 0,
|
||||
nation_id: None,
|
||||
league_id: None,
|
||||
team_id: None,
|
||||
@@ -292,11 +357,34 @@ mod tests {
|
||||
subtype,
|
||||
rating: 0,
|
||||
rare: false,
|
||||
asset_id: 0,
|
||||
nation_id: None,
|
||||
league_id: None,
|
||||
team_id: None,
|
||||
}
|
||||
}
|
||||
/// A kit worn by `team`. `carddbid` is the real `fcc_kitcards` id, which is
|
||||
/// also the catalog `asset_id` and therefore the home/away family key.
|
||||
fn kit_of(carddbid: i64, team: i64) -> ClubStatInput {
|
||||
ClubStatInput {
|
||||
kind: ContentKind::Kit,
|
||||
subtype: 9,
|
||||
rating: 0,
|
||||
rare: false,
|
||||
asset_id: carddbid,
|
||||
nation_id: None,
|
||||
league_id: None,
|
||||
team_id: Some(team),
|
||||
}
|
||||
}
|
||||
/// Real team-21 kits from `fcc_kitcards`: 6300006 is its home kit and
|
||||
/// 6400003 its away kit.
|
||||
const HOME_KIT: i64 = 6_300_006;
|
||||
const AWAY_KIT: i64 = 6_400_003;
|
||||
|
||||
fn kit() -> ClubStatInput {
|
||||
kit_of(HOME_KIT, 21)
|
||||
}
|
||||
|
||||
fn global(body: &Value) -> std::collections::HashMap<String, i64> {
|
||||
body["stat"]
|
||||
@@ -338,6 +426,59 @@ mod tests {
|
||||
assert_eq!(g["staffManager"], 0);
|
||||
}
|
||||
|
||||
/// A row Core classifies as `manager` must still land in the STAFF bucket and
|
||||
/// in `staffManager`: the client's own model counts a manager inside its staff
|
||||
/// total, and the two encodings (`manager`, or `staff` + subtype 4) are the
|
||||
/// same card.
|
||||
#[test]
|
||||
fn a_manager_counts_inside_staff_under_either_kind_token() {
|
||||
for kind in [ContentKind::Manager, ContentKind::Staff] {
|
||||
let mut manager = staff(4);
|
||||
manager.kind = kind;
|
||||
let g = global(&club_stats_body(&[manager, staff(8)], ContextField::Nation));
|
||||
assert_eq!(g["staffManager"], 1, "kind={}", kind.as_str());
|
||||
assert_eq!(
|
||||
g["staff"],
|
||||
2,
|
||||
"the manager is INSIDE the staff total (kind={})",
|
||||
kind.as_str()
|
||||
);
|
||||
assert_eq!(g["staffFitnessCoach"], 1);
|
||||
assert_eq!(g["players"], 0, "a manager is not a player");
|
||||
}
|
||||
}
|
||||
|
||||
/// The count is the GATE: the client will not ask for a family's items until
|
||||
/// this panel reports a non-zero count for it, so an owned badge/ball/stadium
|
||||
/// must be counted even while its item record is withheld.
|
||||
#[test]
|
||||
fn owned_club_items_are_counted_per_family() {
|
||||
let club_item = |kind: ContentKind, subtype: i64| ClubStatInput {
|
||||
kind,
|
||||
subtype,
|
||||
rating: 0,
|
||||
rare: false,
|
||||
asset_id: 0,
|
||||
nation_id: None,
|
||||
league_id: None,
|
||||
team_id: None,
|
||||
};
|
||||
let items = vec![
|
||||
club_item(ContentKind::Badge, 11),
|
||||
club_item(ContentKind::Badge, 11),
|
||||
club_item(ContentKind::Ball, 30),
|
||||
club_item(ContentKind::Stadium, 10),
|
||||
kit(),
|
||||
];
|
||||
let g = global(&club_stats_body(&items, ContextField::Nation));
|
||||
assert_eq!(g["badges"], 2);
|
||||
assert_eq!(g["balls"], 1);
|
||||
assert_eq!(g["stadia"], 1);
|
||||
assert_eq!(g["kits"], 1);
|
||||
assert_eq!(g["players"], 0, "no club item is ever a player");
|
||||
assert_eq!(g["leagueLogos"], 0, "not an ownable Core kind: honest zero");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumables_by_family() {
|
||||
// 54 gk_training, 201 player_contract, 217 healing, 258 player_playstyle
|
||||
@@ -355,6 +496,64 @@ mod tests {
|
||||
assert_eq!(g["consumablesTrainingPlayerPlayStyle"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_kits_increment_global_kit_count() {
|
||||
let items = vec![player(90, false, None), kit(), kit()];
|
||||
let g = global(&club_stats_body(&items, ContextField::Nation));
|
||||
assert_eq!(g["players"], 1);
|
||||
assert_eq!(g["kits"], 2);
|
||||
}
|
||||
|
||||
/// `kits` is the total and `kitsHome`/`kitsAway` are its family split, the
|
||||
/// same total/subset shape as players/playersGold and staff/staffManager.
|
||||
#[test]
|
||||
fn kit_counts_split_by_home_and_away_family() {
|
||||
let items = vec![
|
||||
kit_of(HOME_KIT, 21),
|
||||
kit_of(6_300_010, 38),
|
||||
kit_of(AWAY_KIT, 21),
|
||||
];
|
||||
let g = global(&club_stats_body(&items, ContextField::Nation));
|
||||
assert_eq!(g["kits"], 3);
|
||||
assert_eq!(g["kitsHome"], 2);
|
||||
assert_eq!(g["kitsAway"], 1);
|
||||
}
|
||||
|
||||
/// A kit buckets onto the team that wears it -- including a team the club
|
||||
/// owns no player from, which is the normal case for a kit won from a pack.
|
||||
#[test]
|
||||
fn kits_bucket_onto_their_own_team_on_the_team_screen() {
|
||||
let mut with_team = player(90, false, None);
|
||||
with_team.team_id = Some(21);
|
||||
let items = vec![
|
||||
with_team,
|
||||
kit_of(HOME_KIT, 21),
|
||||
kit_of(AWAY_KIT, 21),
|
||||
kit_of(6_300_010, 38),
|
||||
];
|
||||
let body = club_stats_body(&items, ContextField::Team);
|
||||
let kits_for = |team: i64| {
|
||||
body["stat"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|r| r["contextId"] == 3 && r["contextValue"] == team && r["type"] == "kits")
|
||||
.map(|r| r["typeValue"].as_i64().unwrap())
|
||||
};
|
||||
assert_eq!(kits_for(21), Some(2));
|
||||
// Team 38 has no players, so only the kit creates its bucket.
|
||||
assert_eq!(kits_for(38), Some(1));
|
||||
|
||||
// A nation/league screen has no team context, so kits stay out of it.
|
||||
let nation = club_stats_body(&items, ContextField::Nation);
|
||||
assert!(nation["stat"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|r| r["contextId"] == 3 && r["type"] == "kits")
|
||||
.all(|r| r["typeValue"] == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nation_buckets_emitted_and_players_excludes_nonplayers() {
|
||||
let items = vec![
|
||||
@@ -379,7 +578,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 +613,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).
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
//! The FIFA 17 **consumables screen** response — `GET …/club/consumables/<category>`.
|
||||
//!
|
||||
//! Consumables are NOT a `club?type=` family. A previous round shipped four
|
||||
//! `?type=` arms for them and the screen stayed empty, because the client asks
|
||||
//! HERE — and it asks only once `club/stats/consumables` reports a non-zero count
|
||||
//! for the family, so the counter in [`crate::fut::club_stats`] is the gate and
|
||||
//! this route is the door. Before that was known, the path fell through the
|
||||
//! generic `/club` PREFIX and the consumables screen was answered with the club's
|
||||
//! player list.
|
||||
//!
|
||||
//! ## The element is a STACK WRAPPER, not an item
|
||||
//!
|
||||
//! Learned the hard way (live, 2026-08-05): bare items here were ACCEPTED and did
|
||||
//! nothing — the client's card map afterwards held only the squad, and the screen
|
||||
//! stayed empty with no error anywhere. `FutConsumablesSearchServerResponse`
|
||||
//! (RS4 literal `0x1802222f8`, factory `0x180130a10`, vtable `0x180222200`,
|
||||
//! deserializer `+0x08` = `0x180130d10`) reads `itemData` (atom 0x16b) at the root
|
||||
//! like the club list, but its ELEMENT is a five-atom wrapper of which exactly one
|
||||
//! atom carries the item:
|
||||
//!
|
||||
//! | atom | key | |
|
||||
//! |---|---|---|
|
||||
//! | 0xbc | `count` | copies in the stack |
|
||||
//! | 0xd7 | `discardValue` | |
|
||||
//! | 0x16a | `item` | → `FUN_18013fe00`, the item parser itself |
|
||||
//! | 0x287 | `resourceId` | the stack's identity |
|
||||
//! | 0x362 | `untradeableCount` | drives a UI flag as `untradeableCount < count` |
|
||||
//!
|
||||
//! Everything else falls to the value-SKIP handler, which is exactly why a bare
|
||||
//! item was silently discarded. It is also why FUT draws consumables as one card
|
||||
//! with a quantity badge rather than N cards: identical copies COLLAPSE by
|
||||
//! `resourceId` here.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::item::{shape_consumable_item, Fifa17ConsumableIdentity, ShapeStats};
|
||||
|
||||
/// Build the consumables-screen body from the club's owned consumable copies.
|
||||
///
|
||||
/// Copies are collapsed by `resourceId` into one stack each, in first-seen order
|
||||
/// (deterministic: Core's own owned order), with `count` and `untradeableCount`
|
||||
/// counted over the copies. A copy whose definition is incomplete is DROPPED and
|
||||
/// counted — see [`Fifa17ConsumableIdentity::is_renderable`]; drawing "-1" or a
|
||||
/// different item than the club owns is worse than omitting the stack.
|
||||
///
|
||||
/// `discardValue` is `0`: the client computes a card's own quick-sell price from
|
||||
/// `fcc_discardcoins` on `(cardtype 6, level, rare)`, and `0` is the value the
|
||||
/// live-proven oracle sends on this route. Inventing a price from the player
|
||||
/// quick-sell table would be a fabricated number the client does not need.
|
||||
///
|
||||
/// The stack's `item` is the FIRST copy, so its `id` is a real owned wire id — a
|
||||
/// later item operation on the stack therefore addresses a card the club really
|
||||
/// owns. (Which copy a quick-sell of a whole stack should consume is a lifecycle
|
||||
/// question, not a projection one, and is not decided here.)
|
||||
pub fn consumables_response(items: &[Fifa17ConsumableIdentity]) -> (Value, ShapeStats) {
|
||||
let mut stats = ShapeStats::default();
|
||||
// (resource_id, index into `stacks`) — a Vec keeps first-seen order without a
|
||||
// second sort, and a club holds tens of stacks, not thousands.
|
||||
let mut order: Vec<u32> = Vec::new();
|
||||
let mut stacks: Vec<Value> = Vec::new();
|
||||
for id in items {
|
||||
if !id.is_renderable() {
|
||||
stats.dropped_incomplete += 1;
|
||||
continue;
|
||||
}
|
||||
stats.emitted += 1;
|
||||
match order.iter().position(|r| *r == id.resource_id) {
|
||||
Some(i) => {
|
||||
let stack = stacks[i].as_object_mut().expect("stack is an object");
|
||||
let count = stack["count"].as_i64().unwrap_or(0) + 1;
|
||||
stack["count"] = json!(count);
|
||||
if id.untradeable {
|
||||
let untradeable = stack["untradeableCount"].as_i64().unwrap_or(0) + 1;
|
||||
stack["untradeableCount"] = json!(untradeable);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
order.push(id.resource_id);
|
||||
stacks.push(json!({
|
||||
"count": 1,
|
||||
"discardValue": 0,
|
||||
"item": shape_consumable_item(*id),
|
||||
"resourceId": id.resource_id,
|
||||
"untradeableCount": i64::from(id.untradeable),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
(json!({ "itemData": stacks }), stats)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A play-style card (category 9): `amount` mandatory, art id 50.
|
||||
fn playstyle(item_id: u32, resource_id: u32) -> Fifa17ConsumableIdentity {
|
||||
Fifa17ConsumableIdentity {
|
||||
item_id,
|
||||
resource_id,
|
||||
asset_id: resource_id,
|
||||
card_asset_id: 50,
|
||||
subtype: 258,
|
||||
rareflag: 0,
|
||||
rating: 95,
|
||||
amount: Some(2),
|
||||
contract: None,
|
||||
untradeable: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_copies_collapse_into_one_counted_stack() {
|
||||
// Two copies of 5003103 plus one of 5003112 → two stacks, counts 2 and 1.
|
||||
let items = vec![
|
||||
playstyle(100000293, 5_003_103),
|
||||
playstyle(100000326, 5_003_112),
|
||||
playstyle(100000294, 5_003_103),
|
||||
];
|
||||
let (body, stats) = consumables_response(&items);
|
||||
assert_eq!(stats.emitted, 3, "every copy is accounted for");
|
||||
let stacks = body["itemData"].as_array().unwrap();
|
||||
assert_eq!(stacks.len(), 2, "collapsed by resourceId");
|
||||
assert_eq!(stacks[0]["resourceId"], 5_003_103);
|
||||
assert_eq!(stacks[0]["count"], 2);
|
||||
assert_eq!(stacks[0]["untradeableCount"], 2);
|
||||
assert_eq!(stacks[1]["resourceId"], 5_003_112);
|
||||
assert_eq!(stacks[1]["count"], 1);
|
||||
// The five wrapper atoms and nothing else: anything extra falls to the
|
||||
// value-SKIP handler and only misleads the next reader.
|
||||
let mut keys: Vec<&str> = stacks[0]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
keys.sort_unstable();
|
||||
assert_eq!(
|
||||
keys,
|
||||
vec![
|
||||
"count",
|
||||
"discardValue",
|
||||
"item",
|
||||
"resourceId",
|
||||
"untradeableCount"
|
||||
]
|
||||
);
|
||||
// The item rides inside the wrapper, not beside it.
|
||||
assert_eq!(stacks[0]["item"]["id"], 100000293);
|
||||
assert_eq!(stacks[0]["item"]["cardsubtypeid"], 258);
|
||||
assert_eq!(stacks[0]["item"]["cardassetid"], 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tradeable_copy_lowers_untradeable_count_below_the_stack_count() {
|
||||
// The client's UI flag is `untradeableCount < count`, so the two numbers
|
||||
// must be counted over the same copies.
|
||||
let mut tradeable = playstyle(100000295, 5_003_103);
|
||||
tradeable.untradeable = false;
|
||||
let items = vec![playstyle(100000293, 5_003_103), tradeable];
|
||||
let (body, _) = consumables_response(&items);
|
||||
let stack = &body["itemData"][0];
|
||||
assert_eq!(stack["count"], 2);
|
||||
assert_eq!(stack["untradeableCount"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_definitions_are_dropped_and_counted_never_drawn_wrong() {
|
||||
// (a) a play style with no `amount` would draw "-1" on the card;
|
||||
let mut no_amount = playstyle(100000293, 5_003_103);
|
||||
no_amount.amount = None;
|
||||
// (b) rareflag on 219 turns Player Fitness into SQUAD Fitness;
|
||||
let trap = Fifa17ConsumableIdentity {
|
||||
item_id: 100000300,
|
||||
resource_id: 5_002_030,
|
||||
asset_id: 5_002_030,
|
||||
card_asset_id: 9,
|
||||
subtype: 219,
|
||||
rareflag: 1,
|
||||
rating: 70,
|
||||
amount: Some(10),
|
||||
contract: None,
|
||||
untradeable: true,
|
||||
};
|
||||
// (c) a subtype in no documented range renders as a plausible Squad
|
||||
// Training (Pace) card with amount 0.
|
||||
let mut dead_zone = playstyle(100000301, 5_003_999);
|
||||
dead_zone.subtype = 137;
|
||||
// (d) no `card_asset_id` in the catalog → the resolver defaulted it to
|
||||
// the asset id and the client would draw the notfound.swf green box.
|
||||
let mut no_art = playstyle(100000302, 5_003_104);
|
||||
no_art.card_asset_id = no_art.asset_id;
|
||||
let (body, stats) = consumables_response(&[no_amount, trap, dead_zone, no_art]);
|
||||
assert_eq!(stats.emitted, 0);
|
||||
assert_eq!(stats.dropped_incomplete, 4);
|
||||
assert_eq!(body["itemData"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_contract_card_carries_contract_and_no_amount() {
|
||||
let contract = Fifa17ConsumableIdentity {
|
||||
item_id: 100000294,
|
||||
resource_id: 5_001_004,
|
||||
asset_id: 5_001_004,
|
||||
card_asset_id: 7,
|
||||
subtype: 201,
|
||||
rareflag: 0,
|
||||
rating: 60,
|
||||
amount: None,
|
||||
contract: Some(7),
|
||||
untradeable: true,
|
||||
};
|
||||
let (body, stats) = consumables_response(&[contract]);
|
||||
assert_eq!(stats.emitted, 1);
|
||||
let item = &body["itemData"][0]["item"];
|
||||
assert_eq!(item["contract"], 7);
|
||||
assert!(
|
||||
item.get("amount").is_none(),
|
||||
"categories 2 and 3 ignore `amount` entirely"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_club_is_an_empty_itemdata_not_a_missing_key() {
|
||||
let (body, stats) = consumables_response(&[]);
|
||||
assert_eq!(stats.emitted, 0);
|
||||
assert!(body["itemData"].as_array().unwrap().is_empty());
|
||||
assert_eq!(body.as_object().unwrap().len(), 1, "only itemData at root");
|
||||
}
|
||||
}
|
||||
@@ -18,15 +18,33 @@
|
||||
//! resolves to `None` — the caller DEFERS it (mirroring the player NoName gate),
|
||||
//! never fabricating a family.
|
||||
|
||||
/// The disjoint content classes a FIFA 17 owned card can belong to. Player is
|
||||
/// The disjoint content classes a FIFA 17 owned item can belong to. Player is
|
||||
/// the default so a catalog authored before this taxonomy existed (no `kind`
|
||||
/// field) still classifies every entry as a player, unchanged.
|
||||
///
|
||||
/// The token set is OpenFUT Core's game-independent content vocabulary
|
||||
/// (`player | manager | staff | consumable | kit | badge | ball | stadium |
|
||||
/// misc`), so a Core owned row and a FIFA 17 catalog entry name the same class
|
||||
/// with the same string and the FIFA numerics (`cardsubtypeid`, resource ranges)
|
||||
/// never leak out of this crate.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ContentKind {
|
||||
#[default]
|
||||
Player,
|
||||
Consumable,
|
||||
/// A MANAGER — its own Core kind, but on the FIFA 17 side it is a member of
|
||||
/// the STAFF family, never a class of its own: see
|
||||
/// [`ContentKind::is_staff_family`]. The wire discriminator is
|
||||
/// [`MANAGER_SUBTYPE`], not this token, so a catalog may classify a manager
|
||||
/// as either `manager` or `staff` + subtype 4 and every consumer here
|
||||
/// treats the two encodings identically.
|
||||
Manager,
|
||||
Staff,
|
||||
Consumable,
|
||||
Kit,
|
||||
Badge,
|
||||
Ball,
|
||||
Stadium,
|
||||
Misc,
|
||||
}
|
||||
|
||||
impl ContentKind {
|
||||
@@ -34,8 +52,14 @@ impl ContentKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ContentKind::Player => "player",
|
||||
ContentKind::Consumable => "consumable",
|
||||
ContentKind::Manager => "manager",
|
||||
ContentKind::Staff => "staff",
|
||||
ContentKind::Consumable => "consumable",
|
||||
ContentKind::Kit => "kit",
|
||||
ContentKind::Badge => "badge",
|
||||
ContentKind::Ball => "ball",
|
||||
ContentKind::Stadium => "stadium",
|
||||
ContentKind::Misc => "misc",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,11 +71,30 @@ impl ContentKind {
|
||||
#[allow(clippy::should_implement_trait)]
|
||||
pub fn from_str(s: &str) -> ContentKind {
|
||||
match s {
|
||||
"consumable" => ContentKind::Consumable,
|
||||
"manager" => ContentKind::Manager,
|
||||
"staff" => ContentKind::Staff,
|
||||
"consumable" => ContentKind::Consumable,
|
||||
"kit" => ContentKind::Kit,
|
||||
"badge" => ContentKind::Badge,
|
||||
"ball" => ContentKind::Ball,
|
||||
"stadium" => ContentKind::Stadium,
|
||||
"misc" => ContentKind::Misc,
|
||||
_ => ContentKind::Player,
|
||||
}
|
||||
}
|
||||
|
||||
/// True for the two kinds that make up the FIFA 17 STAFF family.
|
||||
///
|
||||
/// A manager IS a staff card: the client's own club-stats model counts it
|
||||
/// inside the `staff` total with `staffManager` as a bucket within it, its
|
||||
/// STAFF tab asks for the whole family with `type=manager`, and one record
|
||||
/// shape ([`crate::fut::item::shape_staff_item`]) serves all five families.
|
||||
/// Every staff consumer MUST use this predicate rather than matching
|
||||
/// `Staff` alone, or a `manager`-classified row silently leaves the staff
|
||||
/// bucket and the STAFF tab.
|
||||
pub fn is_staff_family(&self) -> bool {
|
||||
matches!(self, ContentKind::Manager | ContentKind::Staff)
|
||||
}
|
||||
}
|
||||
|
||||
/// The functional family + honest display label for a consumable `cardsubtypeid`,
|
||||
@@ -80,6 +123,12 @@ pub fn consumable_family(subtype: i64) -> Option<(&'static str, &'static str)> {
|
||||
Some(pair)
|
||||
}
|
||||
|
||||
/// `cardsubtypeid` of a MANAGER staff card. This value alone selects the
|
||||
/// `managercards` merge in the client (`FUN_1800d8330` → cardtype 2 →
|
||||
/// `FUN_1801356c0`), and it is what distinguishes a manager from the four coach
|
||||
/// families inside [`ContentKind::Staff`].
|
||||
pub const MANAGER_SUBTYPE: i64 = 4;
|
||||
|
||||
/// The staff role + honest display label for a staff `cardsubtypeid` (4..=8), or
|
||||
/// `None` for any other subtype (→ DEFER). Grounded in the `FUN_1800d8330`
|
||||
/// family selector.
|
||||
@@ -95,6 +144,172 @@ pub fn staff_role(subtype: i64) -> Option<(&'static str, &'static str)> {
|
||||
Some(pair)
|
||||
}
|
||||
|
||||
/// The ONE extra wire key a consumable family needs, or [`ConsumableNeeds::None`].
|
||||
///
|
||||
/// Taken verbatim from `fifa17-recon/data/consumables.json`'s per-subtype `needs`
|
||||
/// (generated by `build_consumables.py` from `FUN_18013f4d0`), and independently
|
||||
/// confirmed by the real profile import, where `amount` is present on exactly the
|
||||
/// training/healing/fitness/play-style/league families and `contract` on exactly
|
||||
/// the two contract families.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConsumableNeeds {
|
||||
/// `amount` (atom 0x1b → `rec+0xbf`, or `+0xbe` for a play style) is
|
||||
/// MANDATORY: the parser initialises its temp to -1 and both accessors read
|
||||
/// it SIGNED, so omitting the key draws "-1" on the card, not "0".
|
||||
Amount,
|
||||
/// `contract` (atom 0xb8 → `rec+0x8c`) carries the number the card grants;
|
||||
/// the two contract families IGNORE `amount` entirely.
|
||||
Contract,
|
||||
/// Nothing beyond the common key set — the card's whole meaning comes from
|
||||
/// `cardsubtypeid` (formation and position modifiers).
|
||||
None,
|
||||
}
|
||||
|
||||
/// Which extra key a consumable family requires. An unknown family name is
|
||||
/// [`ConsumableNeeds::None`]; callers get families from [`consumable_family`],
|
||||
/// so an unknown one cannot arrive from the wire.
|
||||
pub fn consumable_needs(family: &str) -> ConsumableNeeds {
|
||||
match family {
|
||||
"gk_training" | "player_training" | "healing" | "player_fitness" | "squad_fitness"
|
||||
| "player_playstyle" | "gk_playstyle" | "manager_league" => ConsumableNeeds::Amount,
|
||||
"player_contract" | "manager_contract" => ConsumableNeeds::Contract,
|
||||
_ => ConsumableNeeds::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The consumable families one `GET club/consumables/<category>` segment asks
|
||||
/// for, or `None` for a segment outside the client's own group table.
|
||||
///
|
||||
/// **This route, not `club?type=`.** Consumables are NOT a `?type=` family: a
|
||||
/// previous round shipped four `?type=` arms for them and the screen stayed
|
||||
/// empty, because the client asks here (and only once
|
||||
/// `club/stats/consumables` reports a non-zero count — the counter is the gate
|
||||
/// and this route is the door).
|
||||
///
|
||||
/// The segment names are the consumable UI group table at `0x180203260` (seven
|
||||
/// codes: `training`, `contracts`, `fitness`, `healing`, `playStyle`,
|
||||
/// `managerLeagueModifier`, `position`); `training` and `contracts` are CONFIRMED
|
||||
/// on the wire and the singular `contract` is accepted because the client has
|
||||
/// used both spellings. Segments are matched lower-cased.
|
||||
///
|
||||
/// The family sets are the `FUN_18013f4d0` categories those codes name, and the
|
||||
/// correspondence is checkable against the panel: training→42, contracts→13,
|
||||
/// healing→21, fitness→6, position→20, chemistry style→24 items in the oracle's
|
||||
/// own shelf. NOTE the two formation-modifier families (categories 6 and 7) have
|
||||
/// NO group code, so no segment can reach them — that is the client's own gap,
|
||||
/// not an omission here.
|
||||
pub fn consumable_families_for_category(segment: &str) -> Option<&'static [&'static str]> {
|
||||
Some(match segment {
|
||||
"training" => &["gk_training", "player_training"],
|
||||
"contracts" | "contract" => &["player_contract", "manager_contract"],
|
||||
"fitness" => &["player_fitness", "squad_fitness"],
|
||||
"healing" => &["healing"],
|
||||
"position" => &["position_mod"],
|
||||
"playstyle" => &["player_playstyle", "gk_playstyle"],
|
||||
"managerleaguemodifier" => &["manager_league"],
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The club-customisation `cardsubtypeid`s, SETTLED (supersedes
|
||||
/// `CARD_SYSTEM.md`'s "STILL UNKNOWN, AND NOT GUESSED" section, which is stale).
|
||||
///
|
||||
/// Kit 9, stadium 10 and badge 11 are cardtype **7** and resolve through
|
||||
/// `FUN_180119bd0` (the manager vtable slot `+0x498`, verified from disk and live
|
||||
/// memory); ball 30 (`0x1e`) and league logo 31 (`0x1f`) are cardtype 9, the
|
||||
/// latter by elimination over `FUN_1800d8330`'s cardtype-9 set. Four independent
|
||||
/// lines agree on kit = 9, including the deserializer's own `cardassetid` default
|
||||
/// of `0x23` = 35 for cardtype 7 / subtype 9 — exactly the `cardassetid` carried
|
||||
/// by all 1482 rows of `fcc_kitcards`.
|
||||
///
|
||||
/// `0x91..=0x96` are TROPHIES (tournament/season), not club items. The enum table
|
||||
/// at `0x180229ab0` (`badge=0xa kit=0xb leagueLogo=0xc … stadium=0x15 ball=0x16`)
|
||||
/// is the transfermarket `&cat=%s` vocabulary and NOT a subtype map: reading it as
|
||||
/// one swaps badge and kit and loses stadium.
|
||||
pub const KIT_SUBTYPE: i64 = 9;
|
||||
pub const STADIUM_SUBTYPE: i64 = 10;
|
||||
pub const BADGE_SUBTYPE: i64 = 11;
|
||||
pub const BALL_SUBTYPE: i64 = 30;
|
||||
pub const LEAGUE_LOGO_SUBTYPE: i64 = 31;
|
||||
|
||||
/// The club-customisation [`ContentKind`] for a `cardsubtypeid`, or `None` for a
|
||||
/// subtype outside the settled set above. A league logo has no Core kind of its
|
||||
/// own (it is not ownable club content in Core's vocabulary), so subtype 31
|
||||
/// deliberately maps to `None` rather than being folded into `Misc`.
|
||||
pub fn club_item_kind(subtype: i64) -> Option<ContentKind> {
|
||||
let kind = match subtype {
|
||||
KIT_SUBTYPE => ContentKind::Kit,
|
||||
STADIUM_SUBTYPE => ContentKind::Stadium,
|
||||
BADGE_SUBTYPE => ContentKind::Badge,
|
||||
BALL_SUBTYPE => ContentKind::Ball,
|
||||
_ => return None,
|
||||
};
|
||||
Some(kind)
|
||||
}
|
||||
|
||||
/// The three MY CLUB position tabs (`type=playerdefender|playermidfielder|
|
||||
/// playerforward`). `FUN_18012ddf0` remaps request field `*(req+0x14)` values
|
||||
/// `0x1c/0x1d/0x1e` onto type codes `0x1b/0x1c/0x1d` and SUPPRESSES `position=`,
|
||||
/// so a position tab arrives as one of those three tokens with no other filter.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PositionGroup {
|
||||
Defender,
|
||||
Midfielder,
|
||||
Forward,
|
||||
}
|
||||
|
||||
/// The FIFA 17 position ID for a FUT position label, from the client's OWN `pos`
|
||||
/// vocabulary — the NUL-terminated `{const char*, int}` table at `0x1802295c0`
|
||||
/// that it emits as the transfer-market `&pos=%s` parameter:
|
||||
/// `GK=0 RWB=2 RB=3 CB=5 LB=7 LWB=8 CDM=10 RM=12 CM=14 LM=16 CAM=18 RF=20 CF=21
|
||||
/// LF=22 RW=23 ST=25 LW=27`.
|
||||
///
|
||||
/// `None` = a label outside that table (never guessed): the item then belongs to
|
||||
/// no position tab rather than to an invented one.
|
||||
pub fn position_id(pos: &str) -> Option<i64> {
|
||||
let id = match pos {
|
||||
"GK" => 0,
|
||||
"RWB" => 2,
|
||||
"RB" => 3,
|
||||
"CB" => 5,
|
||||
"LB" => 7,
|
||||
"LWB" => 8,
|
||||
"CDM" => 10,
|
||||
"RM" => 12,
|
||||
"CM" => 14,
|
||||
"LM" => 16,
|
||||
"CAM" => 18,
|
||||
"RF" => 20,
|
||||
"CF" => 21,
|
||||
"LF" => 22,
|
||||
"RW" => 23,
|
||||
"ST" => 25,
|
||||
"LW" => 27,
|
||||
_ => return None,
|
||||
};
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// Which position tab a FUT position label belongs to, or `None` for a label
|
||||
/// outside the client's own `pos` table.
|
||||
///
|
||||
/// The ladder is the client's, not ours: `FUN_180135890` recomputes `rec+0x14c`
|
||||
/// from the position at `rec+0x146` as `0 → GK`, `1..=8 → DEF`, `9..=19 → MID`,
|
||||
/// `20..=27 → ATT`.
|
||||
///
|
||||
/// THE ONE GUESS, named: GK is folded into `Defender`, because the client has
|
||||
/// exactly three position tabs and no fourth, so a keeper must land in one of
|
||||
/// them or vanish from every drill-down. Falsifier: if the DEF tab renders
|
||||
/// without goalkeepers, move GK out (the group boundary becomes `1..=8`).
|
||||
pub fn position_group(pos: &str) -> Option<PositionGroup> {
|
||||
match position_id(pos)? {
|
||||
0..=8 => Some(PositionGroup::Defender),
|
||||
9..=19 => Some(PositionGroup::Midfielder),
|
||||
20..=27 => Some(PositionGroup::Forward),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -102,19 +317,171 @@ mod tests {
|
||||
#[test]
|
||||
fn content_kind_round_trips_and_defaults_to_player() {
|
||||
assert_eq!(ContentKind::default(), ContentKind::Player);
|
||||
for k in [
|
||||
// The FULL Core content vocabulary, every token round-tripping.
|
||||
let all = [
|
||||
ContentKind::Player,
|
||||
ContentKind::Consumable,
|
||||
ContentKind::Manager,
|
||||
ContentKind::Staff,
|
||||
] {
|
||||
ContentKind::Consumable,
|
||||
ContentKind::Kit,
|
||||
ContentKind::Badge,
|
||||
ContentKind::Ball,
|
||||
ContentKind::Stadium,
|
||||
ContentKind::Misc,
|
||||
];
|
||||
for k in all {
|
||||
assert_eq!(ContentKind::from_str(k.as_str()), k);
|
||||
}
|
||||
let tokens: Vec<&str> = all.iter().map(|k| k.as_str()).collect();
|
||||
assert_eq!(
|
||||
tokens,
|
||||
vec![
|
||||
"player",
|
||||
"manager",
|
||||
"staff",
|
||||
"consumable",
|
||||
"kit",
|
||||
"badge",
|
||||
"ball",
|
||||
"stadium",
|
||||
"misc"
|
||||
],
|
||||
"these exact strings are the cross-crate contract with Core"
|
||||
);
|
||||
// Unknown / absent tokens fall back to Player (backward compatible).
|
||||
assert_eq!(ContentKind::from_str(""), ContentKind::Player);
|
||||
assert_eq!(ContentKind::from_str("nonsense"), ContentKind::Player);
|
||||
assert_eq!(ContentKind::from_str("player"), ContentKind::Player);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_manager_and_staff_are_the_staff_family() {
|
||||
for k in [ContentKind::Manager, ContentKind::Staff] {
|
||||
assert!(k.is_staff_family(), "{} is a staff card", k.as_str());
|
||||
}
|
||||
for k in [
|
||||
ContentKind::Player,
|
||||
ContentKind::Consumable,
|
||||
ContentKind::Kit,
|
||||
ContentKind::Badge,
|
||||
ContentKind::Ball,
|
||||
ContentKind::Stadium,
|
||||
ContentKind::Misc,
|
||||
] {
|
||||
assert!(!k.is_staff_family(), "{} is not staff", k.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_consumable_family_needs_exactly_what_the_client_reads() {
|
||||
// Grouped from data/consumables.json's per-subtype `needs`, and observed
|
||||
// key-for-key in the real profile import.
|
||||
for f in [
|
||||
"gk_training",
|
||||
"player_training",
|
||||
"healing",
|
||||
"player_fitness",
|
||||
"squad_fitness",
|
||||
"player_playstyle",
|
||||
"gk_playstyle",
|
||||
"manager_league",
|
||||
] {
|
||||
assert_eq!(consumable_needs(f), ConsumableNeeds::Amount, "{f}");
|
||||
}
|
||||
for f in ["player_contract", "manager_contract"] {
|
||||
assert_eq!(consumable_needs(f), ConsumableNeeds::Contract, "{f}");
|
||||
}
|
||||
for f in ["manager_formation_mod", "formation_mod", "position_mod"] {
|
||||
assert_eq!(consumable_needs(f), ConsumableNeeds::None, "{f}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_route_categories_partition_the_reachable_families() {
|
||||
// The seven group codes, plus the singular `contract` spelling.
|
||||
let segments = [
|
||||
"training",
|
||||
"contracts",
|
||||
"fitness",
|
||||
"healing",
|
||||
"position",
|
||||
"playstyle",
|
||||
"managerleaguemodifier",
|
||||
];
|
||||
let mut seen: Vec<&str> = Vec::new();
|
||||
for seg in segments {
|
||||
for f in consumable_families_for_category(seg).unwrap() {
|
||||
assert!(!seen.contains(f), "{f} claimed by two categories");
|
||||
seen.push(f);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
consumable_families_for_category("contract"),
|
||||
consumable_families_for_category("contracts"),
|
||||
"both spellings the client has used mean the same set"
|
||||
);
|
||||
// Eleven of the thirteen families are reachable; the two formation
|
||||
// modifiers have no group code in the client's own table.
|
||||
assert_eq!(seen.len(), 11, "no duplicates: {seen:?}");
|
||||
for subtype in [51, 61, 91, 201, 202, 211, 219, 220, 250, 269, 300] {
|
||||
let (family, _) = consumable_family(subtype).unwrap();
|
||||
assert!(seen.contains(&family), "no category serves {family}");
|
||||
}
|
||||
for unreachable in [71, 121] {
|
||||
let (family, _) = consumable_family(unreachable).unwrap();
|
||||
assert!(
|
||||
!seen.contains(&family),
|
||||
"{family} has no group code; claiming it would invent a segment"
|
||||
);
|
||||
}
|
||||
// Not a consumables segment (and NOT a `?type=` token either).
|
||||
for s in ["", "player", "kit", "Training", "development"] {
|
||||
assert!(
|
||||
consumable_families_for_category(s).is_none(),
|
||||
"{s:?} is not a consumable category"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn club_item_subtypes_are_the_settled_five() {
|
||||
assert_eq!(club_item_kind(KIT_SUBTYPE), Some(ContentKind::Kit));
|
||||
assert_eq!(club_item_kind(STADIUM_SUBTYPE), Some(ContentKind::Stadium));
|
||||
assert_eq!(club_item_kind(BADGE_SUBTYPE), Some(ContentKind::Badge));
|
||||
assert_eq!(club_item_kind(BALL_SUBTYPE), Some(ContentKind::Ball));
|
||||
assert_eq!((KIT_SUBTYPE, STADIUM_SUBTYPE, BADGE_SUBTYPE), (9, 10, 11));
|
||||
assert_eq!((BALL_SUBTYPE, LEAGUE_LOGO_SUBTYPE), (30, 31));
|
||||
// A league logo is not ownable Core content, so it maps to no kind.
|
||||
assert_eq!(club_item_kind(LEAGUE_LOGO_SUBTYPE), None);
|
||||
// Trophies (0x91..0x96) are NOT club items, and staff/consumable
|
||||
// subtypes must never be mistaken for one.
|
||||
for s in [0, 4, 8, 0x91, 0x96, 201, 231] {
|
||||
assert_eq!(club_item_kind(s), None, "subtype {s} is not a club item");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_groups_follow_the_clients_own_ladder() {
|
||||
// Ids are the client's `pos` table; groups are its 0/1..8/9..19/20..27
|
||||
// recompute. GK folded into DEF is the one named guess.
|
||||
for p in ["GK", "CB", "LB", "RB", "LWB", "RWB"] {
|
||||
assert_eq!(position_group(p), Some(PositionGroup::Defender), "{p}");
|
||||
}
|
||||
for p in ["CDM", "CM", "CAM", "LM", "RM"] {
|
||||
assert_eq!(position_group(p), Some(PositionGroup::Midfielder), "{p}");
|
||||
}
|
||||
for p in ["RF", "CF", "LF", "RW", "ST", "LW"] {
|
||||
assert_eq!(position_group(p), Some(PositionGroup::Forward), "{p}");
|
||||
}
|
||||
assert_eq!(position_id("ST"), Some(25));
|
||||
assert_eq!(position_id("CDM"), Some(10));
|
||||
// Not in the client's table → no tab, never an invented one.
|
||||
for p in ["", "SW", "st", "MID", "SUB"] {
|
||||
assert_eq!(position_group(p), None, "{p:?}");
|
||||
assert_eq!(position_id(p), None, "{p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_family_range_boundaries() {
|
||||
// Each contiguous range: lower boundary, upper boundary, family + label.
|
||||
|
||||
@@ -2,52 +2,13 @@
|
||||
//!
|
||||
//! These translate FIFA 17 wire semantics into the generic amounts the host
|
||||
//! feeds to Core economy authority. They own NO state — Core owns balances and
|
||||
//! inventory; these are the FIFA-specific numbers/derivations. Values are the
|
||||
//! current OpenFUT economy (match rewards are the Python oracle's
|
||||
//! `MATCH_COINS`/`MATCH_PARTICIPATION` at production defaults); pack prices come
|
||||
//! from the Store catalogue.
|
||||
//! inventory; these are the FIFA-specific numbers/derivations. Pack prices come
|
||||
//! from the Store catalogue; the transfer-market fee is the FUT-era 5%.
|
||||
//!
|
||||
//! Match result mapping + reward-body shaping live in [`crate::fut::match_wire`].
|
||||
|
||||
use crate::fut::store_catalog::pack_by_id;
|
||||
|
||||
/// Normalized match outcome for reward purposes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MatchResult {
|
||||
Win,
|
||||
Draw,
|
||||
Loss,
|
||||
}
|
||||
|
||||
/// Participation award added to every match reward (oracle `MATCH_PARTICIPATION`
|
||||
/// default = 0).
|
||||
pub const MATCH_PARTICIPATION: i64 = 0;
|
||||
|
||||
/// Per-result match coins (oracle `MATCH_COINS`: won 400 / draw 200 / loss 100).
|
||||
pub fn match_result_coins(result: MatchResult) -> i64 {
|
||||
match result {
|
||||
MatchResult::Win => 400,
|
||||
MatchResult::Draw => 200,
|
||||
MatchResult::Loss => 100,
|
||||
}
|
||||
}
|
||||
|
||||
/// Total match reward = per-result coins + participation.
|
||||
pub fn match_reward_total(result: MatchResult) -> i64 {
|
||||
match_result_coins(result) + MATCH_PARTICIPATION
|
||||
}
|
||||
|
||||
/// Derive the outcome from the match `endReason` enum (the oracle's primary
|
||||
/// signal, `_END_REASON`). Unknown/absent reasons default to `Draw`, matching
|
||||
/// the oracle's conservative default. Score-based derivation is a fallback the
|
||||
/// oracle also supports; the enum is authoritative when present.
|
||||
pub fn result_from_end_reason(end_reason: Option<&str>) -> MatchResult {
|
||||
match end_reason.unwrap_or("").to_ascii_uppercase().as_str() {
|
||||
"WIN" | "DNF_WIN" => MatchResult::Win,
|
||||
"LOSS" | "QUIT" | "DNF" | "DNF_LOSS" => MatchResult::Loss,
|
||||
// "DRAW", "DNF_DRAW", "NO_CONTEST", unknown -> draw.
|
||||
_ => MatchResult::Draw,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Store buy-now price for a pack id (`None` for unknown/owned-only packs,
|
||||
/// which are never purchasable).
|
||||
pub fn pack_price(pack_id: u64) -> Option<u64> {
|
||||
@@ -100,24 +61,6 @@ pub fn seller_proceeds(gross: i64) -> i64 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn match_rewards_match_oracle() {
|
||||
assert_eq!(match_reward_total(MatchResult::Win), 400);
|
||||
assert_eq!(match_reward_total(MatchResult::Draw), 200);
|
||||
assert_eq!(match_reward_total(MatchResult::Loss), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_reason_maps_to_outcome() {
|
||||
assert_eq!(result_from_end_reason(Some("WIN")), MatchResult::Win);
|
||||
assert_eq!(result_from_end_reason(Some("dnf_win")), MatchResult::Win);
|
||||
assert_eq!(result_from_end_reason(Some("LOSS")), MatchResult::Loss);
|
||||
assert_eq!(result_from_end_reason(Some("QUIT")), MatchResult::Loss);
|
||||
assert_eq!(result_from_end_reason(Some("DRAW")), MatchResult::Draw);
|
||||
assert_eq!(result_from_end_reason(None), MatchResult::Draw);
|
||||
assert_eq!(result_from_end_reason(Some("weird")), MatchResult::Draw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_price_rejects_unknown_and_owned_only() {
|
||||
assert!(pack_price(1).is_some());
|
||||
|
||||
@@ -24,8 +24,11 @@
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::content_taxonomy::ContentKind;
|
||||
use crate::fut::content_taxonomy::{
|
||||
consumable_family, consumable_needs, ConsumableNeeds, ContentKind, MANAGER_SUBTYPE,
|
||||
};
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
use crate::fut::item_state;
|
||||
|
||||
/// One owned item in game-independent terms, as read from Core's inventory.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -66,12 +69,153 @@ pub struct Fifa17Identity {
|
||||
pub rareflag: i64,
|
||||
}
|
||||
|
||||
/// FIFA-side identity fields needed to render an owned club kit. Unlike player
|
||||
/// items, kit art and source-team metadata come from `fcc_kitcards`, not Core.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Fifa17KitIdentity {
|
||||
pub item_id: u32,
|
||||
pub asset_id: u32,
|
||||
pub resource_id: u32,
|
||||
pub card_asset_id: u32,
|
||||
pub subtype: i64,
|
||||
pub team_id: i64,
|
||||
}
|
||||
|
||||
/// FIFA-side identity fields needed to render an owned staff card (manager or
|
||||
/// coach). Unlike a player, a staff record carries NO attributes, rating,
|
||||
/// position or rareflag: the client merges all of those from its own
|
||||
/// `managercards`/`*coachcards` tables keyed on `resource_id`.
|
||||
///
|
||||
/// `nation`/`league_id`/`team_id` are meaningful for a MANAGER only
|
||||
/// (`subtype == MANAGER_SUBTYPE`) and are zero for the four coach families,
|
||||
/// whose tables carry no nation/league/team column.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Fifa17StaffIdentity {
|
||||
pub item_id: u32,
|
||||
/// THE merge key, read RAW as a u32 by `FUN_1801356c0` with NO `& 0xffffff`
|
||||
/// mask (players are the only family that is masked). It must equal the
|
||||
/// table `carddbid` exactly — a non-zero version byte silently breaks the
|
||||
/// lookup, and the manager branch has no else-arm to report the miss.
|
||||
pub resource_id: u32,
|
||||
/// `cardsubtypeid`. This ALONE selects which staff table the client merges.
|
||||
pub subtype: i64,
|
||||
pub nation: i64,
|
||||
pub league_id: i64,
|
||||
pub team_id: i64,
|
||||
}
|
||||
|
||||
/// FIFA-side identity + definition facts needed to render an owned consumable.
|
||||
///
|
||||
/// A consumable carries NO id space to discover: `FUN_18013f4d0` never touches a
|
||||
/// DB handle, and category, artwork, name and both stat bytes all derive from
|
||||
/// `cardsubtypeid` alone. What it does need is the fcc_* row's ART id and the one
|
||||
/// extra key its family reads — see [`Fifa17ConsumableIdentity::is_renderable`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Fifa17ConsumableIdentity {
|
||||
pub item_id: u32,
|
||||
/// `rec+0x18`. Bookkeeping only for a consumable (artwork is a client-side
|
||||
/// constant, so this never reaches the screen), but kept as EA's own
|
||||
/// `carddbid` so nothing drifts out of their space.
|
||||
pub resource_id: u32,
|
||||
pub asset_id: u32,
|
||||
/// The fcc_* `cardassetid` — the ART id, NOT a copy of `resource_id`.
|
||||
/// Observed values in the real profile: 3 (training), 7/8 (contracts),
|
||||
/// 9 (healing), 34 (position), 50/51 (play style). Copying `resource_id`
|
||||
/// here is right for players and wrong for every other family: the client
|
||||
/// looks up art `5003001`, finds none, and draws the `notfound.swf` green
|
||||
/// "NOT FOUND" box.
|
||||
pub card_asset_id: u32,
|
||||
/// `rec+0x50`. THE ONLY selector: category, artwork, name and both stat
|
||||
/// bytes derive from it.
|
||||
pub subtype: i64,
|
||||
/// `rec+0x58`. Observed 0 on every owned consumable in the real profile.
|
||||
pub rareflag: i64,
|
||||
/// `rec+0xb4`. Drives the card level (`rec+0x54`) and therefore the
|
||||
/// `fcc_discardcoins` price. Definition-level EA data (55..95 observed).
|
||||
pub rating: u8,
|
||||
/// `amount` (atom 0x1b) → `rec+0xbf`, or `+0xbe` for a play style.
|
||||
/// `Some` exactly for the families [`ConsumableNeeds::Amount`] names.
|
||||
pub amount: Option<i64>,
|
||||
/// `contract` (atom 0xb8) → `rec+0x8c`. `Some` for the two contract
|
||||
/// families only; they ignore `amount` entirely.
|
||||
pub contract: Option<i64>,
|
||||
/// `rec+0x49`. Per-INSTANCE in FIFA, unmodelled by Core, so the host passes
|
||||
/// the observed constant [`CONSUMABLE_UNTRADEABLE`]. Carried per copy rather
|
||||
/// than baked into the shaper because the consumables route's stack wrapper
|
||||
/// reports `untradeableCount` over the copies in the stack.
|
||||
pub untradeable: bool,
|
||||
}
|
||||
|
||||
impl Fifa17ConsumableIdentity {
|
||||
/// Whether this definition can be drawn HONESTLY. Three refusals, every one a
|
||||
/// silent-failure guard rather than taste:
|
||||
///
|
||||
/// * the family's mandatory extra key is missing — the parser initialises
|
||||
/// its `amount` temp to `-1` and both accessors read the byte SIGNED, so
|
||||
/// an omission draws "-1" on the card, not "0" (and a contract card with
|
||||
/// no `contract` grants nothing);
|
||||
/// * `rareflag != 0` on subtype 219 — `FUN_1801bfac0` case 5 renders a RARE
|
||||
/// Player Fitness card as a SQUAD Fitness card, i.e. a different item
|
||||
/// entirely, with no error anywhere;
|
||||
/// * `card_asset_id == asset_id` — a consumable's art id is a SMALL `fcc_`
|
||||
/// art id (3, 7, 8, 9, 34, 50, 51 observed) and never its own `carddbid`,
|
||||
/// so this means the catalog carried no `card_asset_id` and the client
|
||||
/// would draw `notfound.swf`, the green "NOT FOUND" box.
|
||||
///
|
||||
/// A subtype outside every documented range is also refused: it falls to
|
||||
/// `FUN_18013f4d0`'s bottom default and renders as a perfectly ordinary
|
||||
/// Squad Training (Pace) card with amount 0 — plausible and wrong.
|
||||
pub fn is_renderable(&self) -> bool {
|
||||
if self.subtype == SQUAD_FITNESS_TRAP_SUBTYPE && self.rareflag != 0 {
|
||||
return false;
|
||||
}
|
||||
if self.card_asset_id == self.asset_id {
|
||||
return false;
|
||||
}
|
||||
match consumable_family(self.subtype) {
|
||||
None => false,
|
||||
Some((family, _)) => match consumable_needs(family) {
|
||||
ConsumableNeeds::Amount => self.amount.is_some(),
|
||||
ConsumableNeeds::Contract => self.contract.is_some(),
|
||||
ConsumableNeeds::None => true,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Supplies the FIFA numeric identity for a Core item. Returning `None` means
|
||||
/// "no real FIFA asset id known" → the caller must not fabricate one.
|
||||
pub trait ItemIdentityResolver {
|
||||
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity>;
|
||||
|
||||
/// Classify a Core item's definition as player/consumable/staff. Defaults to
|
||||
/// Resolve one owned kit definition. Default `None` preserves existing
|
||||
/// player-only resolvers; the catalog-backed FIFA17 resolver overrides it.
|
||||
fn resolve_kit(&self, _item: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve one owned staff definition (manager or coach). Default `None`
|
||||
/// preserves existing resolvers; the catalog-backed FIFA17 resolver
|
||||
/// overrides it.
|
||||
fn resolve_staff(&self, _item: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve one owned consumable definition. Default `None` preserves
|
||||
/// existing resolvers; the catalog-backed FIFA17 resolver overrides it.
|
||||
fn resolve_consumable(&self, _item: &CoreOwnedItem) -> Option<Fifa17ConsumableIdentity> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The FIFA `cardsubtypeid` of a Core item's definition, or `0` when unknown
|
||||
/// or a player. NON-MINTING by contract: `/club`'s per-family filters call it
|
||||
/// for every owned row, so allocating a wire id here would pollute the
|
||||
/// identity store on a read.
|
||||
fn subtype_of(&self, _item: &CoreOwnedItem) -> i64 {
|
||||
0
|
||||
}
|
||||
|
||||
/// Classify a Core item's definition into the content vocabulary. Defaults to
|
||||
/// [`ContentKind::Player`] so existing resolvers keep their behaviour; a
|
||||
/// catalog-backed resolver overrides this to consult its `kind_of`, letting
|
||||
/// `/club` exclude non-player content (which must never render as a
|
||||
@@ -85,9 +229,18 @@ pub trait ItemIdentityResolver {
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ShapeStats {
|
||||
pub emitted: usize,
|
||||
/// No real FIFA asset id for this definition — dropped, never faked.
|
||||
pub dropped_no_asset: usize,
|
||||
/// Consumable/staff items excluded from a player projection (they must never
|
||||
/// render as a 0-rated player). Counted, never emitted.
|
||||
/// The definition resolved but is INCOMPLETE or self-contradictory, so
|
||||
/// drawing it would be a lie the client cannot detect (a consumable missing
|
||||
/// the mandatory `amount`/`contract`, or the subtype-219 rareflag trap).
|
||||
/// Dropped and counted separately, because the fix is a catalog re-emit, not
|
||||
/// an identity mapping.
|
||||
pub dropped_incomplete: usize,
|
||||
/// Owned content this envelope deliberately does not carry: a CONSUMABLE
|
||||
/// (its own route serves it as a stack), or a club-customisation family
|
||||
/// whose record shape is not yet verified (badge, ball, stadium, misc).
|
||||
/// Core owns the row; the projection is withheld, never guessed.
|
||||
pub excluded_non_player: usize,
|
||||
}
|
||||
|
||||
@@ -144,7 +297,7 @@ pub fn shape_item(
|
||||
"leagueId": league_id,
|
||||
"playStyle": 250,
|
||||
"attributeList": attribute_list,
|
||||
"itemState": "free",
|
||||
"itemState": item_state::FREE,
|
||||
"owners": 1,
|
||||
// Owned/pack-pulled cards are TRADEABLE in FIFA 17 (untradeable is the
|
||||
// exception for SBC/promo rewards, which Core does not model). Emitting
|
||||
@@ -158,6 +311,166 @@ pub fn shape_item(
|
||||
})
|
||||
}
|
||||
|
||||
/// Build one FIFA 17 club-kit item. `item_state` is the proven wire enum token:
|
||||
/// `free`, `activeHomeKit`, or `activeAwayKit`; the client deserializes the
|
||||
/// latter two to runtime values 101 and 102.
|
||||
pub fn shape_kit_item(id: Fifa17KitIdentity, item_state: &str) -> Value {
|
||||
json!({
|
||||
"id": id.item_id,
|
||||
"resourceId": id.resource_id,
|
||||
"assetId": id.asset_id,
|
||||
"cardassetid": id.card_asset_id,
|
||||
"cardsubtypeid": id.subtype,
|
||||
"itemState": item_state,
|
||||
"owners": 1,
|
||||
"untradeable": false,
|
||||
"teamid": id.team_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Contracts remaining on an owned staff card.
|
||||
///
|
||||
/// Staff consume contracts exactly as players do (`rec+0x8c`), and the client
|
||||
/// refuses to start a match when the manager's has run out. Core does not model
|
||||
/// staff contracts, so this mirrors the constant [`shape_item`] already emits
|
||||
/// for players rather than inventing a second, different default.
|
||||
pub const STAFF_CONTRACT: i64 = 7;
|
||||
|
||||
/// Build one FIFA 17 staff item (manager or coach).
|
||||
///
|
||||
/// The key set is deliberately minimal and is taken field-by-field from the
|
||||
/// instruction-level reversal in `fifa17-recon/tools/fut_staff.py`, where every
|
||||
/// key is justified by its CardsDLL record offset:
|
||||
///
|
||||
/// * `id` → `rec+0x08`, `resourceId` → `rec+0x18` (the RAW merge key),
|
||||
/// `cardsubtypeid` → `rec+0x50` (alone selects which staff table is merged),
|
||||
/// `contract` → `rec+0x8c`, `itemState` → `rec+0x5c`, `owners` → `rec+0x48`,
|
||||
/// `untradeable` → `rec+0x49`.
|
||||
/// * `nation` → `rec+0xde` and `leagueId` → `rec+0xe0` are MANAGER-ONLY record
|
||||
/// slots the client's merge never writes, so the server is their only source;
|
||||
/// they drive the manager's flag, league badge and both halves of manager
|
||||
/// chemistry. `teamid` → `rec+0x94` is read by the card view-model.
|
||||
///
|
||||
/// Everything else is omitted on purpose, because each is either overwritten by
|
||||
/// the merge from the client's own table (`assetId`/`cardassetid` at `rec+0x20`,
|
||||
/// `rating` at `rec+0xb4`, `rareflag` at `rec+0x58`), skipped by the parser
|
||||
/// (`definitionId`), or — worse — SURVIVES the merge and is then read by the
|
||||
/// view-model, which would hang a position label or an attribute row on a
|
||||
/// manager (`preferredPosition` at `rec+0x146`, `attributeList` at `rec+0x98`).
|
||||
/// A staff card must therefore never be routed through [`shape_item`].
|
||||
///
|
||||
/// The four coach families carry no nation/league/team columns in the client's
|
||||
/// tables, so those three keys are emitted for a manager only rather than being
|
||||
/// invented as zeroes for a coach.
|
||||
pub fn shape_staff_item(id: Fifa17StaffIdentity, contract: i64) -> Value {
|
||||
let mut item = json!({
|
||||
"id": id.item_id,
|
||||
"resourceId": id.resource_id,
|
||||
"cardsubtypeid": id.subtype,
|
||||
// Inert on the wire (the parser reads atom 0x173 into a stack string and
|
||||
// frees it), but it is what every staff family reports, and our own
|
||||
// readers use it to tell a staff card from a footballer at a glance.
|
||||
"itemType": "staff",
|
||||
"contract": contract,
|
||||
"itemState": item_state::FREE,
|
||||
"owners": 1,
|
||||
"untradeable": false,
|
||||
});
|
||||
if id.subtype == MANAGER_SUBTYPE {
|
||||
let obj = item.as_object_mut().expect("json! built an object");
|
||||
obj.insert("nation".to_string(), json!(id.nation));
|
||||
obj.insert("leagueId".to_string(), json!(id.league_id));
|
||||
obj.insert("teamid".to_string(), json!(id.team_id));
|
||||
}
|
||||
item
|
||||
}
|
||||
|
||||
/// Build one FIFA 17 consumable item.
|
||||
///
|
||||
/// The key set is EXACTLY what the real profile import holds for its 17 owned
|
||||
/// consumables — i.e. what the client itself stored — and every key is a key the
|
||||
/// live player path already proves, so this introduces NO new wire shape:
|
||||
///
|
||||
/// * `id` → `rec+0x08`, `resourceId` → `rec+0x18`, `assetId`, `cardassetid` (the
|
||||
/// ART id, see [`Fifa17ConsumableIdentity::card_asset_id`]),
|
||||
/// `cardsubtypeid` → `rec+0x50`, `rareflag` → `rec+0x58`,
|
||||
/// `rating` → `rec+0xb4`, `itemState` → `rec+0x5c`, `owners` → `rec+0x48`,
|
||||
/// `untradeable` → `rec+0x49`.
|
||||
/// * `amount` → `rec+0xbf` / `+0xbe` and `contract` → `rec+0x8c`, each emitted
|
||||
/// only for the families that read it (the caller has already gated on
|
||||
/// [`Fifa17ConsumableIdentity::is_renderable`]).
|
||||
///
|
||||
/// `itemType` is `"player"`, which is not a mislabel: it is the ONLY value this
|
||||
/// client has ever been sent, it is what the real profile stores on all 17, and
|
||||
/// `cardtype` is derived from `cardsubtypeid` alone (`FUN_18013fe00`), so the
|
||||
/// string cannot affect the render. A consumable is discriminated by its subtype
|
||||
/// plus the ABSENCE of `attributeList`; inventing `"consumable"` here would be a
|
||||
/// fabricated token.
|
||||
///
|
||||
/// `untradeable` is carried per copy from
|
||||
/// [`Fifa17ConsumableIdentity::untradeable`] (the host supplies the observed
|
||||
/// [`CONSUMABLE_UNTRADEABLE`]), because the consumables route reports
|
||||
/// `untradeableCount` over a stack and the two must agree.
|
||||
///
|
||||
/// DELIBERATELY ABSENT, each for a named reason:
|
||||
/// * `teamid`, `leagueid` and `value` — the three "extras" copied out of an fcc
|
||||
/// row that CRASHED the client on 2026-08-05. `value` is the established
|
||||
/// culprit (it is an OBJECT member elsewhere, and a scalar where an object is
|
||||
/// expected is the type-desync busy loop at `0x1801c7f1a`); none of the three
|
||||
/// is needed to draw a card.
|
||||
/// * `preferredPosition`, `nation`, `playStyle`, `attributeList`, `fitness` —
|
||||
/// player-only, and `attributeList` is the very thing that distinguishes a
|
||||
/// footballer from a consumable.
|
||||
/// * `definitionId` — not an atom at all; the parser has always skipped it.
|
||||
/// * `discardValue` — the client computes it from `fcc_discardcoins` on
|
||||
/// `(cardtype 6, level, rare)`, and real rows exist for both rare values.
|
||||
/// * `pile` — Core/host state (the transfer pile), not a wire atom: the
|
||||
/// live-proven player path does not send it either.
|
||||
pub fn shape_consumable_item(id: Fifa17ConsumableIdentity) -> Value {
|
||||
let mut item = json!({
|
||||
"id": id.item_id,
|
||||
"resourceId": id.resource_id,
|
||||
"assetId": id.asset_id,
|
||||
"cardassetid": id.card_asset_id,
|
||||
"cardsubtypeid": id.subtype,
|
||||
"itemType": "player",
|
||||
"rareflag": id.rareflag,
|
||||
"rating": id.rating,
|
||||
"itemState": item_state::FREE,
|
||||
"owners": 1,
|
||||
"untradeable": id.untradeable,
|
||||
});
|
||||
let obj = item.as_object_mut().expect("json! built an object");
|
||||
if let Some(amount) = id.amount {
|
||||
obj.insert("amount".to_string(), json!(amount));
|
||||
}
|
||||
if let Some(contract) = id.contract {
|
||||
obj.insert("contract".to_string(), json!(contract));
|
||||
}
|
||||
item
|
||||
}
|
||||
|
||||
/// `cardsubtypeid` of the PLAYER FITNESS card, and the one subtype where
|
||||
/// `rareflag` is load-bearing rather than cosmetic: `FUN_1801bfac0` case 5 reads
|
||||
/// it as the squad-fitness selector, so a rare Player Fitness card silently
|
||||
/// becomes a SQUAD Fitness card — a different item, with no error anywhere.
|
||||
pub const SQUAD_FITNESS_TRAP_SUBTYPE: i64 = 219;
|
||||
|
||||
/// Tradeability of an owned consumable.
|
||||
///
|
||||
/// FIFA models this per INSTANCE (`rec+0x49`) and Core does not model it at all,
|
||||
/// so this is the observed value, not a policy: all 17 owned consumables in the
|
||||
/// real profile import carry `untradeable: true`, and it is also the oracle's own
|
||||
/// default for the family. When Core models per-instance tradeability, this
|
||||
/// constant is what it replaces.
|
||||
///
|
||||
/// Note the lever it controls on screen: the consumables deserializer sets a UI
|
||||
/// flag from `untradeableCount < count`, so an all-untradeable stack draws the
|
||||
/// untradeable badge. That is correct for genuinely untradeable copies; it was
|
||||
/// only wrong for the oracle's SYNTHETIC shelf, where the badge was its own data
|
||||
/// showing through.
|
||||
pub const CONSUMABLE_UNTRADEABLE: bool = true;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -281,4 +594,144 @@ mod tests {
|
||||
"special rareflag carried, not hardcoded 1"
|
||||
);
|
||||
}
|
||||
|
||||
/// The GK-training card the real profile owns: `5003012`, art 3, subtype 54,
|
||||
/// rating 85, amount 15. Its key set is the acceptance criterion.
|
||||
fn training_consumable() -> Fifa17ConsumableIdentity {
|
||||
Fifa17ConsumableIdentity {
|
||||
item_id: 100000239,
|
||||
resource_id: 5_003_012,
|
||||
asset_id: 5_003_012,
|
||||
card_asset_id: 3,
|
||||
subtype: 54,
|
||||
rareflag: 0,
|
||||
rating: 85,
|
||||
amount: Some(15),
|
||||
contract: None,
|
||||
untradeable: CONSUMABLE_UNTRADEABLE,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_emits_exactly_the_keys_the_client_itself_stored() {
|
||||
let it = shape_consumable_item(training_consumable());
|
||||
// Verbatim from the real profile import (persona 33068179):
|
||||
// {"id":100000239,"resourceId":5003012,"assetId":5003012,"cardassetid":3,
|
||||
// "cardsubtypeid":54,"itemType":"player","rareflag":0,"rating":85,
|
||||
// "itemState":"free","owners":1,"untradeable":true,"amount":15}
|
||||
assert_eq!(
|
||||
it,
|
||||
json!({
|
||||
"id": 100000239,
|
||||
"resourceId": 5_003_012,
|
||||
"assetId": 5_003_012,
|
||||
"cardassetid": 3,
|
||||
"cardsubtypeid": 54,
|
||||
"itemType": "player",
|
||||
"rareflag": 0,
|
||||
"rating": 85,
|
||||
"itemState": "free",
|
||||
"owners": 1,
|
||||
"untradeable": true,
|
||||
"amount": 15,
|
||||
})
|
||||
);
|
||||
// The three "extras" that crashed the client on 2026-08-05, and the
|
||||
// player-only keys that would make a consumable look like a footballer.
|
||||
for forbidden in [
|
||||
"teamid",
|
||||
"leagueid",
|
||||
"leagueId",
|
||||
"value",
|
||||
"attributeList",
|
||||
"preferredPosition",
|
||||
"nation",
|
||||
"playStyle",
|
||||
"fitness",
|
||||
"definitionId",
|
||||
"discardValue",
|
||||
"pile",
|
||||
] {
|
||||
assert!(
|
||||
it.get(forbidden).is_none(),
|
||||
"a consumable must not carry `{forbidden}`"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_art_id_is_never_the_resource_id() {
|
||||
// The green "NOT FOUND" box: the client resolves artwork by cardassetid,
|
||||
// which is a SMALL fcc_ art id, not the carddbid.
|
||||
let it = shape_consumable_item(training_consumable());
|
||||
assert_eq!(it["cardassetid"], 3);
|
||||
assert_ne!(it["cardassetid"], it["resourceId"]);
|
||||
}
|
||||
|
||||
/// EVERY `itemState` this crate can put on the wire must be one of the twelve
|
||||
/// tokens recovered from the client's own table. An unrecovered token decodes
|
||||
/// to `0xffffffff` through `FUN_180166660` and the client then acts on an
|
||||
/// unrecognised state.
|
||||
#[test]
|
||||
fn every_emitted_item_state_is_in_the_recovered_table() {
|
||||
let ent = entities();
|
||||
let mut emitted: Vec<String> = Vec::new();
|
||||
let player = shape_item(
|
||||
&item("oc1", "card_ch_1", 86, "CDM"),
|
||||
Fifa17Identity {
|
||||
item_id: 1,
|
||||
asset_id: 20801,
|
||||
resource_id: 20801,
|
||||
rareflag: 1,
|
||||
},
|
||||
&ent,
|
||||
);
|
||||
emitted.push(player["itemState"].as_str().unwrap().to_string());
|
||||
let staff = shape_staff_item(
|
||||
Fifa17StaffIdentity {
|
||||
item_id: 2,
|
||||
resource_id: 1_000_509,
|
||||
subtype: MANAGER_SUBTYPE,
|
||||
nation: 45,
|
||||
league_id: 53,
|
||||
team_id: 241,
|
||||
},
|
||||
STAFF_CONTRACT,
|
||||
);
|
||||
emitted.push(staff["itemState"].as_str().unwrap().to_string());
|
||||
emitted.push(
|
||||
shape_consumable_item(training_consumable())["itemState"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
);
|
||||
// Every state `/club` can hand a kit, including both equipped roles.
|
||||
let kit = Fifa17KitIdentity {
|
||||
item_id: 3,
|
||||
asset_id: 6_300_006,
|
||||
resource_id: 6_300_006,
|
||||
card_asset_id: 35,
|
||||
subtype: 9,
|
||||
team_id: 21,
|
||||
};
|
||||
for state in [
|
||||
item_state::FREE,
|
||||
item_state::ACTIVE_HOME_KIT,
|
||||
item_state::ACTIVE_AWAY_KIT,
|
||||
] {
|
||||
let it = shape_kit_item(kit, state);
|
||||
emitted.push(it["itemState"].as_str().unwrap().to_string());
|
||||
}
|
||||
for state in &emitted {
|
||||
assert!(
|
||||
item_state::is_recovered(state),
|
||||
"{state:?} is not one of the twelve recovered itemState tokens"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!emitted.iter().any(|s| s == item_state::INVALID),
|
||||
"omitting itemState yields `invalid` (0) and fails the squad builder; \
|
||||
no shaper may emit it deliberately either"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! The FIFA 17 **`itemState` vocabulary** — the complete recovered set, and the
|
||||
//! only place these strings are written down.
|
||||
//!
|
||||
//! Twelve entries in one NUL-terminated `{const char* name, u32 value}` table at
|
||||
//! `0x180229cc0` (stride 0x10), walked in full from both disk and live memory.
|
||||
//! `FUN_180166660` is a linear walk over that table and returns `0xffffffff` for
|
||||
//! anything not in it, so an invented token is not a cosmetic slip: it decodes to
|
||||
//! "unrecognised state" and the client acts on garbage. Every shaper in this
|
||||
//! crate therefore takes its `itemState` from a constant here, and
|
||||
//! [`is_recovered`] is asserted over every emitted value by the tests.
|
||||
//!
|
||||
//! **Omitting `itemState` is NOT the same as sending [`FREE`].** The record
|
||||
//! constructor zero-initialises `+0x50..+0x5f` from `_DAT_1801f66a0`, so an
|
||||
//! absent key leaves `0` = [`INVALID`], and an item left at `0` fails the squad
|
||||
//! builder's `state == 1 || state == 2` acceptance test. Always send it.
|
||||
//!
|
||||
//! (Source: `fifa17-recon/docs/plan-2026-08-06-card-subsystem.md` §4, which also
|
||||
//! corrects `CARD_SYSTEM.md`'s earlier ten-row reading — that one started at
|
||||
//! `0x180229d20`, the MIDDLE of the table, and so missed `invalid`, `free`,
|
||||
//! `WAITING_FOR_GAME`, `inGame`, `forSale` and `offered`.)
|
||||
|
||||
/// `0` — what an item gets when `itemState` is OMITTED. No consumer found; it
|
||||
/// fails the squad builder. Never emit it deliberately.
|
||||
pub const INVALID: &str = "invalid";
|
||||
/// `1` — the normal owned state: accepted by the squad builder, and what the
|
||||
/// unequip path writes back.
|
||||
pub const FREE: &str = "free";
|
||||
/// `2` — alias of [`IN_GAME`] (both decode to 2).
|
||||
pub const WAITING_FOR_GAME: &str = "WAITING_FOR_GAME";
|
||||
/// `2` — accepted by the squad builder.
|
||||
pub const IN_GAME: &str = "inGame";
|
||||
/// `5` — an item offered for sale. Never TESTED anywhere in CardsDLL, but it is
|
||||
/// in the table, so it decodes; the transfer market emits it.
|
||||
pub const FOR_SALE: &str = "forSale";
|
||||
/// `6` — never tested anywhere in CardsDLL.
|
||||
pub const OFFERED: &str = "offered";
|
||||
/// `100` — equipped badge; drives the `IS_ACTIVE` tick.
|
||||
pub const ACTIVE_BADGE: &str = "activeBadge";
|
||||
/// `101` — equipped home kit.
|
||||
pub const ACTIVE_HOME_KIT: &str = "activeHomeKit";
|
||||
/// `102` — equipped away kit.
|
||||
pub const ACTIVE_AWAY_KIT: &str = "activeAwayKit";
|
||||
/// `103` — equipped ball; the unequip path writes [`FREE`] back over it.
|
||||
pub const ACTIVE_BALL: &str = "activeBall";
|
||||
/// `104` — equipped stadium.
|
||||
pub const ACTIVE_STADIUM: &str = "activeStadium";
|
||||
/// `255` — no consumer found.
|
||||
pub const ACTIVE: &str = "active";
|
||||
|
||||
/// The complete recovered vocabulary, in table order.
|
||||
pub const ALL: [&str; 12] = [
|
||||
INVALID,
|
||||
FREE,
|
||||
WAITING_FOR_GAME,
|
||||
IN_GAME,
|
||||
FOR_SALE,
|
||||
OFFERED,
|
||||
ACTIVE_BADGE,
|
||||
ACTIVE_HOME_KIT,
|
||||
ACTIVE_AWAY_KIT,
|
||||
ACTIVE_BALL,
|
||||
ACTIVE_STADIUM,
|
||||
ACTIVE,
|
||||
];
|
||||
|
||||
/// Whether `state` is one of the twelve recovered tokens. Case-sensitive, as the
|
||||
/// client's own lookup is a `strcmp` walk.
|
||||
pub fn is_recovered(state: &str) -> bool {
|
||||
ALL.contains(&state)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_table_is_the_twelve_recovered_rows_and_nothing_else() {
|
||||
assert_eq!(ALL.len(), 12);
|
||||
for s in ALL {
|
||||
assert!(is_recovered(s), "{s} must be in its own table");
|
||||
}
|
||||
// Tokens this project has actually seen invented or mis-cased. `listFS`
|
||||
// in particular is the Python oracle's own token and appears NOWHERE in
|
||||
// the client (zero occurrences in the DLL and in 4.26 GiB of live
|
||||
// process memory), so it decodes to -1.
|
||||
for s in [
|
||||
"listFS",
|
||||
"free ",
|
||||
"Free",
|
||||
"activehomekit",
|
||||
"sold",
|
||||
"won",
|
||||
"equipped",
|
||||
"",
|
||||
] {
|
||||
assert!(!is_recovered(s), "{s:?} is not a FIFA 17 itemState");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! FIFA 17 `/match` + `/match/end` wire ↔ Core match-economy mapping.
|
||||
//!
|
||||
//! This module owns the FIFA 17-specific match protocol: the `endReason` enum
|
||||
//! (wire atom 260), the `PUT …/match/end` payload shape, and the reward-response
|
||||
//! body. It is pure — no state, no Core calls. The host wires it to OpenFUT
|
||||
//! Core's authoritative `complete_match` transaction:
|
||||
//!
|
||||
//! 1. [`parse_match_end`] turns the client payload into a [`MatchEnd`].
|
||||
//! 2. [`MatchResult::core_token`] gives Core the canonical, game-independent
|
||||
//! result string — Core never sees a FIFA `endReason`.
|
||||
//! 3. Core applies the economy exactly once and returns the authoritative coin
|
||||
//! numbers, which the host renders back through [`reward_response`].
|
||||
//!
|
||||
//! Keeping every FIFA 17 constant here (never in Core) is the layering contract:
|
||||
//! a second title's adapter maps its own wire onto the same canonical tokens.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Participation award added to every match reward. FIFA 17 economy parameter
|
||||
/// (oracle `MATCH_PARTICIPATION`, production default `0`). Core owns the coin
|
||||
/// balance; this is only the wire body's cosmetic `participationAward` field.
|
||||
pub const MATCH_PARTICIPATION: i64 = 0;
|
||||
|
||||
/// Canonical match result. The FIFA 17 `endReason` enum (atom 260) is the
|
||||
/// AUTHORITATIVE source [STATIC_REVERSED]; this is the normalized shape the host
|
||||
/// forwards to Core.
|
||||
///
|
||||
/// * `Win` / `Draw` / `Loss` — a decided match.
|
||||
/// * `Dnf` — the reporting player abandoned/quit (`DNF`/`QUIT`). Economically a
|
||||
/// loss (LIVE_PROVEN: `endReason=DNF` → loss reward), tracked in its own Core
|
||||
/// statistics bucket.
|
||||
/// * `NoContest` — a voided match; zero economic effect.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MatchResult {
|
||||
Win,
|
||||
Draw,
|
||||
Loss,
|
||||
Dnf,
|
||||
NoContest,
|
||||
}
|
||||
|
||||
impl MatchResult {
|
||||
/// The canonical Core result token — the ONLY match datum the adapter hands
|
||||
/// Core. Matches `openfut_core::models::match_result::MatchResultKind`'s serde
|
||||
/// representation exactly (`win`/`draw`/`loss`/`dnf`/`no_contest`).
|
||||
pub fn core_token(self) -> &'static str {
|
||||
match self {
|
||||
MatchResult::Win => "win",
|
||||
MatchResult::Draw => "draw",
|
||||
MatchResult::Loss => "loss",
|
||||
MatchResult::Dnf => "dnf",
|
||||
MatchResult::NoContest => "no_contest",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the FIFA 17 `endReason` enum onto a canonical [`MatchResult`].
|
||||
///
|
||||
/// The enum is authoritative when present [STATIC_REVERSED]. `DNF`/`QUIT` are the
|
||||
/// reporting player's abandon (→ `Dnf`, loss economics, LIVE_PROVEN); the
|
||||
/// `DNF_WIN`/`DNF_DRAW`/`DNF_LOSS` variants carry a decided outcome (the opponent
|
||||
/// abandoned) and map to that outcome. `NO_CONTEST` voids the match. An
|
||||
/// absent/unknown reason is a conservative `Draw`, matching the oracle default.
|
||||
pub fn result_from_end_reason(end_reason: Option<&str>) -> MatchResult {
|
||||
match end_reason.unwrap_or("").to_ascii_uppercase().as_str() {
|
||||
"WIN" => MatchResult::Win,
|
||||
"DRAW" => MatchResult::Draw,
|
||||
"LOSS" => MatchResult::Loss,
|
||||
"DNF" | "QUIT" => MatchResult::Dnf,
|
||||
"DNF_WIN" => MatchResult::Win,
|
||||
"DNF_DRAW" => MatchResult::Draw,
|
||||
"DNF_LOSS" => MatchResult::Loss,
|
||||
"NO_CONTEST" => MatchResult::NoContest,
|
||||
_ => MatchResult::Draw,
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed `PUT …/match/end` payload: the fields the host needs to drive Core.
|
||||
/// Unknown fields are ignored.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MatchEnd {
|
||||
/// The raw `endReason` string, if the client sent one.
|
||||
pub end_reason: Option<String>,
|
||||
/// Canonical result derived from `end_reason`.
|
||||
pub result: MatchResult,
|
||||
/// `matchReportId` from the payload (observed `0` on the live path).
|
||||
pub match_report_id: i64,
|
||||
/// Goals scored by the reporting player — `myMatchStats[0]` (goals is the
|
||||
/// first of the 15 ints). Absent on `DNF`/`QUIT` (stats omitted) → `0`.
|
||||
pub goals_for: i64,
|
||||
/// Opponent goals — `opponentMatchStats[0]`. Absent on `DNF`/`QUIT` → `0`.
|
||||
pub goals_against: i64,
|
||||
}
|
||||
|
||||
/// Parse the FIFA 17 match-end body. Returns `None` for a body that is not a
|
||||
/// JSON object (malformed). A well-formed object with a missing/unknown
|
||||
/// `endReason` still parses — the result defaults to `Draw`.
|
||||
pub fn parse_match_end(body: &[u8]) -> Option<MatchEnd> {
|
||||
let v: Value = serde_json::from_slice(body).ok()?;
|
||||
if !v.is_object() {
|
||||
return None;
|
||||
}
|
||||
let end_reason = v
|
||||
.get("endReason")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
let result = result_from_end_reason(end_reason.as_deref());
|
||||
let match_report_id = v.get("matchReportId").and_then(Value::as_i64).unwrap_or(0);
|
||||
Some(MatchEnd {
|
||||
end_reason,
|
||||
result,
|
||||
match_report_id,
|
||||
goals_for: first_stat(&v, "myMatchStats"),
|
||||
goals_against: first_stat(&v, "opponentMatchStats"),
|
||||
})
|
||||
}
|
||||
|
||||
/// `myMatchStats`/`opponentMatchStats` are 15 ints with goals first
|
||||
/// [STATIC_REVERSED]; the arrays are OMITTED on DNF/QUIT, so a missing array is
|
||||
/// `0` goals, not an error.
|
||||
fn first_stat(v: &Value, key: &str) -> i64 {
|
||||
v.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|a| a.first())
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Build the FIFA 17 match-reward response body (the `destroy_match_body` shape,
|
||||
/// [STATIC_REVERSED]).
|
||||
///
|
||||
/// `all_coins` is Core's AUTHORITATIVE post-credit balance; `match_coins` is the
|
||||
/// amount Core granted for THIS match (mirrored into `gameModeAward.coins`, where
|
||||
/// the client reads it). Emits ONLY the reversed fields — it NEVER emits
|
||||
/// `bidTokens` or `qualifiedChampionEventId`, which are client freeze traps.
|
||||
pub fn reward_response(all_coins: i64, match_coins: i64) -> Value {
|
||||
json!({
|
||||
"allCoins": all_coins,
|
||||
"matchCoins": match_coins,
|
||||
"seasonCoins": 0,
|
||||
"tournamentCoins": 0,
|
||||
"boostConis": 0, // EA's misspelling (atom 96), preserved on the wire.
|
||||
"participationAward": MATCH_PARTICIPATION,
|
||||
"teamOfTournamentWinner": false,
|
||||
"gameModeAward": { "coins": match_coins },
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the FIFA 17 `POST …/match` create ack. Zero economic effect: it only
|
||||
/// hands the client a match id + start time. `id` doubles as the per-match
|
||||
/// identity the host later keys Core's exactly-once completion on.
|
||||
pub fn create_response(id: i64, start_epoch: i64) -> Value {
|
||||
json!({
|
||||
"startDateTime": start_epoch,
|
||||
"reportIdEnabled": false,
|
||||
"id": id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the FIFA 17 `…/match/ready` ack (FutMatchReady). Zero economic effect.
|
||||
///
|
||||
/// Two scalars only. The response type also has an optional nested item list,
|
||||
/// which is deliberately omitted: a nested value the client half-reads is the
|
||||
/// documented freeze mode, and nothing needs it here. `opponent_persona_id` is
|
||||
/// echoed from the request when the client supplies one and is otherwise `0` —
|
||||
/// an offline AI opponent has no persona, and it must NEVER default to the
|
||||
/// player's own persona, which would claim the user is their own opponent.
|
||||
pub fn ready_response(match_id: i64, opponent_persona_id: i64) -> Value {
|
||||
json!({
|
||||
"matchId": match_id,
|
||||
"opponentPersonaId": opponent_persona_id,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_end_reason_maps_to_canonical_result() {
|
||||
assert_eq!(result_from_end_reason(Some("WIN")), MatchResult::Win);
|
||||
assert_eq!(result_from_end_reason(Some("DRAW")), MatchResult::Draw);
|
||||
assert_eq!(result_from_end_reason(Some("LOSS")), MatchResult::Loss);
|
||||
assert_eq!(result_from_end_reason(Some("DNF")), MatchResult::Dnf);
|
||||
assert_eq!(result_from_end_reason(Some("QUIT")), MatchResult::Dnf);
|
||||
assert_eq!(
|
||||
result_from_end_reason(Some("NO_CONTEST")),
|
||||
MatchResult::NoContest
|
||||
);
|
||||
assert_eq!(result_from_end_reason(Some("DNF_WIN")), MatchResult::Win);
|
||||
assert_eq!(result_from_end_reason(Some("DNF_DRAW")), MatchResult::Draw);
|
||||
assert_eq!(result_from_end_reason(Some("DNF_LOSS")), MatchResult::Loss);
|
||||
// Case-insensitive.
|
||||
assert_eq!(result_from_end_reason(Some("dnf_win")), MatchResult::Win);
|
||||
// Unknown / absent → conservative draw.
|
||||
assert_eq!(result_from_end_reason(Some("weird")), MatchResult::Draw);
|
||||
assert_eq!(result_from_end_reason(None), MatchResult::Draw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_tokens_match_core_serde() {
|
||||
assert_eq!(MatchResult::Win.core_token(), "win");
|
||||
assert_eq!(MatchResult::Draw.core_token(), "draw");
|
||||
assert_eq!(MatchResult::Loss.core_token(), "loss");
|
||||
assert_eq!(MatchResult::Dnf.core_token(), "dnf");
|
||||
assert_eq!(MatchResult::NoContest.core_token(), "no_contest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_match_end_reads_reason_and_goals() {
|
||||
let body = br#"{"matchReportId":7,"endReason":"WIN","myMatchStats":[3,1,2,0,0,0,0,0,0,0,0,0,0,0,0],"opponentMatchStats":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}"#;
|
||||
let end = parse_match_end(body).expect("parses");
|
||||
assert_eq!(end.result, MatchResult::Win);
|
||||
assert_eq!(end.match_report_id, 7);
|
||||
assert_eq!(end.goals_for, 3);
|
||||
assert_eq!(end.goals_against, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_match_end_dnf_omits_stats_as_zero() {
|
||||
// The live DNF payload: stats arrays omitted entirely.
|
||||
let body = br#"{"matchReportId":0,"endReason":"DNF","items":[],"matchData":"","matchStatusFlags":0}"#;
|
||||
let end = parse_match_end(body).expect("parses");
|
||||
assert_eq!(end.result, MatchResult::Dnf);
|
||||
assert_eq!(end.goals_for, 0);
|
||||
assert_eq!(end.goals_against, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_match_end_unknown_reason_defaults_draw() {
|
||||
let end = parse_match_end(br#"{"endReason":"BANANA"}"#).expect("parses");
|
||||
assert_eq!(end.result, MatchResult::Draw);
|
||||
assert_eq!(end.end_reason.as_deref(), Some("BANANA"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_match_end_rejects_malformed() {
|
||||
assert!(parse_match_end(b"not json").is_none());
|
||||
assert!(parse_match_end(b"[]").is_none());
|
||||
assert!(parse_match_end(b"42").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reward_response_has_only_reversed_fields() {
|
||||
let body = reward_response(29_876_876, 100);
|
||||
assert_eq!(body["allCoins"], 29_876_876);
|
||||
assert_eq!(body["matchCoins"], 100);
|
||||
assert_eq!(body["gameModeAward"]["coins"], 100);
|
||||
assert_eq!(body["seasonCoins"], 0);
|
||||
assert_eq!(body["tournamentCoins"], 0);
|
||||
assert_eq!(body["boostConis"], 0);
|
||||
assert_eq!(body["participationAward"], 0);
|
||||
assert_eq!(body["teamOfTournamentWinner"], false);
|
||||
// The freeze traps must never appear.
|
||||
assert!(body.get("bidTokens").is_none());
|
||||
assert!(body.get("qualifiedChampionEventId").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_response_shape() {
|
||||
let body = create_response(100_004_838, 1_700_000_000);
|
||||
assert_eq!(body["id"], 100_004_838);
|
||||
assert_eq!(body["reportIdEnabled"], false);
|
||||
assert_eq!(body["startDateTime"], 1_700_000_000);
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,19 @@
|
||||
pub mod catalog;
|
||||
pub mod club_response;
|
||||
pub mod club_stats;
|
||||
pub mod consumables;
|
||||
pub mod content_taxonomy;
|
||||
pub mod economy;
|
||||
pub mod economy_policy;
|
||||
pub mod entities;
|
||||
pub mod item;
|
||||
pub mod item_state;
|
||||
pub mod match_wire;
|
||||
pub mod non_economy;
|
||||
pub mod owned_query;
|
||||
pub mod pack_content;
|
||||
pub mod sbc;
|
||||
pub mod season_wire;
|
||||
pub mod squad;
|
||||
pub mod squad_ext;
|
||||
pub mod squad_projection;
|
||||
|
||||
@@ -67,8 +67,15 @@ 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 {
|
||||
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])
|
||||
@@ -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.
|
||||
|
||||
@@ -48,6 +48,9 @@ use std::collections::HashMap;
|
||||
/// fields are FIFA entity ids that MUST be resolved before reaching Core.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct Fifa17ClubQuery {
|
||||
/// Requested FIFA item family (`player`, `kit`, …). Adapter-owned: Core has
|
||||
/// no FIFA content taxonomy, so the host applies this filter locally.
|
||||
pub item_type: Option<String>,
|
||||
/// Quality filter: `any` (default, always present) or `gold`.
|
||||
pub level: Option<String>,
|
||||
/// "Special" filter (`SP`). Semantics UNKNOWN — never applied.
|
||||
@@ -115,6 +118,7 @@ pub fn parse_club_query(query: &str) -> Fifa17ClubQuery {
|
||||
None => (pair, String::new()),
|
||||
};
|
||||
match k {
|
||||
"type" => out.item_type = Some(v),
|
||||
"level" => out.level = Some(v),
|
||||
"rare" => out.rare = Some(v),
|
||||
"position" => out.position = Some(v),
|
||||
@@ -325,6 +329,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
q,
|
||||
Fifa17ClubQuery {
|
||||
item_type: Some("player".into()),
|
||||
level: Some("gold".into()),
|
||||
rare: None,
|
||||
position: Some("ST".into()),
|
||||
|
||||
@@ -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,388 @@
|
||||
//! 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, std::slice::from_ref(&repeatable));
|
||||
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, std::slice::from_ref(&once));
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! FIFA 17 offline-Seasons wire shapes.
|
||||
//!
|
||||
//! Reversed from `CardsDLL_Win64_retail.dll`, not guessed. The `season/list`
|
||||
//! per-element parser is `FUN_180167740` (element stride 0x318) and the response
|
||||
//! deserialiser root is `FUN_1801683f0` (an object with the single key
|
||||
//! `seasons`). Element fields land at:
|
||||
//!
|
||||
//! | wire key | atom | element offset |
|
||||
//! |--------------|-------|--------------------------------|
|
||||
//! | `id` | 0x15c | +0x1b0 |
|
||||
//! | `divisionId` | 0x0dc | +0x1f8, as `(0xb - value)` |
|
||||
//! | `type` | — | +0x1b4 (int, switch) |
|
||||
//! | `matches` | 0x1b8 | vector at +0x2e8/+0x2f0/+0x2f8 |
|
||||
//!
|
||||
//! Each `matches` element is 16 bytes, parsed by `FUN_180167fb0`:
|
||||
//! `teamId`(0x305) int@+0x0, `difficulty`(0xd4) byte@+0x4, `roundId`(0x291)
|
||||
//! byte@+0x5, `rewardMult`(0x28b) int@+0x8, `coins`(0x95) int@+0xc.
|
||||
//!
|
||||
//! WHY `matches` MUST BE NON-EMPTY: `StartSeason` (`FUN_1800fc500`) reads
|
||||
//! `matches[*(x+0x70)].teamId` through `*(elem+0x2e8 + index*0x10)`. With an
|
||||
//! empty vector `elem+0x2e8` is NULL and the client dereferences address 0 —
|
||||
//! a hard crash at `CardsDLL+0xfc5b5`. Emitting a full round set is therefore a
|
||||
//! correctness requirement, not a nicety.
|
||||
//!
|
||||
//! WHY STRUCTS AND NOT `json!`: `type` must precede `divisionId` — the element
|
||||
//! parser binds the competition type before it maps the division. `serde_json`'s
|
||||
//! `Value` is a `BTreeMap` without the `preserve_order` feature, so `json!`
|
||||
//! silently reorders keys ALPHABETICALLY and would emit `divisionId` first.
|
||||
//! A `#[derive(Serialize)]` struct serialises in declaration order, so these
|
||||
//! types ARE the wire contract. For the same reason every body here is rendered
|
||||
//! straight to a `String` and never round-tripped through `Value`.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Rounds in one FIFA 17 offline season. The division ladder is ten matches.
|
||||
pub const SEASON_ROUNDS: i64 = 10;
|
||||
|
||||
/// Opponent team ids used for the round schedule.
|
||||
///
|
||||
/// These are real team ids observed in this client's own database (they appear
|
||||
/// as the `teamid` of club players in the live kit-item trace), so every round
|
||||
/// resolves to a team the client can actually render. They are cycled rather
|
||||
/// than randomised so a season's schedule is stable across reloads — the client
|
||||
/// re-reads `season/list` and a shifting schedule would renumber fixtures.
|
||||
const OPPONENT_TEAM_IDS: &[i64] = &[21, 73, 240, 241, 243];
|
||||
|
||||
/// One scheduled offline-season round.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SeasonMatch {
|
||||
#[serde(rename = "teamId")]
|
||||
pub team_id: i64,
|
||||
pub difficulty: i64,
|
||||
#[serde(rename = "roundId")]
|
||||
pub round_id: i64,
|
||||
#[serde(rename = "rewardMult")]
|
||||
pub reward_mult: i64,
|
||||
pub coins: i64,
|
||||
}
|
||||
|
||||
/// One offline competition. FIELD ORDER IS THE WIRE CONTRACT — `type` first.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SeasonElement {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: &'static str,
|
||||
pub id: i64,
|
||||
#[serde(rename = "divisionId")]
|
||||
pub division_id: i64,
|
||||
pub matches: Vec<SeasonMatch>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SeasonList {
|
||||
pub seasons: Vec<SeasonElement>,
|
||||
}
|
||||
|
||||
/// The club's position in its current season.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SeasonUser {
|
||||
#[serde(rename = "seasonId")]
|
||||
pub season_id: i64,
|
||||
#[serde(rename = "divisionId")]
|
||||
pub division_id: i64,
|
||||
pub round: i64,
|
||||
#[serde(rename = "userPoints")]
|
||||
pub user_points: i64,
|
||||
/// Opaque client blob; the client round-trips it and never requires server
|
||||
/// interpretation.
|
||||
#[serde(rename = "dataVersion")]
|
||||
pub data_version: &'static str,
|
||||
pub data: &'static str,
|
||||
}
|
||||
|
||||
fn round(index: i64) -> SeasonMatch {
|
||||
SeasonMatch {
|
||||
team_id: OPPONENT_TEAM_IDS[(index as usize) % OPPONENT_TEAM_IDS.len()],
|
||||
// Difficulty and reward multiplier are per-round bytes; a flat schedule
|
||||
// is the honest default until the retail ladder is captured.
|
||||
difficulty: 1,
|
||||
round_id: index,
|
||||
reward_mult: 1,
|
||||
coins: 400,
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET …/season/list` — the offline competitions the club can enter, as wire
|
||||
/// text (see the module note on key order).
|
||||
pub fn season_list_body(season_id: i64, division_id: i64) -> String {
|
||||
let list = SeasonList {
|
||||
seasons: vec![SeasonElement {
|
||||
kind: "OFFLINE",
|
||||
id: season_id,
|
||||
division_id,
|
||||
matches: (0..SEASON_ROUNDS).map(round).collect(),
|
||||
}],
|
||||
};
|
||||
serde_json::to_string(&list).expect("season list serialises")
|
||||
}
|
||||
|
||||
/// `GET …/season/user` — where the club currently is in its season.
|
||||
pub fn season_user_body(season_id: i64, division_id: i64, round: i64, user_points: i64) -> String {
|
||||
let user = SeasonUser {
|
||||
season_id,
|
||||
division_id,
|
||||
round,
|
||||
user_points,
|
||||
data_version: "1",
|
||||
data: "",
|
||||
};
|
||||
serde_json::to_string(&user).expect("season user serialises")
|
||||
}
|
||||
|
||||
/// `GET …/season/user/history` — completed seasons. Empty until a season ends;
|
||||
/// the client renders an empty history without complaint.
|
||||
pub fn season_history_body() -> String {
|
||||
String::from(r#"{"seasons":[]}"#)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::Value;
|
||||
|
||||
fn parsed(text: &str) -> Value {
|
||||
serde_json::from_str(text).expect("valid json")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_emits_a_full_round_schedule() {
|
||||
let body = parsed(&season_list_body(1, 10));
|
||||
let season = &body["seasons"][0];
|
||||
assert_eq!(season["type"], "OFFLINE");
|
||||
assert_eq!(season["id"], 1);
|
||||
assert_eq!(season["divisionId"], 10);
|
||||
assert_eq!(
|
||||
season["matches"].as_array().unwrap().len(),
|
||||
SEASON_ROUNDS as usize
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty `matches` vector makes StartSeason dereference NULL
|
||||
/// (CardsDLL+0xfc5b5), so the schedule can never be empty.
|
||||
#[test]
|
||||
fn matches_are_never_empty_and_every_round_has_a_team() {
|
||||
let body = parsed(&season_list_body(3, 7));
|
||||
let matches = body["seasons"][0]["matches"].as_array().unwrap();
|
||||
assert!(!matches.is_empty());
|
||||
for (i, m) in matches.iter().enumerate() {
|
||||
assert_eq!(m["roundId"], i as i64, "rounds are 0..n and in order");
|
||||
assert!(
|
||||
m["teamId"].as_i64().is_some_and(|t| t > 0),
|
||||
"round {i} must name a real opponent team: {m}"
|
||||
);
|
||||
assert!(m["coins"].as_i64().is_some());
|
||||
assert!(m["rewardMult"].as_i64().is_some());
|
||||
assert!(m["difficulty"].as_i64().is_some());
|
||||
}
|
||||
}
|
||||
|
||||
/// The element parser binds the competition type before mapping the
|
||||
/// division, so `type` MUST serialise before `divisionId`. `json!` would
|
||||
/// order them alphabetically and break this.
|
||||
#[test]
|
||||
fn type_is_serialised_before_division_id() {
|
||||
let text = season_list_body(1, 10);
|
||||
let type_at = text.find("\"type\"").expect("type key");
|
||||
let division_at = text.find("\"divisionId\"").expect("divisionId key");
|
||||
assert!(
|
||||
type_at < division_at,
|
||||
"type must precede divisionId on the wire: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_state_carries_the_season_position() {
|
||||
let body = parsed(&season_user_body(1, 10, 3, 6));
|
||||
assert_eq!(body["seasonId"], 1);
|
||||
assert_eq!(body["divisionId"], 10);
|
||||
assert_eq!(body["round"], 3);
|
||||
assert_eq!(body["userPoints"], 6);
|
||||
assert_eq!(body["dataVersion"], "1");
|
||||
assert_eq!(body["data"], "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_is_an_empty_season_list() {
|
||||
let body = parsed(&season_history_body());
|
||||
assert_eq!(body["seasons"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -141,9 +141,31 @@ pub struct ProposedSquad {
|
||||
/// never used to derive slot layout.
|
||||
pub formation: Option<String>,
|
||||
pub slots: Vec<ProposedSlot>,
|
||||
/// Occupied wire item ids the resolver could not map. A caller MUST refuse the
|
||||
/// replacement if this is non-empty — a save must never silently drop an
|
||||
/// owned player it failed to identify.
|
||||
/// The owned instance assigned as the squad's **manager**, reverse-resolved
|
||||
/// from the wire `manager` ref to a Core `owned_card_id` (so the assignment
|
||||
/// is ownership-backed, never a dangling wire id). `None` when the save
|
||||
/// carries no manager, or when its manager ref does not resolve — see
|
||||
/// [`Self::unresolved_manager_wire_id`].
|
||||
pub manager_owned_card_id: Option<String>,
|
||||
/// A non-zero manager ref the resolver could not map, if any.
|
||||
///
|
||||
/// This does NOT refuse the save. FIFA 17 sends a manager ref that is not an
|
||||
/// owned club item: production's own squad points at instance 100000427,
|
||||
/// which is absent from production's `/club/staff` listing (1975 items,
|
||||
/// 100000001..100004826), and the client accepts that squad back unchanged —
|
||||
/// so the client does not validate the manager against the club, and refusing
|
||||
/// the save would break EVERY real squad save for a field that was not even
|
||||
/// ownership-backed before migration 0023.
|
||||
///
|
||||
/// An unresolvable ref therefore means "no ownership-backed manager": the
|
||||
/// assignment is cleared, exactly as a full replacement should, and the id is
|
||||
/// reported so the host can log what it could not map. An occupied PLAYER
|
||||
/// slot is different and still refuses the save — dropping one would silently
|
||||
/// lose an owned card from the club.
|
||||
pub unresolved_manager_wire_id: Option<i64>,
|
||||
/// Occupied PLAYER wire item ids the resolver could not map. A caller MUST
|
||||
/// refuse the replacement if this is non-empty — a save must never silently
|
||||
/// drop an owned player it failed to identify.
|
||||
pub unresolved_wire_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
@@ -180,12 +202,14 @@ pub fn parse_squad_put(body: &[u8]) -> Result<Fifa17SquadPut, SquadError> {
|
||||
|
||||
/// Resolve a parsed save into a **canonical** [`ProposedSquad`]: drop empty
|
||||
/// (`id == 0`) slots, reverse-map each occupied slot's wire id to a Core
|
||||
/// `owned_card_id`, flag the captain, and derive the bench split from the fixed
|
||||
/// 23-slot array. FIFA-only state (`custom`, manager, kicktakers, kit numbers,
|
||||
/// squadType) and client-reported evaluation are NOT canonical — they are built
|
||||
/// separately into [`crate::fut::squad_ext::Fifa17SquadExtensionV1`]. The
|
||||
/// formation token is carried verbatim (never mapped). Unresolvable occupied ids
|
||||
/// are reported, never guessed or dropped.
|
||||
/// `owned_card_id`, flag the captain, derive the bench split from the fixed
|
||||
/// 23-slot array, and reverse-resolve the manager ref to an owned instance
|
||||
/// (the manager assignment is ownership-backed canonical state, migration 0023).
|
||||
/// The remaining FIFA-only state (`custom`, kicktakers, kit numbers, squadType)
|
||||
/// and client-reported evaluation are NOT canonical — they are built separately
|
||||
/// into [`crate::fut::squad_ext::Fifa17SquadExtensionV1`]. The formation token is
|
||||
/// carried verbatim (never mapped). Unresolvable occupied ids are reported,
|
||||
/// never guessed or dropped.
|
||||
pub fn to_proposed(put: &Fifa17SquadPut, resolver: &dyn SquadWireResolver) -> ProposedSquad {
|
||||
let captain = put.captain.unwrap_or(0);
|
||||
let mut slots = Vec::new();
|
||||
@@ -205,11 +229,25 @@ pub fn to_proposed(put: &Fifa17SquadPut, resolver: &dyn SquadWireResolver) -> Pr
|
||||
None => unresolved.push(p.item_data.id),
|
||||
}
|
||||
}
|
||||
// Manager: the first non-zero manager ref, reverse-resolved to an owned
|
||||
// instance. Unresolvable is NOT fatal (see `unresolved_manager_wire_id`) --
|
||||
// the real client always sends a dangling ref, so refusing would break every
|
||||
// squad save.
|
||||
let mut manager_owned_card_id = None;
|
||||
let mut unresolved_manager_wire_id = None;
|
||||
if let Some(wire) = put.manager.iter().map(|m| m.id).find(|&id| id != 0) {
|
||||
match resolver.owned_id_for_wire(wire) {
|
||||
Some(owned) => manager_owned_card_id = Some(owned),
|
||||
None => unresolved_manager_wire_id = Some(wire),
|
||||
}
|
||||
}
|
||||
ProposedSquad {
|
||||
squad_id: put.id,
|
||||
name: put.squad_name.clone(),
|
||||
formation: put.formation.clone(),
|
||||
slots,
|
||||
manager_owned_card_id,
|
||||
unresolved_manager_wire_id,
|
||||
unresolved_wire_ids: unresolved,
|
||||
}
|
||||
}
|
||||
@@ -237,9 +275,11 @@ mod tests {
|
||||
}
|
||||
fn full_resolver() -> MapResolver {
|
||||
// indices 0..=10 (11 starters); the rest of the 23 slots are id==0 (empty).
|
||||
// 100000427 is the fixture's manager ref — the host resolves it like any
|
||||
// other owned instance, so the manager assignment is ownership-backed.
|
||||
let ids = [
|
||||
100000003, 100000010, 100000005, 100000008, 100000007, 100000006, 100000004, 100000009,
|
||||
100000001, 100000002, 100000025,
|
||||
100000001, 100000002, 100000025, 100000427,
|
||||
];
|
||||
MapResolver(ids.iter().map(|&w| (w, format!("oc-{w}"))).collect())
|
||||
}
|
||||
@@ -283,6 +323,8 @@ mod tests {
|
||||
assert_eq!(caps[0].owned_card_id, "oc-100000001");
|
||||
assert_eq!(caps[0].index, 8);
|
||||
assert_eq!(caps[0].kit_number, 8);
|
||||
// The manager ref is reverse-resolved to an owned instance (canonical).
|
||||
assert_eq!(sq.manager_owned_card_id.as_deref(), Some("oc-100000427"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! | `custom` | opaque 33-int string; meaning UNKNOWN, round-tripped verbatim |
|
||||
//! | `squad_type` | an observed FIFA wire token; no matching generic Core concept |
|
||||
//! | `kit_numbers` | keyed by **`owned_card_id`** — evidence: kit follows the player |
|
||||
//! | `manager` | a FIFA manager item ref; not a squad player, semantics opaque |
|
||||
//! | ~~manager~~ | MOVED to ownership-backed canonical Core state (migration 0023 `squad_managers`); resolved to an `owned_card_id`, no longer opaque here |
|
||||
//! | `kicktakers` | role→item refs; relationship to captain UNKNOWN, kept opaque |
|
||||
//! | `client_reported` | chemistry/rating/starRating — client shadow, NOT authority |
|
||||
//!
|
||||
@@ -30,7 +30,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::fut::squad::{ClientReportedSquadEval, Fifa17SquadPut, ProposedSquad, SquadEntityRef};
|
||||
use crate::fut::squad::{ClientReportedSquadEval, Fifa17SquadPut, ProposedSquad};
|
||||
|
||||
/// Opaque scope key Core files this extension under (`game_entity_ext.namespace`).
|
||||
pub const EXT_NAMESPACE: &str = "fifa17.squad";
|
||||
@@ -49,15 +49,6 @@ pub struct WireItemRef {
|
||||
pub dream: bool,
|
||||
}
|
||||
|
||||
impl From<&SquadEntityRef> for WireItemRef {
|
||||
fn from(r: &SquadEntityRef) -> Self {
|
||||
WireItemRef {
|
||||
id: r.id,
|
||||
dream: r.dream,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A kicktaker slot preserved verbatim. `index` is the role slot (0..=4 observed);
|
||||
/// `item` is the referenced FIFA wire item. The role→player meaning and any
|
||||
/// relationship to the captain are UNKNOWN, so this is stored opaquely and never
|
||||
@@ -83,9 +74,9 @@ pub struct Fifa17SquadExtensionV1 {
|
||||
/// proves the kit number follows the player across swaps and formation change.
|
||||
#[serde(default)]
|
||||
pub kit_numbers: BTreeMap<String, i64>,
|
||||
/// Manager item ref(s), opaque. Not a squad player; not shaped as an item.
|
||||
#[serde(default)]
|
||||
pub manager: Vec<WireItemRef>,
|
||||
// NOTE: the squad manager is NO LONGER carried here. It is ownership-backed
|
||||
// canonical Core state (migration 0023 `squad_managers`), resolved to an
|
||||
// `owned_card_id` on the ProposedSquad — never a dangling opaque wire ref.
|
||||
/// Kicktaker role refs, opaque (see [`KicktakerRef`]).
|
||||
#[serde(default)]
|
||||
pub kicktakers: Vec<KicktakerRef>,
|
||||
@@ -132,7 +123,6 @@ impl Fifa17SquadExtensionV1 {
|
||||
custom: put.custom.clone(),
|
||||
squad_type: put.squad_type.clone(),
|
||||
kit_numbers,
|
||||
manager: put.manager.iter().map(WireItemRef::from).collect(),
|
||||
kicktakers: put
|
||||
.kicktakers
|
||||
.iter()
|
||||
@@ -256,6 +246,9 @@ mod tests {
|
||||
let ids = [
|
||||
100000003, 100000010, 100000005, 100000008, 100000007, 100000006, 100000004, 100000009,
|
||||
100000001, 100000002, 100000025,
|
||||
// The f442 fixture's manager ref — the host resolves it like any other
|
||||
// owned instance, so the ownership-backed manager assignment is present.
|
||||
100000427,
|
||||
];
|
||||
MapResolver(ids.iter().map(|&w| (w, format!("oc-{w}"))).collect())
|
||||
}
|
||||
@@ -308,22 +301,41 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_and_kicktakers_preserved_opaquely() {
|
||||
let ext = built().extension;
|
||||
fn manager_is_canonical_and_kicktakers_stay_opaque() {
|
||||
let build = built();
|
||||
// Manager is now ownership-backed canonical state: the wire ref resolved
|
||||
// to a Core owned_card_id on the ProposedSquad, not an opaque ext blob.
|
||||
assert_eq!(
|
||||
ext.manager,
|
||||
vec![WireItemRef {
|
||||
id: 100000427,
|
||||
dream: false
|
||||
}]
|
||||
build.canonical.manager_owned_card_id.as_deref(),
|
||||
Some("oc-100000427")
|
||||
);
|
||||
// Kicktakers remain opaque in the extension.
|
||||
let ext = build.extension;
|
||||
assert_eq!(ext.kicktakers.len(), 5);
|
||||
// All five reference the same wire id in this capture; carried verbatim,
|
||||
// NEVER normalized to the captain even though they coincide here.
|
||||
assert!(ext.kicktakers.iter().all(|k| k.item.id == 100000001));
|
||||
assert_eq!(ext.kicktakers[0].index, 0);
|
||||
}
|
||||
|
||||
/// A manager ref that does not resolve must NOT refuse the save: FIFA always
|
||||
/// sends one, and on a real profile it is dangling (production points at
|
||||
/// 100000427, absent from its own /club/staff). The save commits with no
|
||||
/// ownership-backed manager and reports the id it could not map.
|
||||
#[test]
|
||||
fn an_unresolvable_manager_ref_clears_the_assignment_without_refusing() {
|
||||
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
||||
let mut ids = full_resolver().0;
|
||||
ids.remove(&100000427);
|
||||
let build = build_squad_write(&put, &MapResolver(ids)).expect("save must still commit");
|
||||
assert_eq!(build.canonical.manager_owned_card_id, None);
|
||||
assert_eq!(
|
||||
build.canonical.unresolved_manager_wire_id,
|
||||
Some(100000427),
|
||||
"the ref we could not map is reported, not swallowed"
|
||||
);
|
||||
// The starting XI is untouched -- only the manager assignment is dropped.
|
||||
assert_eq!(build.canonical.slots.len(), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_schema_version_is_rejected_not_coerced() {
|
||||
let payload = built().extension.to_payload();
|
||||
|
||||
@@ -36,7 +36,9 @@ use std::collections::HashMap;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
use crate::fut::item::{shape_item, CoreOwnedItem, ItemIdentityResolver};
|
||||
use crate::fut::item::{
|
||||
shape_item, shape_staff_item, CoreOwnedItem, ItemIdentityResolver, STAFF_CONTRACT,
|
||||
};
|
||||
use crate::fut::squad::FIFA17_SQUAD_SLOTS;
|
||||
use crate::fut::squad_ext::Fifa17SquadExtensionV1;
|
||||
|
||||
@@ -77,6 +79,11 @@ pub struct SquadProjectionInput<'a> {
|
||||
/// Every owned item a slot references, keyed by `owned_card_id`. Assembled by
|
||||
/// the host in one batch — the projector only reads from it.
|
||||
pub owned: &'a HashMap<String, CoreOwnedItem>,
|
||||
/// The owned instance assigned as this squad's **manager** (Core's
|
||||
/// ownership-backed `squad_managers` assignment, migration 0023), or `None`.
|
||||
/// Projected as the FIFA `manager` wire ref resolved from ownership — never a
|
||||
/// dangling wire id, and never fabricated when absent.
|
||||
pub manager: Option<CoreOwnedItem>,
|
||||
}
|
||||
|
||||
/// Result of a projection, with the extension-freshness verdict surfaced.
|
||||
@@ -171,6 +178,35 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
||||
}
|
||||
}
|
||||
|
||||
// Manager: the ownership-backed assignment, resolved to its FIFA wire ref
|
||||
// AND carrying its item, as `[{id, itemData, dream}]`.
|
||||
//
|
||||
// The bare `[{id, dream}]` form is NOT sufficient, which cost a real
|
||||
// debugging round: the operator picked a manager in the hub, the save
|
||||
// persisted (Core `squad_managers` row written, `outcome=ok`, no unresolved
|
||||
// ref), and the pre-match squad still showed no manager. Every retail
|
||||
// capture that shows the bare form has `id: 0` — an EMPTY manager — so none
|
||||
// of them ever demonstrated that a POPULATED ref resolves without its item.
|
||||
//
|
||||
// The squad response is self-contained for players: `players[].itemData`
|
||||
// carries the whole card rather than an id the client resolves out of band.
|
||||
// The manager is the same kind of slot in the same object, and the one
|
||||
// implementation that ever drove a working manager (the Python oracle's
|
||||
// squad) emits `id` BESIDE `itemData` exactly like this. Note the element
|
||||
// shape differs from a player slot: `{index, itemData, kitNumber}` there,
|
||||
// `{id, itemData, dream}` here.
|
||||
//
|
||||
// An owned manager with no resolvable FIFA staff identity is omitted
|
||||
// (non-fatal, like /club dropping an unrenderable card) rather than emitted
|
||||
// with a fabricated id.
|
||||
let manager = match input.manager.as_ref().and_then(|m| ident.resolve_staff(m)) {
|
||||
Some(id) => json!([{
|
||||
"id": id.item_id,
|
||||
"itemData": shape_staff_item(id, STAFF_CONTRACT),
|
||||
"dream": false,
|
||||
}]),
|
||||
None => json!([]),
|
||||
};
|
||||
let squad = json!({
|
||||
"id": input.fifa_squad_id,
|
||||
"squadName": input.name,
|
||||
@@ -180,7 +216,7 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
||||
"starRating": ext.client_reported.star_rating,
|
||||
"rating": ext.client_reported.rating,
|
||||
"captain": captain_wire,
|
||||
"manager": ext.manager,
|
||||
"manager": manager,
|
||||
"custom": ext.custom,
|
||||
"players": players,
|
||||
"kicktakers": ext.kicktakers,
|
||||
@@ -219,14 +255,22 @@ pub fn squad_list(projected: &Value) -> Value {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fut::entities::Fifa17Entities;
|
||||
use crate::fut::item::Fifa17Identity;
|
||||
use crate::fut::item::{Fifa17Identity, Fifa17StaffIdentity};
|
||||
|
||||
// A resolver that mints a distinct wire id per owned item and a fixed asset.
|
||||
struct TableIdentity(HashMap<String, Fifa17Identity>);
|
||||
// `staff` is separate because a manager resolves through the STAFF identity,
|
||||
// which carries the chemistry fields a player identity has no room for.
|
||||
struct TableIdentity(
|
||||
HashMap<String, Fifa17Identity>,
|
||||
HashMap<String, Fifa17StaffIdentity>,
|
||||
);
|
||||
impl ItemIdentityResolver for TableIdentity {
|
||||
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||
self.0.get(&it.owned_card_id).copied()
|
||||
}
|
||||
fn resolve_staff(&self, it: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
|
||||
self.1.get(&it.owned_card_id).copied()
|
||||
}
|
||||
}
|
||||
|
||||
fn ent() -> Fifa17Entities {
|
||||
@@ -262,6 +306,7 @@ mod tests {
|
||||
}],
|
||||
ext,
|
||||
owned,
|
||||
manager: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +317,6 @@ mod tests {
|
||||
custom: Some("[1,2,3]".into()),
|
||||
squad_type: Some("REGULAR_SQUAD".into()),
|
||||
kit_numbers: kit,
|
||||
manager: vec![],
|
||||
kicktakers: vec![],
|
||||
client_reported: Default::default(),
|
||||
}
|
||||
@@ -282,7 +326,8 @@ mod tests {
|
||||
fn fresh_projects_full_23_slot_array_with_captain_wire_id() {
|
||||
let mut owned = HashMap::new();
|
||||
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
|
||||
let ident = TableIdentity(HashMap::from([(
|
||||
let ident = TableIdentity(
|
||||
HashMap::from([(
|
||||
"oc1".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000042,
|
||||
@@ -290,7 +335,9 @@ mod tests {
|
||||
resource_id: 20801,
|
||||
rareflag: 1,
|
||||
},
|
||||
)]));
|
||||
)]),
|
||||
HashMap::new(),
|
||||
);
|
||||
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
|
||||
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
|
||||
panic!("expected Projected");
|
||||
@@ -318,7 +365,7 @@ mod tests {
|
||||
let owned = HashMap::new();
|
||||
// A stale extension IS carried (host may log it) but must not be applied.
|
||||
let input = one_slot_input(&owned, SquadExtInput::Stale(fresh_ext()));
|
||||
let ident = TableIdentity(HashMap::new());
|
||||
let ident = TableIdentity(HashMap::new(), HashMap::new());
|
||||
assert_eq!(
|
||||
project_squad(&input, &ident, &ent()).unwrap(),
|
||||
SquadProjection::Stale
|
||||
@@ -329,7 +376,7 @@ mod tests {
|
||||
fn missing_is_explicit_never_fabricated() {
|
||||
let owned = HashMap::new();
|
||||
let input = one_slot_input(&owned, SquadExtInput::Missing);
|
||||
let ident = TableIdentity(HashMap::new());
|
||||
let ident = TableIdentity(HashMap::new(), HashMap::new());
|
||||
assert_eq!(
|
||||
project_squad(&input, &ident, &ent()).unwrap(),
|
||||
SquadProjection::Missing
|
||||
@@ -340,7 +387,7 @@ mod tests {
|
||||
fn occupied_starter_without_asset_identity_is_refused_not_faked() {
|
||||
let mut owned = HashMap::new();
|
||||
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
|
||||
let ident = TableIdentity(HashMap::new()); // resolves nothing
|
||||
let ident = TableIdentity(HashMap::new(), HashMap::new()); // resolves nothing
|
||||
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
|
||||
assert_eq!(
|
||||
project_squad(&input, &ident, &ent()),
|
||||
@@ -355,7 +402,8 @@ mod tests {
|
||||
let mut owned = HashMap::new();
|
||||
owned.insert("oc-a".to_string(), owned_item("oc-a", "fifa17_101490"));
|
||||
owned.insert("oc-b".to_string(), owned_item("oc-b", "fifa17_101490"));
|
||||
let ident = TableIdentity(HashMap::from([
|
||||
let ident = TableIdentity(
|
||||
HashMap::from([
|
||||
(
|
||||
"oc-a".to_string(),
|
||||
Fifa17Identity {
|
||||
@@ -374,7 +422,9 @@ mod tests {
|
||||
rareflag: 1,
|
||||
},
|
||||
),
|
||||
]));
|
||||
]),
|
||||
HashMap::new(),
|
||||
);
|
||||
let mut kit = std::collections::BTreeMap::new();
|
||||
kit.insert("oc-a".to_string(), 7);
|
||||
kit.insert("oc-b".to_string(), 19);
|
||||
@@ -403,6 +453,7 @@ mod tests {
|
||||
],
|
||||
ext: SquadExtInput::Fresh(ext),
|
||||
owned: owned_ref,
|
||||
manager: None,
|
||||
};
|
||||
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
|
||||
panic!();
|
||||
@@ -423,4 +474,89 @@ mod tests {
|
||||
"kit stays with the instance"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_projected_from_ownership_as_wire_ref() {
|
||||
let mut owned = HashMap::new();
|
||||
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
|
||||
let ident = TableIdentity(
|
||||
HashMap::from([(
|
||||
"oc1".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000042,
|
||||
asset_id: 20801,
|
||||
resource_id: 20801,
|
||||
rareflag: 1,
|
||||
},
|
||||
)]),
|
||||
HashMap::from([(
|
||||
"oc-mgr".to_string(),
|
||||
Fifa17StaffIdentity {
|
||||
item_id: 100000427,
|
||||
resource_id: 1_000_509,
|
||||
subtype: 4,
|
||||
nation: 45,
|
||||
league_id: 53,
|
||||
team_id: 241,
|
||||
},
|
||||
)]),
|
||||
);
|
||||
let mut input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
|
||||
input.manager = Some(owned_item("oc-mgr", "fifa17_mgr"));
|
||||
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
|
||||
panic!("expected Projected");
|
||||
};
|
||||
// The item must ride ALONG with the ref: a bare `{id, dream}` left the
|
||||
// pre-match squad with no manager even though the assignment had been
|
||||
// saved, because nothing in the response described the card.
|
||||
assert_eq!(
|
||||
v["manager"],
|
||||
json!([{
|
||||
"id": 100000427,
|
||||
"itemData": {
|
||||
"id": 100000427,
|
||||
"resourceId": 1_000_509,
|
||||
"cardsubtypeid": 4,
|
||||
"itemType": "staff",
|
||||
"nation": 45,
|
||||
"leagueId": 53,
|
||||
"teamid": 241,
|
||||
"contract": STAFF_CONTRACT,
|
||||
"itemState": "free",
|
||||
"owners": 1,
|
||||
"untradeable": false,
|
||||
},
|
||||
"dream": false,
|
||||
}]),
|
||||
"manager is the ownership-backed wire ref WITH its item"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_manager_projects_empty_array_never_fabricated() {
|
||||
let mut owned = HashMap::new();
|
||||
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
|
||||
let ident = TableIdentity(
|
||||
HashMap::from([(
|
||||
"oc1".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000042,
|
||||
asset_id: 20801,
|
||||
resource_id: 20801,
|
||||
rareflag: 1,
|
||||
},
|
||||
)]),
|
||||
HashMap::new(),
|
||||
);
|
||||
// one_slot_input leaves manager: None.
|
||||
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
|
||||
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
|
||||
panic!("expected Projected");
|
||||
};
|
||||
assert_eq!(
|
||||
v["manager"],
|
||||
json!([]),
|
||||
"no manager assignment => empty array, nothing fabricated"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+189
-151
@@ -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",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"itemQuantity": 11
|
||||
},
|
||||
"unopened": true,
|
||||
"displayGroup": {
|
||||
"value": "special"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
"value": "mypacks",
|
||||
"priority": 1
|
||||
}
|
||||
},
|
||||
"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": 70,
|
||||
"description": "Reward Special Players Pack",
|
||||
"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
|
||||
}
|
||||
],
|
||||
"timestamp": 1596326400
|
||||
|
||||
+163
-133
@@ -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
|
||||
|
||||
+180
-149
@@ -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
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
//! ```
|
||||
//!
|
||||
//! Fidelity is asserted by ownership class:
|
||||
//! CANONICAL player instance per index, formation, captain, bench split
|
||||
//! EXTENSION custom, kicktakers, manager, kit numbers (by player), squadType
|
||||
//! CANONICAL player instance per index, formation, captain, bench split, and
|
||||
//! the ownership-backed manager assignment (migration 0023)
|
||||
//! EXTENSION custom, kicktakers, kit numbers (by player), squadType
|
||||
//! SHADOW chemistry/rating/starRating (client-reported, round-tripped as-is)
|
||||
//! DERIVED correct FIFA 17 item identity (wire id + resourceId)
|
||||
//!
|
||||
@@ -23,7 +24,9 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use openfut_adapter_fifa17::fut::item::{CoreOwnedItem, Fifa17Identity, ItemIdentityResolver};
|
||||
use openfut_adapter_fifa17::fut::item::{
|
||||
CoreOwnedItem, Fifa17Identity, Fifa17StaffIdentity, ItemIdentityResolver, STAFF_CONTRACT,
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::squad::{parse_squad_put, Fifa17SquadPut, SquadWireResolver};
|
||||
use openfut_adapter_fifa17::fut::squad_ext::{build_squad_write, SquadWriteBuild};
|
||||
use openfut_adapter_fifa17::fut::squad_projection::{
|
||||
@@ -49,11 +52,19 @@ impl SquadWireResolver for OcResolver {
|
||||
}
|
||||
|
||||
/// owned_card_id → FIFA identity, so two copies of one definition stay distinct.
|
||||
struct TableIdentity(HashMap<String, Fifa17Identity>);
|
||||
/// `staff` is a second table because a manager resolves through the STAFF
|
||||
/// identity, which carries the chemistry fields a player identity cannot hold.
|
||||
struct TableIdentity(
|
||||
HashMap<String, Fifa17Identity>,
|
||||
HashMap<String, Fifa17StaffIdentity>,
|
||||
);
|
||||
impl ItemIdentityResolver for TableIdentity {
|
||||
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||
self.0.get(&it.owned_card_id).copied()
|
||||
}
|
||||
fn resolve_staff(&self, it: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
|
||||
self.1.get(&it.owned_card_id).copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// Neutral entity resolver — badge/flag ids are covered by `fut::item` tests; the
|
||||
@@ -115,7 +126,7 @@ fn oracle_tables() -> (HashMap<String, CoreOwnedItem>, TableIdentity) {
|
||||
},
|
||||
);
|
||||
}
|
||||
(owned, TableIdentity(ident))
|
||||
(owned, TableIdentity(ident, HashMap::new()))
|
||||
}
|
||||
|
||||
/// The full pipeline: parse a captured PUT, build the canonical + extension, then
|
||||
@@ -146,6 +157,9 @@ fn project_put(
|
||||
slots,
|
||||
ext: SquadExtInput::Fresh(extension),
|
||||
owned,
|
||||
// This stand-in supplies no owned manager; the manager is projected from
|
||||
// the ownership-backed assignment, exercised in persisted_read below.
|
||||
manager: None,
|
||||
};
|
||||
match project_squad(&input, ident, &NoEntities).unwrap() {
|
||||
SquadProjection::Projected(v) => v,
|
||||
@@ -194,9 +208,11 @@ fn baseline_projects_the_known_squad_round_trip() {
|
||||
projected["captain"], 100000001,
|
||||
"captain is the player's WIRE id"
|
||||
);
|
||||
// EXTENSION: custom byte-identical, manager + squadType preserved.
|
||||
// EXTENSION: custom byte-identical, squadType preserved. The manager is now
|
||||
// an ownership-backed assignment (not projected from the PUT/ext); with none
|
||||
// supplied to this stand-in it projects empty.
|
||||
assert_eq!(projected["custom"], put_v["custom"]);
|
||||
assert_eq!(projected["manager"], put_v["manager"]);
|
||||
assert!(projected["manager"].as_array().unwrap().is_empty());
|
||||
assert_eq!(projected["squadType"], "REGULAR_SQUAD");
|
||||
// SHADOW: client-reported values carried as-is (baseline chemistry 52).
|
||||
assert_eq!(projected["chemistry"], 52);
|
||||
@@ -268,13 +284,11 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
|
||||
// evidence, then project and require the read back — the strongest fidelity
|
||||
// check across all four ownership classes.
|
||||
use openfut_adapter_fifa17::fut::squad::ClientReportedSquadEval;
|
||||
use openfut_adapter_fifa17::fut::squad_ext::{
|
||||
Fifa17SquadExtensionV1, KicktakerRef, WireItemRef,
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::squad_ext::{Fifa17SquadExtensionV1, KicktakerRef};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
let oracle: Value = serde_json::from_str(READ_ORACLE).unwrap();
|
||||
let (owned, ident) = oracle_tables();
|
||||
let (owned, mut ident) = oracle_tables();
|
||||
let captain = oracle["captain"].as_i64().unwrap();
|
||||
|
||||
let mut slots = Vec::new();
|
||||
@@ -294,14 +308,40 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
|
||||
is_on_bench: index >= 11,
|
||||
});
|
||||
}
|
||||
let manager: Vec<WireItemRef> = serde_json::from_value(oracle["manager"].clone()).unwrap();
|
||||
// The manager is ownership-backed: register its owned instance + STAFF
|
||||
// identity and pass it as the assignment, not as an opaque extension field.
|
||||
// A real managercards row is used (1000509 Luis Enrique, nation 45, LaLiga
|
||||
// 53, Barcelona 241) so the projected item is a shape the client could
|
||||
// actually merge.
|
||||
let mgr_wire = oracle["manager"][0]["id"].as_i64().unwrap();
|
||||
let mgr_oc = format!("oc-{mgr_wire}");
|
||||
ident.1.insert(
|
||||
mgr_oc.clone(),
|
||||
Fifa17StaffIdentity {
|
||||
item_id: mgr_wire as u32,
|
||||
resource_id: 1_000_509,
|
||||
subtype: 4,
|
||||
nation: 45,
|
||||
league_id: 53,
|
||||
team_id: 241,
|
||||
},
|
||||
);
|
||||
let manager_item = CoreOwnedItem {
|
||||
owned_card_id: mgr_oc,
|
||||
card_id: "def-manager".to_string(),
|
||||
rating: 0,
|
||||
position: String::new(),
|
||||
nation: String::new(),
|
||||
league: String::new(),
|
||||
club: String::new(),
|
||||
attributes: [0; 6],
|
||||
};
|
||||
let kicktakers: Vec<KicktakerRef> =
|
||||
serde_json::from_value(oracle["kicktakers"].clone()).unwrap();
|
||||
let ext = Fifa17SquadExtensionV1 {
|
||||
custom: oracle["custom"].as_str().map(str::to_string),
|
||||
squad_type: oracle["squadType"].as_str().map(str::to_string),
|
||||
kit_numbers,
|
||||
manager,
|
||||
kicktakers,
|
||||
client_reported: ClientReportedSquadEval {
|
||||
chemistry: oracle["chemistry"].as_i64(),
|
||||
@@ -316,6 +356,7 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
|
||||
slots,
|
||||
ext: SquadExtInput::Fresh(ext),
|
||||
owned: &owned,
|
||||
manager: Some(manager_item),
|
||||
};
|
||||
let SquadProjection::Projected(projected) = project_squad(&input, &ident, &NoEntities).unwrap()
|
||||
else {
|
||||
@@ -352,7 +393,23 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
|
||||
}
|
||||
// EXTENSION + SHADOW: sourced from the read, so they round-trip identically.
|
||||
assert_eq!(projected["custom"], oracle["custom"]);
|
||||
assert_eq!(projected["manager"], oracle["manager"]);
|
||||
// The manager REF round-trips; the item now rides with it. The capture this
|
||||
// oracle came from carried a bare `{id, dream}`, but its manager was the
|
||||
// dangling one every retail capture has, so it never showed that a populated
|
||||
// ref renders on its own — and in practice it did not.
|
||||
assert_eq!(
|
||||
projected["manager"][0]["id"], oracle["manager"][0]["id"],
|
||||
"the manager wire ref itself must still round-trip"
|
||||
);
|
||||
assert_eq!(
|
||||
projected["manager"][0]["dream"],
|
||||
oracle["manager"][0]["dream"]
|
||||
);
|
||||
let mgr_item = &projected["manager"][0]["itemData"];
|
||||
assert_eq!(mgr_item["id"], oracle["manager"][0]["id"]);
|
||||
assert_eq!(mgr_item["cardsubtypeid"], 4);
|
||||
assert_eq!(mgr_item["resourceId"], 1_000_509);
|
||||
assert_eq!(mgr_item["contract"], STAFF_CONTRACT);
|
||||
assert_eq!(projected["kicktakers"], oracle["kicktakers"]);
|
||||
assert_eq!(projected["squadType"], oracle["squadType"]);
|
||||
assert_eq!(projected["chemistry"], oracle["chemistry"]);
|
||||
|
||||
+1
-1
Submodule openfut-bridge updated: 0f581ebef3...07e83fe36c
+1
-1
Submodule openfut-core updated: 637a21eac1...36bc594924
@@ -70,6 +70,11 @@ pub struct GenericClub {
|
||||
pub struct GenericOwned {
|
||||
pub owned_item_id: String,
|
||||
pub card_id: String,
|
||||
/// Generic classification for Core's ownership row. FIFA17 is the only side
|
||||
/// that can map `cardsubtypeid` onto this, and Core defaults to `player`, so
|
||||
/// leaving it off would durably record a coach or a contract card as a
|
||||
/// player — wrong in the ownership authority even when the wire looks right.
|
||||
pub content_kind: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -174,6 +179,7 @@ pub fn plan_apply(
|
||||
owned.push(GenericOwned {
|
||||
owned_item_id: core_id.clone(),
|
||||
card_id: def.card_id.clone(),
|
||||
content_kind: "player",
|
||||
});
|
||||
mappings.push(IdentityMapping {
|
||||
core_id,
|
||||
@@ -191,6 +197,7 @@ pub fn plan_apply(
|
||||
owned.push(GenericOwned {
|
||||
owned_item_id: core_id.clone(),
|
||||
card_id: def.card_id.clone(),
|
||||
content_kind: def.kind.as_str(),
|
||||
});
|
||||
mappings.push(IdentityMapping {
|
||||
core_id,
|
||||
@@ -208,7 +215,15 @@ pub fn plan_apply(
|
||||
.and_then(|s| s.get(0))
|
||||
.context("report says a squad is present but profile has no squads[0]")?;
|
||||
let body = serde_json::to_vec(raw_squad).context("re-serialize source squad")?;
|
||||
let put = parse_squad_put(&body).map_err(|e| anyhow::anyhow!("parse source squad: {e}"))?;
|
||||
let mut put =
|
||||
parse_squad_put(&body).map_err(|e| anyhow::anyhow!("parse source squad: {e}"))?;
|
||||
// A HISTORICAL profile may reference a manager whose owned instance is
|
||||
// not imported (unsupported/deferred, or a dangling id with no owned
|
||||
// item at all). Drop such a manager ref here rather than failing the
|
||||
// whole import — the manager assignment is only imported when its owned
|
||||
// instance is. (A LIVE squad PUT still refuses an unresolved manager,
|
||||
// because the client is actively assigning one it must own.)
|
||||
put.manager.retain(|m| wire_to_owned.contains_key(&m.id));
|
||||
let build = build_squad_write(&put, &MapResolver(&wire_to_owned))
|
||||
.map_err(|e| anyhow::anyhow!("build squad write: {e}"))?;
|
||||
let formation = build
|
||||
|
||||
@@ -166,10 +166,14 @@ pub enum ItemClass {
|
||||
PlayerCard,
|
||||
Consumable,
|
||||
Staff,
|
||||
Kit,
|
||||
Other,
|
||||
}
|
||||
|
||||
pub fn classify(item: &Item) -> ItemClass {
|
||||
if item.cardsubtypeid == Some(9) && (6_300_000..=6_400_654).contains(&item.resource_id) {
|
||||
return ItemClass::Kit;
|
||||
}
|
||||
match item.item_type.as_str() {
|
||||
"staff" => ItemClass::Staff,
|
||||
"player" => {
|
||||
@@ -189,12 +193,13 @@ pub struct ItemCounts {
|
||||
pub player_cards: usize,
|
||||
pub consumables: usize,
|
||||
pub staff: usize,
|
||||
pub kits: usize,
|
||||
pub other: usize,
|
||||
}
|
||||
|
||||
impl ItemCounts {
|
||||
pub fn balances(&self) -> bool {
|
||||
self.player_cards + self.consumables + self.staff + self.other == self.total
|
||||
self.player_cards + self.consumables + self.staff + self.kits + self.other == self.total
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +213,7 @@ pub fn count_items(profile: &Profile) -> ItemCounts {
|
||||
ItemClass::PlayerCard => c.player_cards += 1,
|
||||
ItemClass::Consumable => c.consumables += 1,
|
||||
ItemClass::Staff => c.staff += 1,
|
||||
ItemClass::Kit => c.kits += 1,
|
||||
ItemClass::Other => c.other += 1,
|
||||
}
|
||||
}
|
||||
@@ -524,21 +530,38 @@ pub fn plan_definitions(
|
||||
|
||||
// ------------------------------------------------------- non-player content
|
||||
|
||||
/// An honest, profile-derived NON-player CardDefinition proposal (consumable or
|
||||
/// staff), keyed by `fifa17_<resourceId>`. Neutral player fields are supplied at
|
||||
/// emit time; this carries only the identity + honest functional `name` (the
|
||||
/// taxonomy label, never a marketing name).
|
||||
/// An honest, profile-derived non-player CardDefinition proposal, keyed by
|
||||
/// `fifa17_<resourceId>`. Neutral player fields are supplied at emit time; FIFA
|
||||
/// render metadata stays here and in the adapter catalog, never generic Core.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NonPlayerDefinition {
|
||||
pub card_id: String,
|
||||
pub resource_id: i64,
|
||||
/// Base asset id when the source carries one (consumables: `== resource_id`);
|
||||
/// staff carry no `assetId`, so this is `None`.
|
||||
/// Base asset id when the source carries one.
|
||||
pub asset_id: Option<i64>,
|
||||
pub kind: ContentKind,
|
||||
/// FIFA `cardsubtypeid` (consumable family / staff role selector).
|
||||
/// FIFA `cardsubtypeid` (consumable family, staff role, or kit family).
|
||||
pub subtype: i64,
|
||||
/// Honest functional label (e.g. "Player Contract", "GK Coach").
|
||||
/// Non-player card-art id (`35` for kits), when present.
|
||||
pub card_asset_id: Option<i64>,
|
||||
/// Source team id for a kit definition, when present.
|
||||
pub team_id: Option<i64>,
|
||||
/// Consumable effect magnitude (`amount`), when the source carries one.
|
||||
///
|
||||
/// Definition-level, and measured to be so: across every owned consumable in
|
||||
/// the real profile the observed `amount` equals its `fcc_*` table row
|
||||
/// (1, 2, 4, 5, 10, 15 — no disagreements), and a wire omission corresponds
|
||||
/// to a table `amount` of 0. It is NOT a stack count: two copies of 5003068
|
||||
/// arrive as two separate instances, each with the same amount.
|
||||
pub amount: Option<i64>,
|
||||
/// Contract-card payload (`contract`), when the source carries one. Present
|
||||
/// on exactly the contract families (subtypes 201/202) and absent from every
|
||||
/// other owned consumable, so it is the card's own field rather than the
|
||||
/// generic per-item contract atom that players and staff carry.
|
||||
pub contract: Option<i64>,
|
||||
/// Card rating, when the source carries one (matches the `fcc_*` row).
|
||||
pub rating: Option<i64>,
|
||||
/// Honest functional label (e.g. "Player Contract", "GK Coach", "Kit").
|
||||
pub name: String,
|
||||
/// Wire ids of every owned copy of this resourceId (preserved).
|
||||
pub wire_ids: Vec<i64>,
|
||||
@@ -563,6 +586,8 @@ pub struct NonPlayerPlan {
|
||||
pub consumables: usize,
|
||||
/// Count of SUPPORTED staff definitions.
|
||||
pub staff: usize,
|
||||
/// Count of SUPPORTED kit definitions.
|
||||
pub kits: usize,
|
||||
}
|
||||
|
||||
impl NonPlayerPlan {
|
||||
@@ -572,15 +597,16 @@ impl NonPlayerPlan {
|
||||
}
|
||||
}
|
||||
|
||||
/// Plan the non-player (consumable + staff) CardDefinitions. Groups Consumable
|
||||
/// and Staff items by `resourceId`; each group must agree on `cardsubtypeid`
|
||||
/// across copies (a disagreement DEFERS with `subtype_conflict`), then resolves
|
||||
/// the family (consumable) or role (staff) via the adapter's evidence-based
|
||||
/// taxonomy. A missing or unknown `cardsubtypeid` DEFERS — never a placeholder.
|
||||
/// Plan non-player CardDefinitions. Consumable, Staff, and Kit groups must agree
|
||||
/// on their definition-level metadata across every owned copy; disagreement or
|
||||
/// missing required metadata defers the whole group, never fabricates a value.
|
||||
pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
|
||||
let mut groups: BTreeMap<i64, Vec<&Item>> = BTreeMap::new();
|
||||
for it in &profile.items {
|
||||
if matches!(classify(it), ItemClass::Consumable | ItemClass::Staff) {
|
||||
if matches!(
|
||||
classify(it),
|
||||
ItemClass::Consumable | ItemClass::Staff | ItemClass::Kit
|
||||
) {
|
||||
groups.entry(it.resource_id).or_default().push(it);
|
||||
}
|
||||
}
|
||||
@@ -622,6 +648,31 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
|
||||
continue;
|
||||
};
|
||||
|
||||
let card_asset_id = items[0].cardassetid;
|
||||
let team_id = items[0].teamid;
|
||||
// These are definition-level, so every owned copy must agree. Two copies
|
||||
// of one consumable that disagreed would mean the field is really
|
||||
// per-instance, and silently taking the first copy's value would bake a
|
||||
// guess into the catalog — so defer the whole group instead.
|
||||
let amount = items[0].amount;
|
||||
let contract = items[0].contract;
|
||||
let rating = items[0].rating;
|
||||
if items.iter().any(|item| {
|
||||
item.cardassetid != card_asset_id
|
||||
|| item.teamid != team_id
|
||||
|| item.amount != amount
|
||||
|| item.contract != contract
|
||||
|| item.rating != rating
|
||||
}) {
|
||||
plan.deferred.push(DeferredNonPlayer {
|
||||
resource_id,
|
||||
subtype: Some(subtype),
|
||||
wire_ids,
|
||||
reason: "render_metadata_conflict".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let (kind, label) = match class {
|
||||
ItemClass::Consumable => match consumable_family(subtype) {
|
||||
Some((_family, label)) => (ContentKind::Consumable, label),
|
||||
@@ -647,15 +698,36 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
|
||||
continue;
|
||||
}
|
||||
},
|
||||
_ => unreachable!("only Consumable/Staff were grouped"),
|
||||
ItemClass::Kit if subtype == 9 => {
|
||||
if card_asset_id != Some(35) || team_id.is_none() {
|
||||
plan.deferred.push(DeferredNonPlayer {
|
||||
resource_id,
|
||||
subtype: Some(subtype),
|
||||
wire_ids,
|
||||
reason: "missing_kit_render_metadata".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
(ContentKind::Kit, "Kit")
|
||||
}
|
||||
_ => unreachable!("only Consumable/Staff/Kit were grouped"),
|
||||
};
|
||||
|
||||
plan.supported.push(NonPlayerDefinition {
|
||||
card_id: format!("fifa17_{resource_id}"),
|
||||
resource_id,
|
||||
asset_id: items[0].asset_id,
|
||||
asset_id: if class == ItemClass::Kit {
|
||||
Some(resource_id)
|
||||
} else {
|
||||
items[0].asset_id
|
||||
},
|
||||
kind,
|
||||
subtype,
|
||||
card_asset_id,
|
||||
team_id,
|
||||
amount,
|
||||
contract,
|
||||
rating,
|
||||
name: label.to_string(),
|
||||
wire_ids,
|
||||
});
|
||||
@@ -670,6 +742,11 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
|
||||
.iter()
|
||||
.filter(|d| d.kind == ContentKind::Staff)
|
||||
.count();
|
||||
plan.kits = plan
|
||||
.supported
|
||||
.iter()
|
||||
.filter(|definition| definition.kind == ContentKind::Kit)
|
||||
.count();
|
||||
plan
|
||||
}
|
||||
|
||||
@@ -815,10 +892,11 @@ impl Report {
|
||||
let mut b = Vec::new();
|
||||
if !self.counts.balances() {
|
||||
b.push(format!(
|
||||
"item-type accounting does not balance ({}+{}+{}+{} != {})",
|
||||
"item-type accounting does not balance ({}+{}+{}+{}+{} != {})",
|
||||
self.counts.player_cards,
|
||||
self.counts.consumables,
|
||||
self.counts.staff,
|
||||
self.counts.kits,
|
||||
self.counts.other,
|
||||
self.counts.total
|
||||
));
|
||||
@@ -911,11 +989,12 @@ impl std::fmt::Display for Report {
|
||||
)?;
|
||||
writeln!(
|
||||
f,
|
||||
"\nSOURCE ITEMS total={} player_cards={} consumables={} staff={} other={} balances={}",
|
||||
"\nSOURCE ITEMS total={} player_cards={} consumables={} staff={} kits={} other={} balances={}",
|
||||
self.counts.total,
|
||||
self.counts.player_cards,
|
||||
self.counts.consumables,
|
||||
self.counts.staff,
|
||||
self.counts.kits,
|
||||
self.counts.other,
|
||||
self.counts.balances()
|
||||
)?;
|
||||
@@ -976,10 +1055,11 @@ impl std::fmt::Display for Report {
|
||||
}
|
||||
writeln!(
|
||||
f,
|
||||
"\nNON-PLAYER CONTENT (consumable/staff) supported={} (consumables={} staff={}) deferred_groups={} deferred_instances={}",
|
||||
"\nNON-PLAYER CONTENT (consumable/staff/kit) supported={} (consumables={} staff={} kits={}) deferred_groups={} deferred_instances={}",
|
||||
np.supported.len(),
|
||||
np.consumables,
|
||||
np.staff,
|
||||
np.kits,
|
||||
np.deferred.len(),
|
||||
np.deferred_instances()
|
||||
)?;
|
||||
@@ -1130,6 +1210,16 @@ pub fn emit_content(
|
||||
cards.insert(
|
||||
d.card_id.clone(),
|
||||
serde_json::json!({
|
||||
"card_asset_id": d.card_asset_id,
|
||||
"team_id": d.team_id,
|
||||
// A consumable is unrenderable without these: the adapter refuses
|
||||
// to emit a card whose art id it does not know, and the families
|
||||
// that read `amount`/`contract` draw "-1" or grant nothing when
|
||||
// the field is missing. Omitting them here is what kept every
|
||||
// owned consumable off the wire.
|
||||
"amount": d.amount,
|
||||
"contract": d.contract,
|
||||
"rating": d.rating,
|
||||
"asset_id": d.asset_id.unwrap_or(d.resource_id),
|
||||
"version": 0,
|
||||
"rareflag": 0,
|
||||
@@ -1195,6 +1285,8 @@ pub fn emit_content(
|
||||
.iter()
|
||||
.map(|d| {
|
||||
serde_json::json!({
|
||||
"card_asset_id": d.card_asset_id,
|
||||
"team_id": d.team_id,
|
||||
"card_id": d.card_id,
|
||||
"resource_id": d.resource_id,
|
||||
"asset_id": d.asset_id,
|
||||
|
||||
@@ -70,12 +70,17 @@ pub struct Item {
|
||||
/// Consumable ART id (small id), distinct from `resourceId`. Permissive.
|
||||
#[serde(default)]
|
||||
pub cardassetid: Option<i64>,
|
||||
/// Consumable stack size (`amount`). Permissive.
|
||||
/// Consumable effect magnitude (`amount`) — NOT a stack size: every owned
|
||||
/// copy is its own instance and carries its definition's value, which is
|
||||
/// exactly the `fcc_*` table's `amount` column. Permissive.
|
||||
#[serde(default)]
|
||||
pub amount: Option<i64>,
|
||||
/// Staff/contract `contract` count. Permissive.
|
||||
#[serde(default)]
|
||||
pub contract: Option<i64>,
|
||||
/// Owned-item lifecycle state (`activeHomeKit` / `activeAwayKit` for kits).
|
||||
#[serde(rename = "itemState", default)]
|
||||
pub item_state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
||||
@@ -72,11 +72,21 @@ fn classification_balances_across_disjoint_classes() {
|
||||
player(100000002, VER5_176580, 176580, 92),
|
||||
r#"{"id":100000239,"resourceId":5003012,"assetId":5003012,"itemType":"player","rating":85}"#.to_string(),
|
||||
r#"{"id":100000427,"resourceId":3000083,"itemType":"staff"}"#.to_string(),
|
||||
r#"{"id":100000500,"resourceId":6300006,"assetId":6300006,
|
||||
"cardsubtypeid":9,"cardassetid":35,"teamid":21,"itemState":"activeHomeKit"}"#
|
||||
.to_string(),
|
||||
];
|
||||
let c = count_items(&profile(&items, "[]", 100000500));
|
||||
let c = count_items(&profile(&items, "[]", 100000501));
|
||||
assert_eq!(
|
||||
(c.total, c.player_cards, c.consumables, c.staff, c.other),
|
||||
(4, 2, 1, 1, 0)
|
||||
(
|
||||
c.total,
|
||||
c.player_cards,
|
||||
c.consumables,
|
||||
c.staff,
|
||||
c.kits,
|
||||
c.other
|
||||
),
|
||||
(5, 2, 1, 1, 1, 0)
|
||||
);
|
||||
assert!(c.balances());
|
||||
}
|
||||
@@ -649,6 +659,14 @@ fn staff(id: i64, resource: i64, subtype: i64) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn kit(id: i64, resource: i64, team_id: i64, item_state: &str) -> String {
|
||||
format!(
|
||||
r#"{{"id":{id},"resourceId":{resource},"assetId":{resource},
|
||||
"cardsubtypeid":9,"cardassetid":35,"teamid":{team_id},
|
||||
"itemState":"{item_state}","owners":1,"untradeable":false}}"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_non_player_supports_seventeen_consumables_and_three_staff() {
|
||||
// The exact record set from the ticket: distinct resourceIds, so each is its
|
||||
@@ -676,6 +694,7 @@ fn plan_non_player_supports_seventeen_consumables_and_three_staff() {
|
||||
);
|
||||
assert_eq!(plan.consumables, 17);
|
||||
assert_eq!(plan.staff, 3);
|
||||
assert_eq!(plan.kits, 0);
|
||||
assert!(plan.deferred.is_empty(), "0 deferred: {:?}", plan.deferred);
|
||||
|
||||
// Honest labels + kinds resolve from the taxonomy (spot checks).
|
||||
@@ -695,6 +714,69 @@ fn plan_non_player_supports_seventeen_consumables_and_three_staff() {
|
||||
assert_eq!(by_id("fifa17_3000003").name, "GK Coach");
|
||||
}
|
||||
|
||||
/// A consumable the adapter cannot render is a consumable the club cannot use:
|
||||
/// the shaper drops any card whose art id it does not know, and the families
|
||||
/// that read `amount`/`contract` would draw "-1" or grant nothing. Emitting the
|
||||
/// definition without these fields is exactly what kept all 17 owned
|
||||
/// consumables off the wire while club/stats still counted them.
|
||||
#[test]
|
||||
fn consumable_definitions_carry_the_fields_the_client_renders() {
|
||||
let items = vec![
|
||||
// training +15: carries `amount`, no `contract`
|
||||
r#"{"id":100000201,"resourceId":5003012,"assetId":5003012,"itemType":"player",
|
||||
"cardsubtypeid":54,"cardassetid":3,"amount":15,"rating":85,"rareflag":0}"#
|
||||
.to_string(),
|
||||
// player contract: carries `contract`, no `amount`
|
||||
r#"{"id":100000202,"resourceId":5001004,"assetId":5001004,"itemType":"player",
|
||||
"cardsubtypeid":201,"cardassetid":7,"contract":7,"rating":60,"rareflag":0}"#
|
||||
.to_string(),
|
||||
];
|
||||
let plan = plan_non_player_definitions(&profile(&items, "[]", 100000500));
|
||||
assert!(plan.deferred.is_empty(), "{:?}", plan.deferred);
|
||||
let by_id = |cid: &str| plan.supported.iter().find(|d| d.card_id == cid).unwrap();
|
||||
|
||||
let training = by_id("fifa17_5003012");
|
||||
assert_eq!(training.card_asset_id, Some(3), "card art id");
|
||||
assert_eq!(training.amount, Some(15), "effect magnitude");
|
||||
assert_eq!(training.contract, None);
|
||||
assert_eq!(training.rating, Some(85));
|
||||
|
||||
let contract = by_id("fifa17_5001004");
|
||||
assert_eq!(contract.card_asset_id, Some(7));
|
||||
assert_eq!(contract.contract, Some(7));
|
||||
assert_eq!(contract.amount, None, "contract families ignore amount");
|
||||
}
|
||||
|
||||
/// `amount` is definition-level (every owned copy of a card carries the same
|
||||
/// value), so two copies that DISAGREE mean the field is really per-instance.
|
||||
/// Taking the first copy's value would silently bake a guess into the catalog.
|
||||
#[test]
|
||||
fn disagreeing_render_metadata_defers_rather_than_guessing() {
|
||||
let items = vec![
|
||||
r#"{"id":100000201,"resourceId":5003012,"assetId":5003012,"itemType":"player",
|
||||
"cardsubtypeid":54,"cardassetid":3,"amount":15,"rareflag":0}"#
|
||||
.to_string(),
|
||||
r#"{"id":100000202,"resourceId":5003012,"assetId":5003012,"itemType":"player",
|
||||
"cardsubtypeid":54,"cardassetid":3,"amount":10,"rareflag":0}"#
|
||||
.to_string(),
|
||||
];
|
||||
let plan = plan_non_player_definitions(&profile(&items, "[]", 100000500));
|
||||
assert!(plan.supported.is_empty(), "must not pick a winner");
|
||||
assert_eq!(plan.deferred.len(), 1);
|
||||
assert_eq!(plan.deferred[0].reason, "render_metadata_conflict");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kit_missing_render_metadata_defers() {
|
||||
let item = r#"{"id":100000501,"resourceId":6300006,"assetId":6300006,
|
||||
"cardsubtypeid":9,"itemState":"activeHomeKit"}"#
|
||||
.to_string();
|
||||
let plan = plan_non_player_definitions(&profile(&[item], "[]", 100000600));
|
||||
assert!(plan.supported.is_empty());
|
||||
assert_eq!(plan.deferred.len(), 1);
|
||||
assert_eq!(plan.deferred[0].reason, "missing_kit_render_metadata");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_subtype_consumable_defers_never_fabricated() {
|
||||
let plan = plan_non_player_definitions(&profile(
|
||||
@@ -763,6 +845,7 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() {
|
||||
player(100000001, 20801, 20801, 94),
|
||||
consumable(100000201, 5003012, 201), // Player Contract
|
||||
staff(100000427, 3000083, 8), // Fitness Coach
|
||||
kit(100000500, 6300006, 21, "activeHomeKit"),
|
||||
];
|
||||
let rep = analyze(
|
||||
&profile(&items, "[]", 100000500),
|
||||
@@ -771,16 +854,16 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() {
|
||||
&none(),
|
||||
);
|
||||
assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers());
|
||||
assert_eq!(rep.non_player.supported.len(), 2);
|
||||
assert_eq!(rep.non_player.supported.len(), 3);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sum = emit_content(&rep, dir.path(), "fp").unwrap();
|
||||
assert_eq!(sum.definitions, 1, "one player definition");
|
||||
assert_eq!(sum.non_player_definitions, 2);
|
||||
assert_eq!(sum.non_player_instances, 2);
|
||||
assert_eq!(sum.non_player_definitions, 3);
|
||||
assert_eq!(sum.non_player_instances, 3);
|
||||
assert_eq!(
|
||||
sum.catalog_entries, 3,
|
||||
"player + 2 non-player catalog entries"
|
||||
sum.catalog_entries, 4,
|
||||
"player + 3 non-player catalog entries"
|
||||
);
|
||||
|
||||
// Content pack: neutral non-player CardDefinition with honest name.
|
||||
@@ -807,21 +890,29 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() {
|
||||
assert_eq!(cat["cards"]["fifa17_3000083"]["kind"], "staff");
|
||||
assert_eq!(cat["cards"]["fifa17_3000083"]["subtype"], 8);
|
||||
assert_eq!(cat["cards"]["fifa17_3000083"]["asset_id"], 3000083);
|
||||
assert_eq!(cat["cards"]["fifa17_6300006"]["kind"], "kit");
|
||||
assert_eq!(cat["cards"]["fifa17_6300006"]["subtype"], 9);
|
||||
assert_eq!(cat["cards"]["fifa17_6300006"]["card_asset_id"], 35);
|
||||
assert_eq!(cat["cards"]["fifa17_6300006"]["team_id"], 21);
|
||||
|
||||
let loaded = Fifa17CardCatalog::from_file(&sum.host_catalog).unwrap();
|
||||
assert_eq!(loaded.kind_of("fifa17_20801"), ContentKind::Player);
|
||||
assert_eq!(loaded.kind_of("fifa17_5003012"), ContentKind::Consumable);
|
||||
assert_eq!(loaded.subtype_of("fifa17_5003012"), 201);
|
||||
assert_eq!(loaded.kind_of("fifa17_3000083"), ContentKind::Staff);
|
||||
assert_eq!(loaded.kind_of("fifa17_6300006"), ContentKind::Kit);
|
||||
let loaded_kit = loaded.lookup("fifa17_6300006").unwrap();
|
||||
assert_eq!(loaded_kit.card_asset_id, 35);
|
||||
assert_eq!(loaded_kit.team_id, 21);
|
||||
|
||||
// Manifest: private non_player section with preserved wire ids.
|
||||
let man: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&sum.manifest).unwrap()).unwrap();
|
||||
assert_eq!(man["non_player"]["supported_instances"], 2);
|
||||
assert_eq!(man["non_player"]["supported_instances"], 3);
|
||||
let np = man["non_player"]["supported_definitions"]
|
||||
.as_array()
|
||||
.unwrap();
|
||||
assert_eq!(np.len(), 2);
|
||||
assert_eq!(np.len(), 3);
|
||||
let cons_man = np
|
||||
.iter()
|
||||
.find(|d| d["card_id"] == "fifa17_5003012")
|
||||
@@ -836,13 +927,14 @@ fn plan_apply_mints_non_player_owned_instances() {
|
||||
player(100000001, 20801, 20801, 94),
|
||||
consumable(100000201, 5003012, 201),
|
||||
staff(100000427, 3000083, 8),
|
||||
kit(100000500, 6300006, 21, "activeHomeKit"),
|
||||
];
|
||||
let (report, raw) = report_and_raw(&items, "[]", 100000500);
|
||||
let plan = plan_apply(&report, &raw, "fp").unwrap();
|
||||
// 1 player + 2 non-player owned instances, minted via the identical path.
|
||||
assert_eq!(plan.request.owned.len(), 3);
|
||||
assert_eq!(plan.mappings.len(), 3);
|
||||
assert_eq!(plan.supported_instances, 3);
|
||||
// Player, consumable, staff, and kit instances mint through one generic path.
|
||||
assert_eq!(plan.request.owned.len(), 4);
|
||||
assert_eq!(plan.mappings.len(), 4);
|
||||
assert_eq!(plan.supported_instances, 4);
|
||||
assert_eq!(plan.deferred_instances, 0);
|
||||
let cards: BTreeSet<&str> = plan
|
||||
.request
|
||||
@@ -852,6 +944,31 @@ fn plan_apply_mints_non_player_owned_instances() {
|
||||
.collect();
|
||||
assert!(cards.contains("fifa17_5003012"), "consumable minted");
|
||||
assert!(cards.contains("fifa17_3000083"), "staff minted");
|
||||
assert!(cards.contains("fifa17_6300006"), "kit minted");
|
||||
// Core is the ownership authority, and it defaults an unstated row to
|
||||
// `player`. A coach or a contract card durably recorded as a player is wrong
|
||||
// in the authority even while the catalog-driven wire still looks right.
|
||||
let kind_of = |card: &str| {
|
||||
plan.request
|
||||
.owned
|
||||
.iter()
|
||||
.find(|o| o.card_id == card)
|
||||
.unwrap()
|
||||
.content_kind
|
||||
};
|
||||
assert_eq!(kind_of("fifa17_20801"), "player");
|
||||
assert_eq!(kind_of("fifa17_5003012"), "consumable");
|
||||
assert_eq!(kind_of("fifa17_3000083"), "staff");
|
||||
assert_eq!(kind_of("fifa17_6300006"), "kit");
|
||||
// `amount` is an effect magnitude, not a stack count: two copies of one
|
||||
// consumable are two rows, never one row of quantity 2. The importer states
|
||||
// no quantity at all, and Core's default for an absent quantity is "not a
|
||||
// stack" — so the guarantee is that the key never appears in the request.
|
||||
let wire = serde_json::to_string(&plan.request).unwrap();
|
||||
assert!(
|
||||
!wire.contains("quantity"),
|
||||
"no instance may claim to be a stack: {wire}"
|
||||
);
|
||||
// Deterministic OwnedItemId per (persona, wire) — same rule as players.
|
||||
let m = plan
|
||||
.mappings
|
||||
|
||||
+1
-1
Submodule openfut-launcher updated: 1cd4f18e92...5294f589ad
@@ -31,7 +31,9 @@ S2 live-staging defect where the v2 Store BUY escaped to Python.
|
||||
| Quick-sell (path) | `DELETE …/item/<digits>` | `QuickSellPath` |
|
||||
| Quick-sell (body) | `POST (/ut/delete/game\|/ut/v2/delete/game)/<sku>/item` | `QuickSellBody` |
|
||||
| Move | `PUT …/item` | `MoveItems` |
|
||||
| Match end | `POST (/ut/delete/game\|/ut/v2/delete/game)/<sku>/match` | `MatchEnd` |
|
||||
| Match create / play | `…/match` (any verb; `matchId` in body = FutPlayGame) | `MatchCreate` |
|
||||
| Match ready | `…/match/ready` (any verb) | `MatchReady` |
|
||||
| Match end | `…/match/end` (any verb) — also `POST (/ut/delete/game\|/ut/v2/delete/game)/<sku>/match` | `MatchEnd` |
|
||||
| Market list | `POST …/auctionhouse` \| `…/transfermarket` | `MarketList` |
|
||||
| Market query | `GET …/tradePile` **and** `…/tradePile/counts` (CASE-INSENSITIVE: `tradepile` too) | `MarketQuery` |
|
||||
| Market buy | `…/trade/<id>` | `MarketBuy` |
|
||||
@@ -76,7 +78,9 @@ per-route authority (all economy routes owner = Rust, Python proxy = NO):
|
||||
| `/item/<id>` (DELETE) | R | -/R | -/R | - | - | - | - | NO | `handle_quick_sell_path` → `sell_item` |
|
||||
| `/ut/delete/…/item` (POST) | R | -/R | -/R | - | - | - | - | NO | `handle_quick_sell_body` → `sell_item` |
|
||||
| `/item` (PUT) | R | - | R/- | - | -/R | - | - | NO | `handle_move_items` (PileStore) |
|
||||
| `/ut/delete/…/match` (POST) | R | -/R | - | - | - | - | - | NO | `handle_match_end` → `grant_reward` |
|
||||
| `/match` (any verb) | R | - | - | - | - | - | - | NO | `handle_match_create` (mints the session id; no economy) |
|
||||
| `/match/ready` (any verb) | R | - | - | - | - | - | - | NO | `handle_match_ready` |
|
||||
| `/match/end` (any verb) | R | -/R | - | - | - | - | - | NO | `handle_match_end` → Core `complete_match` |
|
||||
| `/auctionhouse`,`/transfermarket` | R | R/- | - | - | - | -/R | - | NO | `handle_market_list` (MarketStore) |
|
||||
| `/tradePile` (GET) | R | R/- | - | - | - | R/- | - | NO | `handle_market_query` |
|
||||
| `/trade/<id>` (POST/PUT/GET) | R | R/R | -/R | - | - | R/R | - | NO | `handle_market_buy` → `purchase_item` |
|
||||
@@ -177,8 +181,11 @@ Landed (Core authority + transport + several handlers; classifier NOT yet flippe
|
||||
full-gen (`handle_purchasegroup`, no Python body dependency), `userMassInfo`
|
||||
economy overlay (`overlay_massinfo_economy`). Invariant test: all three read one
|
||||
Core state.
|
||||
- **Writer handler**: `/match` reward (`handle_match_end` → Core `grant_reward`,
|
||||
oracle `destroy_match_body` shape).
|
||||
- **Writer handler**: `/match/end` reward (`handle_match_end` → Core
|
||||
`complete_match`, the single exactly-once transaction — NOT `grant_reward`).
|
||||
The per-match identity is the id minted by `POST …/match`, which is what makes
|
||||
two abandoned matches (whose bodies are byte-identical) both payable while a
|
||||
replay of either is refused.
|
||||
- **Adapter policy mappers** (`181bd94`): match reward, pack price.
|
||||
- All Core-backed, fail-closed (503, never Python), `FakeEconomy`-tested.
|
||||
- **Store/item writers** (`4d2b8b9`, `economy_store.rs`, unrouted): `handle_store_buy`
|
||||
|
||||
@@ -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": [] })),
|
||||
@@ -457,7 +464,10 @@ mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicI64, AtomicU32, Ordering};
|
||||
|
||||
use crate::{EconomyEntitlement, EconomyPurchase, EconomySale, EconomySaleReceipt};
|
||||
use crate::{
|
||||
CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyPurchase, EconomySale,
|
||||
EconomySaleReceipt,
|
||||
};
|
||||
|
||||
// ── Recording economy double ────────────────────────────────────────────
|
||||
|
||||
@@ -592,6 +602,13 @@ mod tests {
|
||||
// Sale settlement is not exercised by the Store/quick-sell paths.
|
||||
Err(CoreError::Status(501))
|
||||
}
|
||||
fn complete_match(
|
||||
&self,
|
||||
_m: &CoreMatchCompletion<'_>,
|
||||
) -> Result<CoreMatchReceipt, CoreError> {
|
||||
// Match completion is not exercised by the Store/quick-sell paths.
|
||||
Err(CoreError::Status(501))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Identity / entity / lookup doubles ──────────────────────────────────
|
||||
@@ -695,7 +712,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 +722,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 +746,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 +877,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 +903,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"]);
|
||||
|
||||
+2284
-177
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@ use serde_json::{json, Value};
|
||||
|
||||
use openfut_adapter_fifa17::fut::entities::ReverseEntityResolver;
|
||||
use openfut_adapter_fifa17::fut::item::{shape_item, ItemIdentityResolver};
|
||||
use openfut_adapter_fifa17::fut::item_state;
|
||||
use openfut_adapter_fifa17::fut::non_economy;
|
||||
use openfut_adapter_fifa17::fut::squad::SquadWireResolver;
|
||||
|
||||
@@ -36,7 +37,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 +48,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +82,7 @@ fn trade_id_from_path(path: &str) -> Option<String> {
|
||||
/// snapshot persisted at listing time; a row written before snapshots existed
|
||||
/// degrades to the stub (honest, not fabricated).
|
||||
///
|
||||
/// `item_state` overrides the card's `itemState`. FIFA 17's vocabulary is the
|
||||
/// `state` overrides the card's `itemState`. FIFA 17's vocabulary is the
|
||||
/// 12-row `{const char*, int}` table at `0x180229cc0`, and `forSale` (5) is its
|
||||
/// value for an item offered for sale. The Python oracle stamps `listFS` on the
|
||||
/// seller's own pile instead — a token that does NOT EXIST in FIFA 17 (zero
|
||||
@@ -88,8 +90,8 @@ fn trade_id_from_path(path: &str) -> Option<String> {
|
||||
/// memory) and therefore decodes to `-1` through `FUN_180166660`, i.e. the client
|
||||
/// is handed an unrecognised `CARD_OFFERSTATE`. Where the binary contradicts the
|
||||
/// oracle, the binary wins.
|
||||
fn auction_record_as(l: &Listing, item_state: &str) -> Value {
|
||||
auction_record_tuned(l, item_state, None, 0)
|
||||
fn auction_record_as(l: &Listing, state: &str) -> Value {
|
||||
auction_record_tuned(l, state, None, 0)
|
||||
}
|
||||
|
||||
/// [`auction_record_as`] with the two fields the staging sold experiment varies.
|
||||
@@ -104,7 +106,7 @@ fn auction_record_as(l: &Listing, item_state: &str) -> Value {
|
||||
/// what makes the client's reaction attributable to the token.
|
||||
fn auction_record_tuned(
|
||||
l: &Listing,
|
||||
item_state: &str,
|
||||
state: &str,
|
||||
sold_bid_state: Option<&str>,
|
||||
coins_processed: i64,
|
||||
) -> Value {
|
||||
@@ -151,7 +153,7 @@ fn auction_record_tuned(
|
||||
.map(|mut card| {
|
||||
// Keep the wire identity and presentation state authoritative here.
|
||||
card["id"] = json!(item_id);
|
||||
card["itemState"] = json!(item_state);
|
||||
card["itemState"] = json!(state);
|
||||
card["untradeable"] = json!(false);
|
||||
card
|
||||
})
|
||||
@@ -159,7 +161,7 @@ fn auction_record_tuned(
|
||||
json!({
|
||||
"id": item_id,
|
||||
"resourceId": resource,
|
||||
"itemState": item_state,
|
||||
"itemState": state,
|
||||
"untradeable": false,
|
||||
})
|
||||
});
|
||||
@@ -203,9 +205,9 @@ fn auction_record_tuned(
|
||||
/// closed/sold echoes the buy path returns.
|
||||
fn auction_record(l: &Listing) -> Value {
|
||||
let state = if l.state == "active" {
|
||||
"forSale"
|
||||
item_state::FOR_SALE
|
||||
} else {
|
||||
"free"
|
||||
item_state::FREE
|
||||
};
|
||||
auction_record_as(l, state)
|
||||
}
|
||||
@@ -443,7 +445,7 @@ pub async fn handle_market_query(
|
||||
};
|
||||
let mut auctions: Vec<Value> = listings
|
||||
.iter()
|
||||
.map(|l| auction_record_as(l, "forSale"))
|
||||
.map(|l| auction_record_as(l, item_state::FOR_SALE))
|
||||
.collect();
|
||||
// STAGING ONLY. FIFA 17's bulk `DELETE …/trade/sold` verb only makes sense if
|
||||
// sold rows persist in the seller's pile until acknowledged, so the experiment
|
||||
@@ -455,7 +457,7 @@ pub async fn handle_market_query(
|
||||
for l in &sold {
|
||||
auctions.push(auction_record_tuned(
|
||||
l,
|
||||
"forSale",
|
||||
item_state::FOR_SALE,
|
||||
exp.bid_state,
|
||||
exp.coins_processed,
|
||||
));
|
||||
@@ -606,7 +608,7 @@ pub async fn handle_market_status(
|
||||
};
|
||||
let auctions: Vec<Value> = listings
|
||||
.iter()
|
||||
.map(|l| auction_record_tuned(l, "forSale", exp.bid_state, exp.coins_processed))
|
||||
.map(|l| auction_record_tuned(l, item_state::FOR_SALE, exp.bid_state, exp.coins_processed))
|
||||
.collect();
|
||||
eprintln!(
|
||||
"utas-host owner=RUST route=market-status requested={} returned={} query={}",
|
||||
@@ -722,7 +724,7 @@ pub async fn handle_market_buy(
|
||||
rec["tradeState"] = json!("closed");
|
||||
rec["bidState"] = json!("highest");
|
||||
rec["currentBid"] = json!(price);
|
||||
rec["itemData"]["itemState"] = json!("free");
|
||||
rec["itemData"]["itemState"] = json!(item_state::FREE);
|
||||
ok_json(&json!({ "auctionInfo": [rec], "credits": new_balance }))
|
||||
}
|
||||
// Insufficient funds surfaced by Core (concurrent debit) -> 461.
|
||||
@@ -804,7 +806,8 @@ pub async fn handle_move_items(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt,
|
||||
CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyGrantItem,
|
||||
EconomyPurchase, EconomySale, EconomySaleReceipt,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering};
|
||||
@@ -958,6 +961,13 @@ mod tests {
|
||||
squad_slots_freed: 0,
|
||||
})
|
||||
}
|
||||
fn complete_match(
|
||||
&self,
|
||||
_m: &CoreMatchCompletion<'_>,
|
||||
) -> Result<CoreMatchReceipt, CoreError> {
|
||||
// Match completion is not exercised through the market double.
|
||||
Err(CoreError::Status(501))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- SquadWireResolver double -----------------------------------------
|
||||
@@ -1146,6 +1156,10 @@ mod tests {
|
||||
let pile = handle_market_query("active", &econ, &store, SoldExperiment::OFF).await;
|
||||
let rec = parse(&pile)["auctionInfo"][0].clone();
|
||||
assert_eq!(rec["itemData"]["itemState"], "forSale");
|
||||
assert!(
|
||||
item_state::is_recovered(rec["itemData"]["itemState"].as_str().unwrap()),
|
||||
"every emitted itemState must be in FIFA 17's own 12-row table"
|
||||
);
|
||||
assert_eq!(rec["itemData"]["rating"], 84);
|
||||
assert_eq!(rec["itemData"]["id"], 100004617i64);
|
||||
assert_eq!(rec["itemData"]["resourceId"], 169193);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -436,7 +436,9 @@ fn case_d_two_market_buyers(h: &Harness) -> String {
|
||||
"POST",
|
||||
"/ut/game/fifa17/auctionhouse",
|
||||
&[],
|
||||
format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#)
|
||||
format!(
|
||||
r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#
|
||||
)
|
||||
.as_bytes(),
|
||||
None,
|
||||
)
|
||||
@@ -484,20 +486,39 @@ fn case_d_two_market_buyers(h: &Harness) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// E: match REWARD + Store BUY concurrently → final balance is one legal
|
||||
/// serialization (no lost update). Reward (+400) and buy (−400) commute, so the
|
||||
/// final balance must equal the start exactly.
|
||||
/// E: match REWARD + Store BUY concurrently → one legal serialization (no lost
|
||||
/// update). The two commute, so the final balance must equal exactly one serial
|
||||
/// outcome: the match's reported post-credit balance (`allCoins`), or that minus
|
||||
/// the buy's debit — never a torn value from a clobbered write. Amount-agnostic,
|
||||
/// so it holds even when a WIN also triggers an XP level-up bonus.
|
||||
fn case_e_reward_and_buy(h: &Harness) -> String {
|
||||
// Measure the store BUY's deterministic debit once.
|
||||
set_balance(&h.client, 50_000);
|
||||
let pre_probe = h.client.balance().unwrap();
|
||||
let probe = fire(
|
||||
&h.server,
|
||||
vec![(
|
||||
"PUT",
|
||||
"/ut/game/fifa17/store/transaction".into(),
|
||||
b"{\"packId\":1}".to_vec(),
|
||||
)],
|
||||
);
|
||||
assert_eq!(probe[0].status, 200, "probe buy ok");
|
||||
let buy_debit = pre_probe - h.client.balance().unwrap();
|
||||
assert!(buy_debit > 0, "store buy must debit a positive price");
|
||||
|
||||
let start = 8_000i64;
|
||||
for _ in 0..ITERS {
|
||||
for i in 0..ITERS {
|
||||
set_balance(&h.client, start);
|
||||
// A DISTINCT match per iteration (unique matchReportId) so the
|
||||
// exactly-once reward applies every time.
|
||||
let rs = fire(
|
||||
&h.server,
|
||||
vec![
|
||||
(
|
||||
"POST",
|
||||
"/ut/delete/game/fifa17/match".into(),
|
||||
b"{\"endReason\":\"WIN\"}".to_vec(),
|
||||
format!("{{\"matchReportId\":{i},\"endReason\":\"WIN\"}}").into_bytes(),
|
||||
),
|
||||
(
|
||||
"PUT",
|
||||
@@ -507,13 +528,15 @@ fn case_e_reward_and_buy(h: &Harness) -> String {
|
||||
],
|
||||
);
|
||||
assert!(rs.iter().all(|r| r.status == 200), "both ops succeed");
|
||||
assert_eq!(
|
||||
h.client.balance().unwrap(),
|
||||
start,
|
||||
"reward(+400) and buy(-400) both applied: no lost update"
|
||||
let all = bj(&rs[0])["allCoins"].as_i64().expect("allCoins");
|
||||
let after = h.client.balance().unwrap();
|
||||
// Both writers serialized: `after` is one of the two legal orderings.
|
||||
assert!(
|
||||
after == all || after == all - buy_debit,
|
||||
"no lost update: after={after}, match allCoins={all}, buy_debit={buy_debit}"
|
||||
);
|
||||
}
|
||||
format!("E reward+buy: {ITERS} iters, final==start ({start}) every time (no lost update)")
|
||||
format!("E reward+buy: {ITERS} iters, no lost update (buy_debit={buy_debit})")
|
||||
}
|
||||
|
||||
/// F: MOVE + QUICK-SELL of the same item → one coherent final state (item sold
|
||||
|
||||
@@ -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,61 @@ 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(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Coins Core has granted through ACHIEVEMENT unlocks so far, summed from Core's
|
||||
/// own report.
|
||||
///
|
||||
/// Core's progression system (objectives + achievements) has no counterpart in
|
||||
/// the Python oracle, and `complete_match` unlocks achievements in the SAME
|
||||
/// transaction that pays the match reward. So a raw balance delta around a match
|
||||
/// is `match reward + newly unlocked achievements`, and the differential has to
|
||||
/// account for the second term instead of pretending it does not exist.
|
||||
///
|
||||
/// NOTE: `GET /achievements` is unlock-on-read (it calls `check_and_unlock`), so
|
||||
/// this snapshot must be taken BEFORE the pre-op balance is captured; that flushes
|
||||
/// any already-satisfied unlock and leaves the measured window attributable to the
|
||||
/// op under test.
|
||||
fn achievement_coins_granted(http: &reqwest::blocking::Client, core_base: &str) -> i64 {
|
||||
let body: Value = http
|
||||
.get(format!("{core_base}/achievements"))
|
||||
.header("X-OpenFUT-Game", "fifa17")
|
||||
.send()
|
||||
.expect("GET /achievements")
|
||||
.json()
|
||||
.expect("achievements json");
|
||||
body["achievements"]
|
||||
.as_array()
|
||||
.expect("achievements array")
|
||||
.iter()
|
||||
.filter(|a| a["unlocked"].as_bool().unwrap_or(false))
|
||||
.map(|a| a["reward_coins"].as_i64().unwrap_or(0))
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// 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 +580,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() {
|
||||
}
|
||||
// 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, "packType parity for pack {id}");
|
||||
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", "PARITY"));
|
||||
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 +644,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 +674,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();
|
||||
@@ -784,6 +862,11 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
matrix.push(("move-items PUT /item", "PARITY"));
|
||||
|
||||
// ── OP 10: match reward (WIN = +400) ───────────────────────────────────
|
||||
// The oracle pays the match reward and nothing else. Core pays the same match
|
||||
// reward and, in the same transaction, any achievement the match unlocks — a
|
||||
// deliberate Rust-authority feature the oracle never had. Flush pending
|
||||
// unlocks first so the measured window belongs to this match.
|
||||
let r_ach_before = achievement_coins_granted(&http, core_base);
|
||||
let o_mbal = oracle.coins();
|
||||
let (o_mms, o_mm) = oracle.req(
|
||||
"POST",
|
||||
@@ -841,7 +924,15 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
);
|
||||
}
|
||||
assert_eq!(oracle.coins() - o_mbal, 400, "oracle WIN +400");
|
||||
assert_eq!(client.balance().unwrap() - r_mbal, 400, "rust WIN +400");
|
||||
// Same match reward on both sides; Core additionally credits exactly the
|
||||
// achievements this match unlocked, and nothing unexplained.
|
||||
let r_ach_awarded = achievement_coins_granted(&http, core_base) - r_ach_before;
|
||||
assert_eq!(
|
||||
client.balance().unwrap() - r_mbal,
|
||||
400 + r_ach_awarded,
|
||||
"rust WIN credits +400 plus exactly the achievements it unlocked \
|
||||
(achievement coins = {r_ach_awarded})"
|
||||
);
|
||||
matrix.push(("match reward (WIN)", "PARITY"));
|
||||
|
||||
// ── OP 11: market list (POST /auctionhouse) ────────────────────────────
|
||||
@@ -1153,8 +1244,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 +1256,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 +1308,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 +1331,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 +1568,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");
|
||||
}
|
||||
|
||||
@@ -30,9 +30,9 @@ use openfut_utas_host::async_bridge::AsyncBridge;
|
||||
use openfut_utas_host::market_store::MarketStore;
|
||||
use openfut_utas_host::pile_store::PileStore;
|
||||
use openfut_utas_host::{
|
||||
build_content_pool, CoreAccess, CoreEconomy, CoreError, EconomyEntitlement, EconomyGrantItem,
|
||||
EconomyPurchase, EconomySale, EconomySaleReceipt, EconomyServices, Fifa17IdentityResolver,
|
||||
HttpCoreClient, PassClient, Server, WireResponse,
|
||||
build_content_pool, CoreAccess, CoreEconomy, CoreError, CoreMatchCompletion, CoreMatchReceipt,
|
||||
EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt,
|
||||
EconomyServices, Fifa17IdentityResolver, HttpCoreClient, PassClient, Server, WireResponse,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::Value;
|
||||
@@ -140,6 +140,12 @@ impl CoreEconomy for FaultEconomy {
|
||||
}
|
||||
self.inner.settle_sale(sale)
|
||||
}
|
||||
fn complete_match(&self, m: &CoreMatchCompletion<'_>) -> Result<CoreMatchReceipt, CoreError> {
|
||||
if self.trip("complete_match") {
|
||||
return Err(Self::injected());
|
||||
}
|
||||
self.inner.complete_match(m)
|
||||
}
|
||||
}
|
||||
|
||||
/// An `ExternalIdentityStore` that forwards to a real `JsonIdentityStore` but can
|
||||
|
||||
@@ -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;
|
||||
@@ -140,8 +140,8 @@ fn pack_ids(pg: &Value) -> Vec<u64> {
|
||||
}
|
||||
|
||||
/// Seed via the real Core HTTP API, then exercise the host handlers + transport.
|
||||
/// Returns nothing; panics on any mismatch.
|
||||
fn seed_and_exercise(base: &str) {
|
||||
/// Returns the final Core balance so the restart phase can assert persistence.
|
||||
fn seed_and_exercise(base: &str) -> i64 {
|
||||
wait_ready(base);
|
||||
let http = reqwest::blocking::Client::new();
|
||||
|
||||
@@ -155,12 +155,43 @@ fn seed_and_exercise(base: &str) {
|
||||
let credits: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap();
|
||||
assert_eq!(credits["currencies"][0]["funds"], 5000, "seeded balance");
|
||||
|
||||
// Match-reward WRITER: win credits +400 via Core grant_reward, end to end.
|
||||
let m = handle_match_end(&client, br#"{"endReason":"WIN"}"#);
|
||||
// Match-reward WRITER: a WIN applies its reward through Core's authoritative,
|
||||
// exactly-once complete_match transaction, end to end. The reward is at least
|
||||
// the match coins; Core may also grant XP-driven level-up and first-win
|
||||
// achievement coins, so assert the flat match coins + a relative delta.
|
||||
let before_match = client.balance().unwrap();
|
||||
let m = handle_match_end(&client, 1, Some(200_000_001), br#"{"endReason":"WIN"}"#);
|
||||
assert_eq!(m.status, 200);
|
||||
let mb: Value = serde_json::from_slice(&m.body).unwrap();
|
||||
assert_eq!(mb["allCoins"], 5400, "match reward credited in Core");
|
||||
assert_eq!(client.balance().unwrap(), 5400);
|
||||
assert_eq!(mb["matchCoins"], 400, "flat match coins");
|
||||
let after_match = client.balance().unwrap();
|
||||
assert!(
|
||||
after_match >= before_match + 400,
|
||||
"match credited at least +400"
|
||||
);
|
||||
assert_eq!(
|
||||
mb["allCoins"].as_i64().unwrap(),
|
||||
after_match,
|
||||
"response echoes the authoritative Core balance"
|
||||
);
|
||||
// Idempotent replay: the SAME match session does NOT double-credit.
|
||||
let replay = handle_match_end(&client, 1, Some(200_000_001), br#"{"endReason":"WIN"}"#);
|
||||
assert_eq!(replay.status, 200);
|
||||
assert_eq!(
|
||||
client.balance().unwrap(),
|
||||
after_match,
|
||||
"replay must not re-credit"
|
||||
);
|
||||
// A DIFFERENT session with a BYTE-IDENTICAL body is a different match and
|
||||
// must credit again. Keyed on the body alone it would not, which is the
|
||||
// silent under-credit every abandoned match would have hit.
|
||||
let second = handle_match_end(&client, 1, Some(200_000_002), br#"{"endReason":"WIN"}"#);
|
||||
assert_eq!(second.status, 200);
|
||||
let after_second = client.balance().unwrap();
|
||||
assert!(
|
||||
after_second >= after_match + 400,
|
||||
"a second, distinct match must credit: {after_second} vs {after_match}"
|
||||
);
|
||||
|
||||
// Buy a numeric entitlement "70" through the Core economy API (debit 600).
|
||||
post(
|
||||
@@ -169,11 +200,16 @@ fn seed_and_exercise(base: &str) {
|
||||
"/economy/purchase-entitlement",
|
||||
json!({ "cost": 600, "definition_id": "70" }),
|
||||
);
|
||||
assert_eq!(client.balance().unwrap(), 4800, "debit applied atomically");
|
||||
let after_buy = after_second - 600;
|
||||
assert_eq!(
|
||||
client.balance().unwrap(),
|
||||
after_buy,
|
||||
"debit applied atomically"
|
||||
);
|
||||
|
||||
// credits reflects the debit through the same Core state.
|
||||
let credits2: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap();
|
||||
assert_eq!(credits2["currencies"][0]["funds"], 4800);
|
||||
assert_eq!(credits2["currencies"][0]["funds"], after_buy);
|
||||
|
||||
// purchasegroup full-gen shows the owned pack 70 and NO sentinel.
|
||||
let pg: Value =
|
||||
@@ -194,25 +230,27 @@ fn seed_and_exercise(base: &str) {
|
||||
client.balance().unwrap(),
|
||||
client.entitlements().unwrap().len(),
|
||||
);
|
||||
assert_eq!(mass["userInfo"]["currencies"][0]["funds"], 4800);
|
||||
assert_eq!(mass["userInfo"]["currencies"][0]["funds"], after_buy);
|
||||
// Invariant: credits coins == userMassInfo coins == Core balance.
|
||||
assert_eq!(
|
||||
credits2["currencies"][0]["funds"],
|
||||
mass["userInfo"]["currencies"][0]["funds"]
|
||||
);
|
||||
|
||||
after_buy
|
||||
}
|
||||
|
||||
/// After a Core restart from the same DB file, all economy state persists.
|
||||
fn verify_after_restart(base: &str) {
|
||||
fn verify_after_restart(base: &str, expected_balance: i64) {
|
||||
wait_ready(base);
|
||||
let client = HttpCoreClient::new(base, "fifa17");
|
||||
assert_eq!(
|
||||
client.balance().unwrap(),
|
||||
4800,
|
||||
expected_balance,
|
||||
"coins persisted across restart"
|
||||
);
|
||||
let credits: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap();
|
||||
assert_eq!(credits["currencies"][0]["funds"], 4800);
|
||||
assert_eq!(credits["currencies"][0]["funds"], expected_balance);
|
||||
let pg: Value =
|
||||
serde_json::from_slice(&handle_purchasegroup(&client, StoreMode::Sentinel).body).unwrap();
|
||||
assert!(
|
||||
@@ -239,12 +277,12 @@ async fn economy_end_to_end_and_restart_persistence() {
|
||||
let b1 = base1.clone();
|
||||
let r = tokio::task::spawn_blocking(move || seed_and_exercise(&b1)).await;
|
||||
h1.abort();
|
||||
r.expect("exercise phase");
|
||||
let expected_balance = r.expect("exercise phase");
|
||||
|
||||
// --- Core instance #2: same on-disk DB, prove persistence ---
|
||||
let (h2, base2) = start_core(&db_url).await;
|
||||
let b2 = base2.clone();
|
||||
let r2 = tokio::task::spawn_blocking(move || verify_after_restart(&b2)).await;
|
||||
let r2 = tokio::task::spawn_blocking(move || verify_after_restart(&b2, expected_balance)).await;
|
||||
h2.abort();
|
||||
r2.expect("restart phase");
|
||||
|
||||
@@ -373,8 +411,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"
|
||||
@@ -422,7 +460,8 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
||||
"totalCredits == absolute Core balance"
|
||||
);
|
||||
|
||||
// 4) Match END reward through dispatch (WIN = +400).
|
||||
// 4) Match END reward through dispatch. The WIN credits at least the flat
|
||||
// match coins (Core may also add XP level-up / first-win achievement coins).
|
||||
let before_match = client.balance().unwrap();
|
||||
let mm = server
|
||||
.try_handle_economy(
|
||||
@@ -434,10 +473,11 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
||||
)
|
||||
.expect("match routed");
|
||||
assert_eq!(mm.status, 200);
|
||||
assert_eq!(
|
||||
client.balance().unwrap(),
|
||||
before_match + 400,
|
||||
"WIN credited +400 via Core"
|
||||
let mmb: Value = serde_json::from_slice(&mm.body).unwrap();
|
||||
assert_eq!(mmb["matchCoins"], 400, "flat match coins");
|
||||
assert!(
|
||||
client.balance().unwrap() >= before_match + 400,
|
||||
"WIN credited at least +400 via Core"
|
||||
);
|
||||
|
||||
// 5) MARKET buy-now (async handlers via the bridge): list -> query -> buy ->
|
||||
@@ -629,6 +669,98 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
||||
.owned_id_for_wire(move_wire)
|
||||
.expect("moved item reverses to a Core id");
|
||||
|
||||
// 8) THE MATCH LIFECYCLE, through the REAL dispatch rather than the handler.
|
||||
// `POST …/match` and `PUT …/match/end` were not classified at all and were
|
||||
// proxied to Python, which the client reported as "There was an error
|
||||
// creating your game session".
|
||||
let created = server
|
||||
.try_handle_economy(
|
||||
"POST",
|
||||
"/ut/game/fifa17/match",
|
||||
&[],
|
||||
br#"{"squadId":0,"type":"OFFLINE","seasonId":1,"divisionId":10}"#,
|
||||
None,
|
||||
)
|
||||
.expect("match create must be claimed by the economy dispatch, never proxied");
|
||||
assert_eq!(created.status, 200);
|
||||
let created_body: Value = serde_json::from_slice(&created.body).unwrap();
|
||||
let match_id = created_body["id"].as_i64().expect("a match id is minted");
|
||||
assert!(match_id > 0, "the client needs a non-zero session id");
|
||||
assert_eq!(created_body["reportIdEnabled"], false);
|
||||
assert!(
|
||||
created_body.get("squad").is_none(),
|
||||
"`squad` is nested and a documented freeze risk — it must be omitted"
|
||||
);
|
||||
// The match id lives in its OWN identity scope: it must not reverse-map to
|
||||
// an owned card, or quick-sell and move would resolve a match as an item.
|
||||
assert!(
|
||||
resolver.owned_id_for_wire(match_id).is_none(),
|
||||
"a match id must never appear in the owned-item reverse map"
|
||||
);
|
||||
|
||||
// FutPlayGame reuses the create path, discriminated by an integer matchId.
|
||||
// It must ack without minting a second session.
|
||||
let play = server
|
||||
.try_handle_economy(
|
||||
"POST",
|
||||
"/ut/game/fifa17/match",
|
||||
&[],
|
||||
format!(r#"{{"matchId":{match_id}}}"#).as_bytes(),
|
||||
None,
|
||||
)
|
||||
.expect("play routed");
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&play.body).unwrap(),
|
||||
serde_json::json!({}),
|
||||
"FutPlayGame parses no fields"
|
||||
);
|
||||
|
||||
// Every abandoned match sends a BYTE-IDENTICAL body. The first credits…
|
||||
let dnf = br#"{"matchReportId":0,"endReason":"DNF","items":[],"matchData":"","matchPerfTelemetry01":"","matchStatusFlags":0}"#;
|
||||
let before_dnf = client.balance().unwrap();
|
||||
let ended = server
|
||||
.try_handle_economy("PUT", "/ut/game/fifa17/match/end", &[], dnf, None)
|
||||
.expect("match end must be claimed, never proxied");
|
||||
assert_eq!(ended.status, 200);
|
||||
let after_dnf = client.balance().unwrap();
|
||||
assert!(after_dnf > before_dnf, "the abandoned match credited");
|
||||
|
||||
// …a REPLAY of that same match must not. Holding (not taking) the in-flight
|
||||
// id is what makes the replay reuse one identity; taking it sent the replay
|
||||
// down the body-fingerprint path, a different identity, and Core paid twice.
|
||||
server
|
||||
.try_handle_economy("PUT", "/ut/game/fifa17/match/end", &[], dnf, None)
|
||||
.expect("replay routed");
|
||||
assert_eq!(
|
||||
client.balance().unwrap(),
|
||||
after_dnf,
|
||||
"a replayed match end must NOT credit again"
|
||||
);
|
||||
|
||||
// …and a NEW session with the identical body is a different match, which
|
||||
// must credit. Keyed on the body alone every abandoned match after the
|
||||
// first would silently pay nothing.
|
||||
let created2 = server
|
||||
.try_handle_economy(
|
||||
"POST",
|
||||
"/ut/game/fifa17/match",
|
||||
&[],
|
||||
br#"{"squadId":0,"type":"OFFLINE"}"#,
|
||||
None,
|
||||
)
|
||||
.expect("second create routed");
|
||||
let second_id = serde_json::from_slice::<Value>(&created2.body).unwrap()["id"]
|
||||
.as_i64()
|
||||
.unwrap();
|
||||
assert_ne!(second_id, match_id, "each match gets its own id");
|
||||
server
|
||||
.try_handle_economy("PUT", "/ut/game/fifa17/match/end", &[], dnf, None)
|
||||
.expect("second end routed");
|
||||
assert!(
|
||||
client.balance().unwrap() > after_dnf,
|
||||
"a second, distinct abandoned match must credit"
|
||||
);
|
||||
|
||||
SeqResult {
|
||||
final_balance: client.balance().unwrap(),
|
||||
sold_listing: trade_id.to_string(),
|
||||
@@ -769,6 +901,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 +1041,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 +1379,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(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,9 @@ use openfut_identity::JsonIdentityStore;
|
||||
use openfut_utas_host::account_store::AccountStore;
|
||||
use openfut_utas_host::{
|
||||
classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list,
|
||||
handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState, CorePage,
|
||||
CoreReplaceRequest, CoreReplaceResult, CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver,
|
||||
HttpCoreClient, PassClient, Route, Server, SquadDeps,
|
||||
handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState,
|
||||
CoreKitAssignments, CorePage, CoreReplaceRequest, CoreReplaceResult, CoreSquadRead,
|
||||
CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, PassClient, Route, Server, SquadDeps,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::Value;
|
||||
@@ -47,6 +47,9 @@ struct FakeCore {
|
||||
/// The stored squad + extension returned by `read_squad_ext`. A successful
|
||||
/// `replace_squad` overwrites it (Fresh), enabling coupled read-after-write.
|
||||
squad: Mutex<Option<CoreSquadRead>>,
|
||||
/// The ownership-backed manager assignment, exactly as Core persists it: a
|
||||
/// full squad replacement writes it (or clears it with `None`).
|
||||
manager: Mutex<Option<String>>,
|
||||
replaced: Mutex<Vec<StoredReplace>>,
|
||||
panic_if_called: bool,
|
||||
return_err: bool,
|
||||
@@ -92,6 +95,9 @@ impl FakeCore {
|
||||
fn last(&self) -> Vec<(String, String)> {
|
||||
self.last_params.lock().clone()
|
||||
}
|
||||
fn manager(&self) -> Option<String> {
|
||||
self.manager.lock().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl CoreAccess for FakeCore {
|
||||
@@ -184,6 +190,21 @@ impl CoreAccess for FakeCore {
|
||||
slots_written: req.slots.len(),
|
||||
})
|
||||
}
|
||||
|
||||
fn get_squad_manager(&self) -> Result<Option<String>, CoreError> {
|
||||
if self.return_err {
|
||||
return Err(CoreError::Status(500));
|
||||
}
|
||||
Ok(self.manager.lock().clone())
|
||||
}
|
||||
|
||||
fn set_squad_manager(&self, owned_card_id: Option<&str>) -> Result<(), CoreError> {
|
||||
if self.return_err {
|
||||
return Err(CoreError::Status(500));
|
||||
}
|
||||
*self.manager.lock() = owned_card_id.map(str::to_string);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn entities() -> Fifa17Entities {
|
||||
@@ -338,11 +359,13 @@ fn club_excludes_listed_items_and_paginates_the_visible_set() {
|
||||
|
||||
// oc2 has an active transfer-market listing.
|
||||
let hidden: std::collections::HashSet<String> = ["oc2".to_string()].into_iter().collect();
|
||||
let active_kits = CoreKitAssignments::default();
|
||||
let deps = ClubDeps {
|
||||
core: core.as_ref(),
|
||||
entities: &ents,
|
||||
assets: resolver.as_ref(),
|
||||
hidden: &hidden,
|
||||
active_kits: &active_kits,
|
||||
};
|
||||
let (resp, log) = handle_club("", &deps);
|
||||
let v: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
@@ -370,13 +393,14 @@ fn club_excludes_listed_items_and_paginates_the_visible_set() {
|
||||
);
|
||||
assert_eq!(log2.total, 2);
|
||||
|
||||
// Nothing hidden → the fast Core-paginated path, all three visible.
|
||||
// Nothing hidden → all three player items remain visible.
|
||||
let none: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let deps_all = ClubDeps {
|
||||
core: core.as_ref(),
|
||||
entities: &ents,
|
||||
assets: resolver.as_ref(),
|
||||
hidden: &none,
|
||||
active_kits: &active_kits,
|
||||
};
|
||||
let (resp3, log3) = handle_club("", &deps_all);
|
||||
let v3: Value = serde_json::from_slice(&resp3.body).unwrap();
|
||||
@@ -384,6 +408,67 @@ fn club_excludes_listed_items_and_paginates_the_visible_set() {
|
||||
assert_eq!(log3.total, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn club_projects_only_owned_kits_with_active_designations() {
|
||||
let core = Arc::new(FakeCore::new(
|
||||
vec![
|
||||
item(
|
||||
"player",
|
||||
"card_player",
|
||||
90,
|
||||
"ST",
|
||||
"Argentina",
|
||||
"Premier League",
|
||||
"Chelsea",
|
||||
),
|
||||
item("home", "kit_home", 0, "", "", "", ""),
|
||||
item("away", "kit_away", 0, "", "", "", ""),
|
||||
],
|
||||
3,
|
||||
));
|
||||
let catalog = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||
"card_player":{"asset_id":20801},
|
||||
"kit_home":{"asset_id":6300006,"kind":"kit","subtype":9,
|
||||
"card_asset_id":35,"team_id":21,"rareflag":0},
|
||||
"kit_away":{"asset_id":6400003,"kind":"kit","subtype":9,
|
||||
"card_asset_id":35,"team_id":21,"rareflag":0}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let resolver = Arc::new(Fifa17IdentityResolver::new(
|
||||
catalog,
|
||||
Arc::new(JsonIdentityStore::open(unique_store_path()).unwrap()),
|
||||
));
|
||||
let ents = entities();
|
||||
let hidden = std::collections::HashSet::new();
|
||||
let active_kits = CoreKitAssignments {
|
||||
home_owned_card_id: Some("home".into()),
|
||||
away_owned_card_id: Some("away".into()),
|
||||
};
|
||||
let deps = ClubDeps {
|
||||
core: core.as_ref(),
|
||||
entities: &ents,
|
||||
assets: resolver.as_ref(),
|
||||
hidden: &hidden,
|
||||
active_kits: &active_kits,
|
||||
};
|
||||
|
||||
let (kit_response, kit_log) = handle_club("type=kit", &deps);
|
||||
let kits: Value = serde_json::from_slice(&kit_response.body).unwrap();
|
||||
assert_eq!(kit_log.total, 2);
|
||||
assert_eq!(kits["itemData"][0]["itemState"], "activeHomeKit");
|
||||
assert_eq!(kits["itemData"][1]["itemState"], "activeAwayKit");
|
||||
assert_eq!(kits["itemData"][0]["cardassetid"], 35);
|
||||
assert_eq!(kits["itemData"][0]["teamid"], 21);
|
||||
|
||||
let (player_response, player_log) = handle_club("type=player", &deps);
|
||||
let players: Value = serde_json::from_slice(&player_response.body).unwrap();
|
||||
assert_eq!(player_log.total, 1);
|
||||
assert_eq!(players["itemData"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(players["itemData"][0]["itemType"], "player");
|
||||
}
|
||||
|
||||
// ── /club served from Core ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -408,7 +493,7 @@ fn club_route_maps_query_and_shapes_core_items() {
|
||||
|
||||
let resp = server.handle(
|
||||
"GET",
|
||||
"/ut/game/fifa17/club?year=2017&type=player&count=11&level=gold&nation=52&league=13&team=5&sort=desc&start=10",
|
||||
"/ut/game/fifa17/club?year=2017&type=player&count=11&level=gold&nation=52&league=13&team=5&sort=desc&start=0",
|
||||
&[],
|
||||
b"",
|
||||
);
|
||||
@@ -438,8 +523,14 @@ fn club_route_maps_query_and_shapes_core_items() {
|
||||
p.contains(&("club".into(), "Chelsea".into())),
|
||||
"team id 5 -> club name {p:?}"
|
||||
);
|
||||
assert!(p.contains(&("offset".into(), "10".into())));
|
||||
assert!(p.contains(&("limit".into(), "11".into())));
|
||||
// The host must NOT ask Core for a window. Kind and transfer-pile membership
|
||||
// are host-side concepts Core cannot express, so the visible set only exists
|
||||
// after local filtering and shaping; a Core-side window would paginate the
|
||||
// unfiltered set and hand the client short pages.
|
||||
assert!(
|
||||
!p.iter().any(|(k, _)| k == "offset" || k == "limit"),
|
||||
"pagination must not be delegated to Core {p:?}"
|
||||
);
|
||||
// No raw FIFA id reached Core.
|
||||
for (_, v) in &p {
|
||||
if v == "13" || v == "5" || v == "52" {
|
||||
@@ -448,6 +539,55 @@ fn club_route_maps_query_and_shapes_core_items() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The client's `start`/`count` window is applied by the host, over the set that
|
||||
/// survives kind + transfer-pile filtering. A window past the end is an empty
|
||||
/// page, not the first item.
|
||||
#[test]
|
||||
fn club_window_is_applied_locally_after_filtering() {
|
||||
let core = Arc::new(FakeCore::new(
|
||||
vec![item(
|
||||
"oc1",
|
||||
"card_ch_1",
|
||||
86,
|
||||
"CDM",
|
||||
"Argentina",
|
||||
"Premier League",
|
||||
"Chelsea",
|
||||
)],
|
||||
1,
|
||||
));
|
||||
let server = build_server(
|
||||
core.clone(),
|
||||
"http://127.0.0.1:1",
|
||||
Some(HashMap::from([("card_ch_1".to_string(), 20801u32)])),
|
||||
);
|
||||
|
||||
let in_window = server.handle(
|
||||
"GET",
|
||||
"/ut/game/fifa17/club?type=player&start=0&count=11",
|
||||
&[],
|
||||
b"",
|
||||
);
|
||||
let body: Value = serde_json::from_slice(&in_window.body).unwrap();
|
||||
assert_eq!(body["itemData"].as_array().unwrap().len(), 1);
|
||||
|
||||
let past_end = server.handle(
|
||||
"GET",
|
||||
"/ut/game/fifa17/club?type=player&start=10&count=11",
|
||||
&[],
|
||||
b"",
|
||||
);
|
||||
assert_eq!(
|
||||
past_end.status, 200,
|
||||
"a window past the end is still a page"
|
||||
);
|
||||
let body: Value = serde_json::from_slice(&past_end.body).unwrap();
|
||||
assert!(
|
||||
body["itemData"].as_array().unwrap().is_empty(),
|
||||
"offset past the visible set yields an empty page, not the first item"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn club_route_with_empty_asset_map_drops_items_not_fakes_them() {
|
||||
// The current production reality: no card→asset mapping → empty itemData.
|
||||
@@ -824,11 +964,26 @@ fn resolver_with_wires(
|
||||
}
|
||||
|
||||
/// A FIFA squad-save body. `players` = (index, wire id, kit number).
|
||||
fn put_body(formation: &str, captain: i64, players: &[(i64, i64, i64)], custom: &str) -> Vec<u8> {
|
||||
///
|
||||
/// `manager` is explicit because a squad save carries an ownership-backed
|
||||
/// manager assignment: a ref the resolver cannot map to an owned instance is an
|
||||
/// unresolved wire id and the whole save is refused (you cannot manage with a
|
||||
/// card you do not own). Tests that are not about the manager pass `None`.
|
||||
fn put_body(
|
||||
formation: &str,
|
||||
captain: i64,
|
||||
players: &[(i64, i64, i64)],
|
||||
custom: &str,
|
||||
manager: Option<i64>,
|
||||
) -> Vec<u8> {
|
||||
let ps: Vec<Value> = players
|
||||
.iter()
|
||||
.map(|(i, w, k)| json!({"index": i, "itemData": {"id": w, "dream": false}, "kitNumber": k}))
|
||||
.collect();
|
||||
let mgr: Vec<Value> = manager
|
||||
.into_iter()
|
||||
.map(|id| json!({"id": id, "dream": false}))
|
||||
.collect();
|
||||
json!({
|
||||
"id": 0,
|
||||
"formation": formation,
|
||||
@@ -839,7 +994,7 @@ fn put_body(formation: &str, captain: i64, players: &[(i64, i64, i64)], custom:
|
||||
"starRating": 90,
|
||||
"captain": captain,
|
||||
"custom": custom,
|
||||
"manager": [{"id": 100000427, "dream": false}],
|
||||
"manager": mgr,
|
||||
"players": ps,
|
||||
"kicktakers": [{"index": 0, "id": captain, "dream": false}],
|
||||
})
|
||||
@@ -968,6 +1123,7 @@ fn numbered_squad_get_returns_current_core_squad_without_python() {
|
||||
wires["oc-a"],
|
||||
&[(0, wires["oc-a"], 1), (1, wires["oc-b"], 9)],
|
||||
"[1,2,3]",
|
||||
None,
|
||||
),
|
||||
);
|
||||
assert_eq!(put.status, 200);
|
||||
@@ -997,6 +1153,7 @@ fn put_full_replacement_commits_canonical_and_extension_and_acks_id0() {
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1), (1, w["oc-b"], 9)],
|
||||
"[1,2,3]",
|
||||
None,
|
||||
);
|
||||
|
||||
let (resp, log) = handle_put_squad(&body, &deps);
|
||||
@@ -1019,6 +1176,95 @@ fn put_full_replacement_commits_canonical_and_extension_and_acks_id0() {
|
||||
assert_eq!(r.chemistry, Some(52), "client-reported shadow carried");
|
||||
}
|
||||
|
||||
/// The squad's manager is an ownership-backed assignment, not an opaque wire
|
||||
/// echo: a save assigns the owned instance behind the ref, and a later save
|
||||
/// without a manager CLEARS it (a full replacement replaces the manager too).
|
||||
#[test]
|
||||
fn put_assigns_the_owned_manager_and_a_later_save_clears_it() {
|
||||
let items = vec![gk(), st()];
|
||||
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::new(items.clone(), 2);
|
||||
let ent = entities();
|
||||
let deps = SquadDeps {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
};
|
||||
|
||||
let with_manager = put_body(
|
||||
"f442",
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1)],
|
||||
"[]",
|
||||
Some(w["oc-b"]),
|
||||
);
|
||||
let (resp, log) = handle_put_squad(&with_manager, &deps);
|
||||
assert_eq!(resp.status, 200, "{log:?}");
|
||||
assert_eq!(
|
||||
core.manager().as_deref(),
|
||||
Some("oc-b"),
|
||||
"manager persisted as the Core owned id, never the wire id"
|
||||
);
|
||||
|
||||
let without_manager = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]", None);
|
||||
let (resp, log) = handle_put_squad(&without_manager, &deps);
|
||||
assert_eq!(resp.status, 200, "{log:?}");
|
||||
assert_eq!(
|
||||
core.manager(),
|
||||
None,
|
||||
"a full replacement without a manager clears the assignment"
|
||||
);
|
||||
}
|
||||
|
||||
/// The REAL client always sends a manager ref, and on a real profile it does not
|
||||
/// resolve: production's own save points at 100000427, which is absent from
|
||||
/// production's `/club/staff`, and the client accepts that squad back unchanged.
|
||||
/// Refusing the save would therefore break EVERY squad save, so an unresolvable
|
||||
/// manager ref commits the squad with NO manager assignment.
|
||||
#[test]
|
||||
fn put_saves_the_squad_when_the_manager_ref_does_not_resolve() {
|
||||
let items = vec![gk(), st()];
|
||||
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::new(items.clone(), 2);
|
||||
let ent = entities();
|
||||
let deps = SquadDeps {
|
||||
core: &core,
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
};
|
||||
|
||||
let body = put_body(
|
||||
"f442",
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1)],
|
||||
"[]",
|
||||
Some(100_000_427),
|
||||
);
|
||||
let (resp, log) = handle_put_squad(&body, &deps);
|
||||
assert_eq!(resp.status, 200, "{log:?}");
|
||||
assert_eq!(resp.body, br#"{"id":0}"#);
|
||||
assert_eq!(core.replace_calls(), 1, "the squad itself is committed");
|
||||
assert_eq!(
|
||||
core.manager(),
|
||||
None,
|
||||
"no ownership-backed manager is invented from a ref we cannot map"
|
||||
);
|
||||
|
||||
// An occupied PLAYER slot that does not resolve is the opposite case: it
|
||||
// would silently lose an owned card, so it still refuses the whole save.
|
||||
let bad_player = put_body(
|
||||
"f442",
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1), (1, 999_999_999, 9)],
|
||||
"[]",
|
||||
None,
|
||||
);
|
||||
let (resp, log) = handle_put_squad(&bad_player, &deps);
|
||||
assert_eq!(resp.status, 400);
|
||||
assert_eq!(log.outcome, "unresolved_wire_ids");
|
||||
assert_eq!(core.replace_calls(), 1, "nothing further committed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_rejects_wire_id_owned_by_another_profile_core_unchanged() {
|
||||
// oc-b resolves (identity) but is NOT in the active club's owned set.
|
||||
@@ -1036,6 +1282,7 @@ fn put_rejects_wire_id_owned_by_another_profile_core_unchanged() {
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1), (1, w["oc-b"], 9)],
|
||||
"[]",
|
||||
None,
|
||||
);
|
||||
|
||||
let (resp, log) = handle_put_squad(&body, &deps);
|
||||
@@ -1062,6 +1309,7 @@ fn put_rejects_unknown_wire_id() {
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1), (1, 999_999_999, 9)],
|
||||
"[]",
|
||||
None,
|
||||
);
|
||||
let (resp, log) = handle_put_squad(&body, &deps);
|
||||
assert_eq!(resp.status, 400);
|
||||
@@ -1085,6 +1333,7 @@ fn put_rejects_duplicate_owned_item() {
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1), (1, w["oc-a"], 9)],
|
||||
"[]",
|
||||
None,
|
||||
);
|
||||
let (resp, log) = handle_put_squad(&body, &deps);
|
||||
assert_eq!(resp.status, 400);
|
||||
@@ -1104,7 +1353,7 @@ fn put_core_failure_returns_error_never_python() {
|
||||
resolver: &resolver,
|
||||
entities: &ent,
|
||||
};
|
||||
let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]");
|
||||
let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]", None);
|
||||
let (resp, log) = handle_put_squad(&body, &deps);
|
||||
assert_eq!(resp.status, 502);
|
||||
assert_eq!(log.outcome, "core_error");
|
||||
@@ -1126,6 +1375,7 @@ fn repeated_identical_put_is_idempotent() {
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1), (1, w["oc-b"], 9)],
|
||||
"[1,2,3]",
|
||||
None,
|
||||
);
|
||||
let (r1, _) = handle_put_squad(&body, &deps);
|
||||
let (r2, _) = handle_put_squad(&body, &deps);
|
||||
@@ -1161,6 +1411,7 @@ fn coupled_read_after_write_list_and_usermassinfo_agree() {
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1), (1, w["oc-b"], 9)],
|
||||
"[1,2,3]",
|
||||
None,
|
||||
);
|
||||
let (put, _) = handle_put_squad(&body, &deps);
|
||||
assert_eq!(put.status, 200);
|
||||
@@ -1266,6 +1517,7 @@ fn squad_active_serves_core_backed_object_with_configured_persona() {
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1), (1, w["oc-b"], 9)],
|
||||
"[1,2,3]",
|
||||
None,
|
||||
);
|
||||
let (put, _) = handle_put_squad(&body, &deps);
|
||||
assert_eq!(put.status, 200);
|
||||
@@ -1311,6 +1563,7 @@ fn read_path_is_bounded_no_per_slot_lookup() {
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1), (1, w["oc-b"], 9)],
|
||||
"[]",
|
||||
None,
|
||||
);
|
||||
handle_put_squad(&body, &deps);
|
||||
let reads_before = core.read_calls();
|
||||
@@ -1480,6 +1733,7 @@ fn duplicate_definition_instances_stay_distinct_through_host() {
|
||||
w["oc-a"],
|
||||
&[(0, w["oc-a"], 1), (1, w["oc-c"], 7)],
|
||||
"[]",
|
||||
None,
|
||||
);
|
||||
let (put, _) = handle_put_squad(&body, &deps);
|
||||
assert_eq!(put.status, 200);
|
||||
@@ -1635,3 +1889,555 @@ fn club_stats_year_counts_tiers_from_core_no_python() {
|
||||
assert_eq!(g["staff"], 0);
|
||||
assert_eq!(rec.lock().len(), 0, "club/stats never reaches Python");
|
||||
}
|
||||
|
||||
/// A resolver over a catalog authored verbatim, so a test can express the
|
||||
/// non-player fields (`kind`/`subtype`/`nation`/`league_id`/`team_id`) that the
|
||||
/// `card_id -> asset_id` helper above cannot.
|
||||
fn resolver_for_catalog(cards_json: &str) -> Arc<Fifa17IdentityResolver> {
|
||||
let doc = format!("{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{cards_json}}}}}");
|
||||
let catalog = Fifa17CardCatalog::from_json_str(&doc).unwrap();
|
||||
let store = JsonIdentityStore::open(unique_store_path()).unwrap();
|
||||
Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)))
|
||||
}
|
||||
|
||||
/// `?type=staff` and `?type=manager` must both serve the club's staff.
|
||||
///
|
||||
/// Regression: every `?type=` other than player/kit short-circuited to an empty
|
||||
/// page with `outcome:"unsupported_type"` and never reached Core, so an owned
|
||||
/// manager was unreachable — and FIFA refuses to start a match without one. The
|
||||
/// STAFF tab was observed asking with `type=manager`; `type=staff` is the
|
||||
/// sibling token.
|
||||
#[test]
|
||||
fn staff_and_manager_queries_both_serve_the_clubs_staff() {
|
||||
let core = Arc::new(FakeCore::new(
|
||||
vec![
|
||||
item("oc-mgr", "fifa17_1000509", 0, "", "", "", ""),
|
||||
item("oc-coach", "fifa17_3000083", 0, "", "", "", ""),
|
||||
item(
|
||||
"oc-p",
|
||||
"card_player",
|
||||
86,
|
||||
"CDM",
|
||||
"Argentina",
|
||||
"Premier League",
|
||||
"Chelsea",
|
||||
),
|
||||
],
|
||||
3,
|
||||
));
|
||||
let resolver = resolver_for_catalog(
|
||||
"\"fifa17_1000509\":{\"asset_id\":1000509,\"kind\":\"staff\",\"subtype\":4,\
|
||||
\"nation\":45,\"league_id\":53,\"team_id\":241},\
|
||||
\"fifa17_3000083\":{\"asset_id\":3000083,\"kind\":\"staff\",\"subtype\":8},\
|
||||
\"card_player\":{\"asset_id\":20801}",
|
||||
);
|
||||
let entities = entities();
|
||||
let hidden = std::collections::HashSet::new();
|
||||
let kits = CoreKitAssignments::default();
|
||||
|
||||
for token in ["staff", "manager"] {
|
||||
let deps = ClubDeps {
|
||||
core: core.as_ref(),
|
||||
entities: &entities,
|
||||
assets: resolver.as_ref(),
|
||||
hidden: &hidden,
|
||||
active_kits: &kits,
|
||||
};
|
||||
let (resp, log) = handle_club(&format!("type={token}&start=0&count=50"), &deps);
|
||||
assert_eq!(log.outcome, "ok", "type={token} must not be unsupported");
|
||||
let body: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
let items = body["itemData"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 2, "type={token}: manager + coach, no player");
|
||||
let manager = items
|
||||
.iter()
|
||||
.find(|i| i["cardsubtypeid"] == 4)
|
||||
.unwrap_or_else(|| panic!("type={token}: no manager in {items:?}"));
|
||||
// The merge key is read RAW: a version byte here breaks the lookup and
|
||||
// the manager branch has no else-arm to report the miss.
|
||||
assert_eq!(manager["resourceId"], 1_000_509);
|
||||
assert_eq!(manager["itemType"], "staff");
|
||||
// Slots the client's own merge never writes, so the server is the only
|
||||
// possible source of the flag, the badge and manager chemistry.
|
||||
assert_eq!(manager["nation"], 45);
|
||||
assert_eq!(manager["leagueId"], 53);
|
||||
assert_eq!(manager["teamid"], 241);
|
||||
assert!(
|
||||
manager["contract"].as_i64().unwrap_or(0) > 0,
|
||||
"a manager out of contract is exactly what blocks kickoff"
|
||||
);
|
||||
let coach = items.iter().find(|i| i["cardsubtypeid"] == 8).unwrap();
|
||||
assert!(
|
||||
coach.get("nation").is_none() && coach.get("leagueId").is_none(),
|
||||
"coach tables carry no nation/league column; inventing zeroes would be a lie"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A player query must never leak staff, and vice versa: a manager carries
|
||||
/// nation/leagueId/teamid, which would pollute the by-league and by-team club
|
||||
/// drill-downs if it appeared among the players.
|
||||
#[test]
|
||||
fn staff_never_leaks_into_the_player_query() {
|
||||
let core = Arc::new(FakeCore::new(
|
||||
vec![
|
||||
item("oc-mgr", "fifa17_1000509", 0, "", "", "", ""),
|
||||
item(
|
||||
"oc-p",
|
||||
"card_player",
|
||||
86,
|
||||
"CDM",
|
||||
"Argentina",
|
||||
"Premier League",
|
||||
"Chelsea",
|
||||
),
|
||||
],
|
||||
2,
|
||||
));
|
||||
let resolver = resolver_for_catalog(
|
||||
"\"fifa17_1000509\":{\"asset_id\":1000509,\"kind\":\"staff\",\"subtype\":4,\
|
||||
\"nation\":45,\"league_id\":53,\"team_id\":241},\
|
||||
\"card_player\":{\"asset_id\":20801}",
|
||||
);
|
||||
let entities = entities();
|
||||
let hidden = std::collections::HashSet::new();
|
||||
let kits = CoreKitAssignments::default();
|
||||
let deps = ClubDeps {
|
||||
core: core.as_ref(),
|
||||
entities: &entities,
|
||||
assets: resolver.as_ref(),
|
||||
hidden: &hidden,
|
||||
active_kits: &kits,
|
||||
};
|
||||
let (resp, _) = handle_club("type=player&start=0&count=50", &deps);
|
||||
let body: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
let items = body["itemData"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0]["resourceId"], 20801, "only the footballer");
|
||||
}
|
||||
|
||||
/// `watchList` had a Rust handler that nothing could reach: no classifier arm
|
||||
/// produced `Route::WatchList`, so every request fell through to Passthrough and,
|
||||
/// against a staging stack with a deliberately dead Python upstream, 502'd.
|
||||
#[test]
|
||||
fn watchlist_is_rust_owned_and_never_passed_through() {
|
||||
for method in ["GET", "PUT", "POST", "DELETE"] {
|
||||
assert_eq!(
|
||||
classify(method, "/ut/game/fifa17/watchList"),
|
||||
Route::WatchList,
|
||||
"{method} watchList must be served by Rust, not proxied"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── /club ?type= completeness, and the consumables route ────────────────────
|
||||
|
||||
/// A club holding one of everything the taxonomy can ask about: two players (a
|
||||
/// forward and a defender), a manager, a GK coach, a fitness coach, two kits and
|
||||
/// one consumable — the same mix the real profile has, at fixture scale.
|
||||
fn mixed_club_catalog() -> &'static str {
|
||||
"\"fifa17_20801\":{\"asset_id\":20801},\
|
||||
\"fifa17_158023\":{\"asset_id\":158023},\
|
||||
\"fifa17_1000509\":{\"asset_id\":1000509,\"kind\":\"staff\",\"subtype\":4,\
|
||||
\"nation\":45,\"league_id\":53,\"team_id\":241},\
|
||||
\"fifa17_9000081\":{\"asset_id\":9000081,\"kind\":\"staff\",\"subtype\":6},\
|
||||
\"fifa17_3000083\":{\"asset_id\":3000083,\"kind\":\"staff\",\"subtype\":8},\
|
||||
\"fifa17_6300006\":{\"asset_id\":6300006,\"kind\":\"kit\",\"subtype\":9,\
|
||||
\"card_asset_id\":35,\"team_id\":21},\
|
||||
\"fifa17_6400003\":{\"asset_id\":6400003,\"kind\":\"kit\",\"subtype\":9,\
|
||||
\"card_asset_id\":35,\"team_id\":21},\
|
||||
\"fifa17_5003012\":{\"asset_id\":5003012,\"kind\":\"consumable\",\"subtype\":54,\
|
||||
\"card_asset_id\":3,\"rareflag\":0,\"rating\":85,\"amount\":15}"
|
||||
}
|
||||
|
||||
fn mixed_club_items() -> Vec<CoreOwnedItem> {
|
||||
vec![
|
||||
item(
|
||||
"oc-st",
|
||||
"fifa17_20801",
|
||||
94,
|
||||
"ST",
|
||||
"Argentina",
|
||||
"Premier League",
|
||||
"Chelsea",
|
||||
),
|
||||
item(
|
||||
"oc-cb",
|
||||
"fifa17_158023",
|
||||
88,
|
||||
"CB",
|
||||
"Argentina",
|
||||
"Premier League",
|
||||
"Chelsea",
|
||||
),
|
||||
item("oc-mgr", "fifa17_1000509", 0, "", "", "", ""),
|
||||
item("oc-gk-coach", "fifa17_9000081", 0, "", "", "", ""),
|
||||
item("oc-fit-coach", "fifa17_3000083", 0, "", "", "", ""),
|
||||
item("oc-kit-home", "fifa17_6300006", 0, "", "", "", ""),
|
||||
item("oc-kit-away", "fifa17_6400003", 0, "", "", "", ""),
|
||||
item("oc-consumable", "fifa17_5003012", 0, "", "", "", ""),
|
||||
]
|
||||
}
|
||||
|
||||
/// Run one `?type=` query against the mixed club.
|
||||
fn club_query(query: &str) -> (Vec<Value>, String, &'static str) {
|
||||
let core = Arc::new(FakeCore::new(mixed_club_items(), 8));
|
||||
let resolver = resolver_for_catalog(mixed_club_catalog());
|
||||
let ents = entities();
|
||||
let hidden = std::collections::HashSet::new();
|
||||
let kits = CoreKitAssignments {
|
||||
home_owned_card_id: Some("oc-kit-home".into()),
|
||||
away_owned_card_id: Some("oc-kit-away".into()),
|
||||
};
|
||||
let deps = ClubDeps {
|
||||
core: core.as_ref(),
|
||||
entities: &ents,
|
||||
assets: resolver.as_ref(),
|
||||
hidden: &hidden,
|
||||
active_kits: &kits,
|
||||
};
|
||||
let (resp, log) = handle_club(query, &deps);
|
||||
assert_eq!(resp.status, 200, "UTAS must never fail a club query");
|
||||
let body: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
let items = body["itemData"].as_array().cloned().unwrap_or_default();
|
||||
(items, log.filter, log.outcome)
|
||||
}
|
||||
|
||||
/// Every `?type=` arm that claims a family must serve THAT family and nothing
|
||||
/// else. The mirror filter is the point: a manager carries nation/leagueId/teamid,
|
||||
/// so leaking one into a player query would put a coach in the by-league and
|
||||
/// by-team drill-downs — the exact regression the filter exists to prevent.
|
||||
#[test]
|
||||
fn club_type_arms_serve_their_own_family_and_never_leak_another() {
|
||||
// Players: the untyped fetch, `player`, and the taxonomy's `any`/`custom`
|
||||
// arms all mean the club's footballers.
|
||||
for query in ["", "type=player", "type=any", "type=custom"] {
|
||||
let (items, _, outcome) = club_query(query);
|
||||
assert_eq!(outcome, "ok", "{query}");
|
||||
assert_eq!(items.len(), 2, "{query}: two footballers");
|
||||
for it in &items {
|
||||
assert_eq!(it["itemType"], "player", "{query}");
|
||||
assert_eq!(it["cardsubtypeid"], 0, "{query}: no staff/kit subtype");
|
||||
assert!(it["attributeList"].is_array(), "{query}");
|
||||
}
|
||||
}
|
||||
|
||||
// The whole staff family, under either observed token.
|
||||
for query in ["type=staff", "type=manager"] {
|
||||
let (items, _, outcome) = club_query(query);
|
||||
assert_eq!(outcome, "ok", "{query}");
|
||||
let mut subtypes: Vec<i64> = items
|
||||
.iter()
|
||||
.map(|i| i["cardsubtypeid"].as_i64().unwrap())
|
||||
.collect();
|
||||
subtypes.sort_unstable();
|
||||
assert_eq!(
|
||||
subtypes,
|
||||
vec![4, 6, 8],
|
||||
"{query}: manager + both coaches, no footballer and no kit"
|
||||
);
|
||||
for it in &items {
|
||||
assert_eq!(it["itemType"], "staff", "{query}");
|
||||
assert!(it.get("attributeList").is_none(), "{query}");
|
||||
}
|
||||
}
|
||||
|
||||
// One arm per coach family, by cardsubtypeid.
|
||||
for (query, subtype) in [
|
||||
("type=gkcoach", 6),
|
||||
("type=fitnesscoach", 8),
|
||||
("type=headcoach", 5),
|
||||
("type=physio", 7),
|
||||
] {
|
||||
let (items, _, outcome) = club_query(query);
|
||||
assert_eq!(outcome, "ok", "{query}");
|
||||
let owned: Vec<i64> = items
|
||||
.iter()
|
||||
.map(|i| i["cardsubtypeid"].as_i64().unwrap())
|
||||
.collect();
|
||||
// The club owns a GK coach and a fitness coach only, so the other two
|
||||
// arms are legitimately empty — never "everything" to fill the tab.
|
||||
let expected: Vec<i64> = if [6, 8].contains(&subtype) {
|
||||
vec![subtype]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
assert_eq!(owned, expected, "{query}");
|
||||
assert!(
|
||||
!owned.contains(&4),
|
||||
"{query}: the MANAGER is not a coach family"
|
||||
);
|
||||
}
|
||||
|
||||
// Kits, with their ownership-backed active designations.
|
||||
let (kits, _, outcome) = club_query("type=kit");
|
||||
assert_eq!(outcome, "ok");
|
||||
assert_eq!(kits.len(), 2);
|
||||
assert_eq!(kits[0]["itemState"], "activeHomeKit");
|
||||
assert_eq!(kits[1]["itemState"], "activeAwayKit");
|
||||
for kit in &kits {
|
||||
assert_eq!(kit["cardsubtypeid"], 9);
|
||||
assert!(kit.get("attributeList").is_none());
|
||||
}
|
||||
|
||||
// The consumable is in NEITHER: it has its own route and its own envelope.
|
||||
for query in ["", "type=player", "type=staff", "type=kit"] {
|
||||
let (items, _, _) = club_query(query);
|
||||
assert!(
|
||||
!items
|
||||
.iter()
|
||||
.any(|i| i["cardsubtypeid"].as_i64() == Some(54)),
|
||||
"{query}: a consumable must never appear in a /club item list"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The three MY CLUB position tabs answer their own group, from the client's own
|
||||
/// position ladder — never the whole club.
|
||||
#[test]
|
||||
fn position_tabs_serve_only_their_own_position_group() {
|
||||
let (forwards, _, outcome) = club_query("type=playerforward");
|
||||
assert_eq!(outcome, "ok");
|
||||
assert_eq!(forwards.len(), 1);
|
||||
assert_eq!(forwards[0]["preferredPosition"], "ST");
|
||||
|
||||
let (defenders, _, _) = club_query("type=playerdefender");
|
||||
assert_eq!(defenders.len(), 1);
|
||||
assert_eq!(defenders[0]["preferredPosition"], "CB");
|
||||
|
||||
// No midfielder is owned → an empty tab, not the other five cards.
|
||||
let (mids, _, outcome) = club_query("type=playermidfielder");
|
||||
assert_eq!(outcome, "ok");
|
||||
assert!(mids.is_empty());
|
||||
|
||||
// And no position tab ever contains staff or a kit.
|
||||
for query in [
|
||||
"type=playerforward",
|
||||
"type=playerdefender",
|
||||
"type=playermidfielder",
|
||||
] {
|
||||
let (items, _, _) = club_query(query);
|
||||
for it in &items {
|
||||
assert_eq!(it["itemType"], "player", "{query}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The arms that are DELIBERATELY empty answer 200 with an empty list and record
|
||||
/// WHY — distinguishable in the log from a token we do not know.
|
||||
#[test]
|
||||
fn withheld_club_type_arms_are_empty_with_a_recorded_reason() {
|
||||
for (query, reason) in [
|
||||
("type=equippables", "multi_family_crash_2026_08_05"),
|
||||
("type=leaguelogos", "subtype_by_elimination_unprobed"),
|
||||
("type=healing", "served_by_club_consumables_route"),
|
||||
("type=contract", "served_by_club_consumables_route"),
|
||||
("type=training", "served_by_club_consumables_route"),
|
||||
("type=development", "served_by_club_consumables_route"),
|
||||
("type=unlocks", "not_owned_inventory"),
|
||||
("type=offlinetrophy", "no_trophy_ownership_in_core"),
|
||||
("type=onlinetrophy", "no_trophy_ownership_in_core"),
|
||||
("type=featuredofflinetrophy", "no_trophy_ownership_in_core"),
|
||||
("type=featuredonlinetrophy", "no_trophy_ownership_in_core"),
|
||||
("type=allofflinetrophy", "no_trophy_ownership_in_core"),
|
||||
("type=allonlinetrophy", "no_trophy_ownership_in_core"),
|
||||
] {
|
||||
let (items, filter, outcome) = club_query(query);
|
||||
assert_eq!(outcome, "withheld", "{query}");
|
||||
assert!(items.is_empty(), "{query} must serve nothing");
|
||||
assert!(
|
||||
filter.contains(reason),
|
||||
"{query}: log must carry the reason, got [{filter}]"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The three club-customisation families Core can own are MAPPED (so the arm asks
|
||||
/// Core for the right rows) but their item record is still withheld, so the answer
|
||||
/// is an empty list rather than a guessed record — and never another family's.
|
||||
#[test]
|
||||
fn club_item_arms_are_mapped_but_withhold_the_unverified_record() {
|
||||
for query in ["type=badge", "type=stadium", "type=ball", "type=misc"] {
|
||||
let (items, filter, outcome) = club_query(query);
|
||||
assert_eq!(outcome, "ok", "{query}");
|
||||
assert!(
|
||||
items.is_empty(),
|
||||
"{query}: the club owns none, and no other family may fill the tab"
|
||||
);
|
||||
let token = query.trim_start_matches("type=");
|
||||
assert!(filter.contains(&format!("type={token}")), "{filter}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A token outside the client's 30-arm taxonomy stays `unsupported_type`: empty,
|
||||
/// and loud in the log, never silently mapped onto the player set.
|
||||
#[test]
|
||||
fn unknown_club_type_is_unsupported_not_silently_mapped() {
|
||||
for query in ["type=nonsense", "type=PLAYER", "type=kits"] {
|
||||
let (items, filter, outcome) = club_query(query);
|
||||
assert_eq!(outcome, "unsupported_type", "{query}");
|
||||
assert!(items.is_empty(), "{query}");
|
||||
assert!(filter.starts_with("type="), "{filter}");
|
||||
}
|
||||
}
|
||||
|
||||
fn build_server_with_catalog(core: Arc<FakeCore>, upstream: &str, cards_json: &str) -> Server {
|
||||
let doc = format!("{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{cards_json}}}}}");
|
||||
let catalog = Fifa17CardCatalog::from_json_str(&doc).unwrap();
|
||||
let store = JsonIdentityStore::open(unique_store_path()).unwrap();
|
||||
let resolver = Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)));
|
||||
Server::new(
|
||||
core,
|
||||
Arc::new(entities()),
|
||||
resolver,
|
||||
Arc::new(PassClient::new(upstream)),
|
||||
33_068_179,
|
||||
)
|
||||
}
|
||||
|
||||
/// `GET club/consumables/<category>` is Rust-owned, served from Core, and answers
|
||||
/// with the STACK wrapper this response class reads — not bare items, which the
|
||||
/// client accepts and silently discards.
|
||||
#[test]
|
||||
fn consumables_route_serves_core_owned_stacks_per_category() {
|
||||
// Two copies of one training card + one contract card + a footballer.
|
||||
let items = vec![
|
||||
item("oc-c1", "fifa17_5003012", 0, "", "", "", ""),
|
||||
item("oc-c2", "fifa17_5003012", 0, "", "", "", ""),
|
||||
item("oc-c3", "fifa17_5001004", 0, "", "", "", ""),
|
||||
item(
|
||||
"oc-p",
|
||||
"fifa17_20801",
|
||||
94,
|
||||
"ST",
|
||||
"Argentina",
|
||||
"Premier League",
|
||||
"Chelsea",
|
||||
),
|
||||
];
|
||||
let core = Arc::new(FakeCore::new(items, 4));
|
||||
let (py_url, rec) = spawn_mock_python();
|
||||
let server = build_server_with_catalog(
|
||||
core,
|
||||
&py_url,
|
||||
"\"fifa17_20801\":{\"asset_id\":20801},\
|
||||
\"fifa17_5003012\":{\"asset_id\":5003012,\"kind\":\"consumable\",\"subtype\":54,\
|
||||
\"card_asset_id\":3,\"rareflag\":0,\"rating\":85,\"amount\":15},\
|
||||
\"fifa17_5001004\":{\"asset_id\":5001004,\"kind\":\"consumable\",\"subtype\":201,\
|
||||
\"card_asset_id\":7,\"rareflag\":0,\"rating\":60,\"contract\":7}",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
classify("GET", "/ut/game/fifa17/club/consumables/training"),
|
||||
Route::ClubConsumables,
|
||||
"a /club PREFIX must not fall through to the generic club route"
|
||||
);
|
||||
|
||||
let resp = server.handle("GET", "/ut/game/fifa17/club/consumables/training", &[], b"");
|
||||
assert_eq!(resp.status, 200);
|
||||
let body: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
let stacks = body["itemData"].as_array().unwrap();
|
||||
assert_eq!(stacks.len(), 1, "one stack for the two identical copies");
|
||||
assert_eq!(stacks[0]["count"], 2);
|
||||
assert_eq!(stacks[0]["resourceId"], 5_003_012);
|
||||
assert_eq!(stacks[0]["item"]["cardsubtypeid"], 54);
|
||||
assert_eq!(
|
||||
stacks[0]["item"]["cardassetid"], 3,
|
||||
"the ART id, not the id"
|
||||
);
|
||||
assert_eq!(stacks[0]["item"]["amount"], 15, "mandatory for category 0");
|
||||
assert_eq!(stacks[0]["item"]["rating"], 85, "EA's definition rating");
|
||||
assert!(
|
||||
stacks[0]["item"].get("attributeList").is_none(),
|
||||
"a consumable has no attributes — that is what makes it not a player"
|
||||
);
|
||||
|
||||
// The contracts category serves the OTHER card, and never the training one.
|
||||
let resp = server.handle(
|
||||
"GET",
|
||||
"/ut/game/fifa17/club/consumables/contracts",
|
||||
&[],
|
||||
b"",
|
||||
);
|
||||
let body: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
let stacks = body["itemData"].as_array().unwrap();
|
||||
assert_eq!(stacks.len(), 1);
|
||||
assert_eq!(stacks[0]["resourceId"], 5_001_004);
|
||||
assert_eq!(stacks[0]["item"]["contract"], 7);
|
||||
assert!(
|
||||
stacks[0]["item"].get("amount").is_none(),
|
||||
"categories 2 and 3 ignore `amount`"
|
||||
);
|
||||
|
||||
// A category the club owns nothing in is empty — and the footballer never
|
||||
// appears in any of them.
|
||||
for category in ["healing", "fitness", "position", "playstyle"] {
|
||||
let path = format!("/ut/game/fifa17/club/consumables/{category}");
|
||||
let resp = server.handle("GET", &path, &[], b"");
|
||||
let body: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
assert!(
|
||||
body["itemData"].as_array().unwrap().is_empty(),
|
||||
"{category} must not be filled with another family"
|
||||
);
|
||||
}
|
||||
assert_eq!(rec.lock().len(), 0, "consumables never reach Python");
|
||||
}
|
||||
|
||||
/// An UNKNOWN category segment is empty, not the whole shelf: filling a named tab
|
||||
/// with every family is the same bug class as answering a drill-down with the
|
||||
/// entire club.
|
||||
#[test]
|
||||
fn consumables_route_unknown_category_serves_nothing() {
|
||||
let core = Arc::new(FakeCore::new(
|
||||
vec![item("oc-c1", "fifa17_5003012", 0, "", "", "", "")],
|
||||
1,
|
||||
));
|
||||
let (py_url, rec) = spawn_mock_python();
|
||||
let server = build_server_with_catalog(
|
||||
core,
|
||||
&py_url,
|
||||
"\"fifa17_5003012\":{\"asset_id\":5003012,\"kind\":\"consumable\",\"subtype\":54,\
|
||||
\"card_asset_id\":3,\"rareflag\":0,\"rating\":85,\"amount\":15}",
|
||||
);
|
||||
for path in [
|
||||
"/ut/game/fifa17/club/consumables/somethingelse",
|
||||
"/ut/game/fifa17/club/consumables",
|
||||
] {
|
||||
let resp = server.handle("GET", path, &[], b"");
|
||||
assert_eq!(resp.status, 200, "{path}");
|
||||
let body: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
assert!(body["itemData"].as_array().unwrap().is_empty(), "{path}");
|
||||
}
|
||||
assert_eq!(rec.lock().len(), 0, "still never Python");
|
||||
}
|
||||
|
||||
/// A consumable whose catalog entry is INCOMPLETE is dropped, not drawn wrong:
|
||||
/// without `amount` the client renders "-1" (its parser initialises the temp to
|
||||
/// -1 and sign-extends), and without a real `cardassetid` it draws the
|
||||
/// `notfound.swf` green box.
|
||||
#[test]
|
||||
fn incomplete_consumable_definitions_are_dropped_not_drawn_wrong() {
|
||||
let core = Arc::new(FakeCore::new(
|
||||
vec![
|
||||
item("oc-no-amount", "fifa17_5003011", 0, "", "", "", ""),
|
||||
item("oc-no-art", "fifa17_5003013", 0, "", "", "", ""),
|
||||
],
|
||||
2,
|
||||
));
|
||||
let (py_url, _rec) = spawn_mock_python();
|
||||
let server = build_server_with_catalog(
|
||||
core,
|
||||
&py_url,
|
||||
// (a) art id present, `amount` missing; (b) `amount` present, art missing.
|
||||
"\"fifa17_5003011\":{\"asset_id\":5003011,\"kind\":\"consumable\",\"subtype\":54,\
|
||||
\"card_asset_id\":3,\"rareflag\":0,\"rating\":65},\
|
||||
\"fifa17_5003013\":{\"asset_id\":5003013,\"kind\":\"consumable\",\"subtype\":54,\
|
||||
\"rareflag\":0,\"rating\":85,\"amount\":15}",
|
||||
);
|
||||
let resp = server.handle("GET", "/ut/game/fifa17/club/consumables/training", &[], b"");
|
||||
assert_eq!(resp.status, 200);
|
||||
let body: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
assert!(
|
||||
body["itemData"].as_array().unwrap().is_empty(),
|
||||
"neither definition can be drawn honestly"
|
||||
);
|
||||
}
|
||||
|
||||
Executable
+309
@@ -0,0 +1,309 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Take a READ-ONLY snapshot of the operator's real imported FIFA-17 club.
|
||||
|
||||
Why this script exists at all
|
||||
----------------------------
|
||||
The real club -- the 1986-item CAGE import, persona 33068179 -- lives in the
|
||||
production state directory `/home/alex/openfut-promotion/state`. That directory is
|
||||
listed in FORBIDDEN_PATHS by both staging lifecycle scripts, which refuse to open
|
||||
any path underneath it. That guard is deliberate and stays absolute: the staging
|
||||
scripts must not be able to reach production state even by accident.
|
||||
|
||||
So the club cannot enter staging directly. This script is the ONE place allowed to
|
||||
read production state, it is read-only by construction, and its only output is a
|
||||
snapshot in a directory OUTSIDE the guard. `sold-staging-up.py --club real` then
|
||||
installs from the snapshot and never learns where it came from.
|
||||
|
||||
Read-only by construction
|
||||
-------------------------
|
||||
* The Core database is opened `mode=ro` and copied with sqlite3's online backup
|
||||
API, so the copy is transactionally consistent and the source is never written
|
||||
(a plain file copy of a database with a hot WAL can tear).
|
||||
* Every destination is asserted to be outside the production state directory
|
||||
before anything is opened for writing.
|
||||
* The sha256 of every source file is taken before and after the copy and compared.
|
||||
A mismatch aborts loudly -- that would mean this script, or something racing it,
|
||||
modified production state.
|
||||
|
||||
The snapshot is a point-in-time artifact, not a live mirror. Re-run it to refresh.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from openfut_production import ProductionError, production_state # noqa: E402
|
||||
|
||||
# --- fixed facts -------------------------------------------------------------------
|
||||
|
||||
# The production state directory. This script READS these files and writes nothing
|
||||
# back. It is the same path both staging scripts refuse outright.
|
||||
PROD_STATE = "/home/alex/openfut-promotion/state"
|
||||
SRC_CORE_DB = os.path.join(PROD_STATE, "prod-core.db")
|
||||
SRC_IDENTITY = os.path.join(PROD_STATE, "prod-identity.json")
|
||||
SRC_CLIENTDATA = os.path.join(PROD_STATE, "clientdata.json")
|
||||
|
||||
DEFAULT_SNAPSHOT_DIR = "/home/alex/openfut-club-snapshot"
|
||||
|
||||
# Snapshot member names. `sold-staging-up.py` knows these and nothing else.
|
||||
SNAP_CORE_DB = "core.db"
|
||||
SNAP_IDENTITY = "identity.json"
|
||||
SNAP_CLIENTDATA = "clientdata.json"
|
||||
SNAP_MANIFEST = "snapshot.json"
|
||||
|
||||
GAME = "fifa17"
|
||||
|
||||
|
||||
def banner(title: str) -> None:
|
||||
print()
|
||||
print("=" * 78)
|
||||
print(f" {title}")
|
||||
print("=" * 78)
|
||||
|
||||
|
||||
def step(msg: str) -> None:
|
||||
print(f" {msg}")
|
||||
|
||||
|
||||
def ok(msg: str) -> None:
|
||||
print(f" [ OK ] {msg}")
|
||||
|
||||
|
||||
class Fatal(ProductionError):
|
||||
"""Anything that must abort the snapshot loudly rather than degrade."""
|
||||
|
||||
|
||||
def sha256(path: str) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def assert_outside_production(path: str, what: str) -> str:
|
||||
"""Every write target goes through here. The snapshot must never be able to
|
||||
land inside the directory this script is reading."""
|
||||
resolved = os.path.abspath(path)
|
||||
if resolved == PROD_STATE or resolved.startswith(PROD_STATE + os.sep):
|
||||
raise Fatal(
|
||||
f"REFUSING: {what} {resolved!r} is inside the production state "
|
||||
f"directory {PROD_STATE!r} -- this script never writes there"
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
def read_club_facts(db_path: str) -> dict:
|
||||
"""Describe the club in a database WITHOUT modifying it. Used on the source (to
|
||||
record what was taken) and on the copy (to prove the copy is faithful)."""
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
try:
|
||||
profiles = conn.execute(
|
||||
"SELECT id, username, game_id, import_fingerprint FROM profiles "
|
||||
"WHERE game_id = ?", (GAME,)
|
||||
).fetchall()
|
||||
if len(profiles) != 1:
|
||||
raise Fatal(
|
||||
f"expected exactly one {GAME} profile in {db_path}, found "
|
||||
f"{len(profiles)} -- refusing to snapshot an ambiguous club"
|
||||
)
|
||||
profile_id, username, game_id, fingerprint = profiles[0]
|
||||
club = conn.execute(
|
||||
"SELECT id, name, coins FROM clubs WHERE profile_id = ?", (profile_id,)
|
||||
).fetchone()
|
||||
if club is None:
|
||||
raise Fatal(f"{GAME} profile {profile_id} has no club in {db_path}")
|
||||
club_id, club_name, coins = club
|
||||
owned, distinct = conn.execute(
|
||||
"SELECT COUNT(*), COUNT(DISTINCT card_id) FROM owned_cards WHERE club_id = ?",
|
||||
(club_id,),
|
||||
).fetchone()
|
||||
squads = conn.execute(
|
||||
"SELECT id, name, formation FROM squads WHERE club_id = ?", (club_id,)
|
||||
).fetchall()
|
||||
squad_players = conn.execute(
|
||||
"SELECT COUNT(*) FROM squad_players sp JOIN squads s ON s.id = sp.squad_id "
|
||||
"WHERE s.club_id = ?", (club_id,)
|
||||
).fetchone()[0]
|
||||
migration = conn.execute(
|
||||
"SELECT MAX(version) FROM _sqlx_migrations"
|
||||
).fetchone()[0]
|
||||
return {
|
||||
"profile_id": profile_id,
|
||||
"username": username,
|
||||
"game_id": game_id,
|
||||
"import_fingerprint": fingerprint,
|
||||
"club_id": club_id,
|
||||
"club_name": club_name,
|
||||
"coins": coins,
|
||||
"owned_cards": owned,
|
||||
"distinct_card_ids": distinct,
|
||||
"squads": [
|
||||
{"id": s[0], "name": s[1], "formation": s[2]} for s in squads
|
||||
],
|
||||
"squad_players": squad_players,
|
||||
"schema_version": migration,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def copy_database(src: str, dst: str) -> None:
|
||||
"""Online-backup copy. The source is opened read-only, so this cannot write to
|
||||
production state even if the backup API wanted to."""
|
||||
source = sqlite3.connect(f"file:{src}?mode=ro", uri=True)
|
||||
try:
|
||||
if os.path.exists(dst):
|
||||
os.remove(dst)
|
||||
target = sqlite3.connect(dst)
|
||||
try:
|
||||
source.backup(target)
|
||||
finally:
|
||||
target.close()
|
||||
finally:
|
||||
source.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--dir", default=os.environ.get("OPENFUT_CLUB_SNAPSHOT_DIR",
|
||||
DEFAULT_SNAPSHOT_DIR),
|
||||
help=f"snapshot directory (default: {DEFAULT_SNAPSHOT_DIR})")
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
banner("SNAPSHOT THE REAL CLUB (read-only on production state)")
|
||||
dest = assert_outside_production(args.dir, "snapshot directory")
|
||||
step(f"source (read-only): {PROD_STATE}")
|
||||
step(f"destination : {dest}")
|
||||
|
||||
# Production is not involved in a file copy -- its Core runs from a docker
|
||||
# volume, not from this directory -- but proving the freeze held across the
|
||||
# operation costs nothing and keeps the evidence uniform with the other
|
||||
# lifecycle scripts.
|
||||
before_state = production_state()
|
||||
ok("production before: " + ", ".join(before_state.describe()))
|
||||
|
||||
missing = [p for p in (SRC_CORE_DB, SRC_IDENTITY, SRC_CLIENTDATA)
|
||||
if not os.path.isfile(p)]
|
||||
if missing:
|
||||
raise Fatal("missing production artifact(s):\n " + "\n ".join(missing))
|
||||
|
||||
before = {p: sha256(p) for p in (SRC_CORE_DB, SRC_IDENTITY, SRC_CLIENTDATA)}
|
||||
for path, digest in before.items():
|
||||
step(f"{os.path.basename(path):20s} {os.path.getsize(path):>9,d} B "
|
||||
f"sha256 {digest[:16]}")
|
||||
|
||||
facts = read_club_facts(SRC_CORE_DB)
|
||||
ok(
|
||||
f"club to snapshot: {facts['username']} ({facts['club_name']}) "
|
||||
f"{facts['owned_cards']} items, {facts['distinct_card_ids']} distinct "
|
||||
f"card ids, {facts['coins']:,} coins, schema v{facts['schema_version']}"
|
||||
)
|
||||
|
||||
banner("COPY")
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
snap_db = assert_outside_production(os.path.join(dest, SNAP_CORE_DB), "core db")
|
||||
copy_database(SRC_CORE_DB, snap_db)
|
||||
ok(f"{SNAP_CORE_DB} written by sqlite online backup (consistent copy)")
|
||||
|
||||
for src, name in ((SRC_IDENTITY, SNAP_IDENTITY),
|
||||
(SRC_CLIENTDATA, SNAP_CLIENTDATA)):
|
||||
dst = assert_outside_production(os.path.join(dest, name), name)
|
||||
shutil.copy2(src, dst)
|
||||
# The source is root-owned; the copy must be writable by the staging user
|
||||
# that installs it.
|
||||
os.chmod(dst, 0o644)
|
||||
ok(f"{SNAP_IDENTITY} and {SNAP_CLIENTDATA} copied")
|
||||
|
||||
banner("PROVE THE COPY IS FAITHFUL AND THE SOURCE IS UNTOUCHED")
|
||||
after = {p: sha256(p) for p in before}
|
||||
changed = [os.path.basename(p) for p in before if before[p] != after[p]]
|
||||
if changed:
|
||||
raise Fatal(
|
||||
"PRODUCTION STATE WAS MODIFIED during the snapshot: "
|
||||
f"{changed} -- this must never happen"
|
||||
)
|
||||
ok("every source file byte-identical before and after (sha256)")
|
||||
|
||||
copy_facts = read_club_facts(snap_db)
|
||||
differences = {
|
||||
key: (facts[key], copy_facts[key])
|
||||
for key in facts
|
||||
if facts[key] != copy_facts[key]
|
||||
}
|
||||
if differences:
|
||||
raise Fatal(f"snapshot does not match the source: {differences}")
|
||||
ok(
|
||||
f"snapshot matches source exactly: {copy_facts['owned_cards']} owned "
|
||||
f"cards, {copy_facts['coins']:,} coins, "
|
||||
f"{copy_facts['squad_players']} squad players"
|
||||
)
|
||||
|
||||
identity = json.load(open(os.path.join(dest, SNAP_IDENTITY)))
|
||||
rows = identity.get("rows", [])
|
||||
owned_rows = [r for r in rows if r.get("entity_kind") == "owned-item"]
|
||||
conn = sqlite3.connect(f"file:{snap_db}?mode=ro", uri=True)
|
||||
try:
|
||||
owned_ids = {r[0] for r in conn.execute("SELECT id FROM owned_cards")}
|
||||
finally:
|
||||
conn.close()
|
||||
unmapped = owned_ids - {r["core_id"] for r in owned_rows}
|
||||
if unmapped:
|
||||
raise Fatal(
|
||||
f"{len(unmapped)} owned cards have no wire id in the identity store; "
|
||||
"the client would see them appear under freshly minted ids and its "
|
||||
f"cached squad would break. Sample: {sorted(unmapped)[:5]}"
|
||||
)
|
||||
ok(
|
||||
f"identity store maps all {len(owned_ids)} owned cards to stable wire "
|
||||
f"ids ({len(owned_rows)} rows, watermarks {identity.get('watermarks')})"
|
||||
)
|
||||
|
||||
manifest = {
|
||||
"taken_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"source_dir": PROD_STATE,
|
||||
"sources": {
|
||||
os.path.basename(p): {"sha256": before[p], "bytes": os.path.getsize(p)}
|
||||
for p in before
|
||||
},
|
||||
"members": {
|
||||
"core_db": SNAP_CORE_DB,
|
||||
"identity": SNAP_IDENTITY,
|
||||
"clientdata": SNAP_CLIENTDATA,
|
||||
},
|
||||
"club": copy_facts,
|
||||
"identity_rows": len(rows),
|
||||
"watermarks": identity.get("watermarks"),
|
||||
}
|
||||
with open(os.path.join(dest, SNAP_MANIFEST), "w") as fh:
|
||||
json.dump(manifest, fh, indent=2)
|
||||
fh.write("\n")
|
||||
ok(f"manifest written: {os.path.join(dest, SNAP_MANIFEST)}")
|
||||
|
||||
after_state = production_state()
|
||||
ok("production after : " + ", ".join(after_state.describe()))
|
||||
|
||||
banner("SNAPSHOT READY")
|
||||
print(f" {dest}")
|
||||
print()
|
||||
print(" Install it into staging with:")
|
||||
print(" python3 scripts/sold-staging-up.py --club real "
|
||||
"--variant highest \\")
|
||||
print(" --roster-host winter15.gosredirector.ea.com:8081")
|
||||
return 0
|
||||
except ProductionError as exc:
|
||||
print(f"\nFATAL: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Runtime identity of the PRODUCTION FIFA-17 stack, shared by the staging
|
||||
lifecycle scripts.
|
||||
|
||||
This exists because both `sold-staging-up.py` and `sold-staging-down.py` need the
|
||||
same answer to the same safety question -- "what is production right now, and is
|
||||
it still healthy?" -- and two hand-maintained copies of a safety gate is two
|
||||
chances to rot.
|
||||
|
||||
Production runs in containers, so its pids are NOT stable facts: every pid changes
|
||||
when a container is restarted. A hardcoded pid list decays into the worst of both
|
||||
worlds -- a kill-refusal gate that guards nothing (the real production pids are no
|
||||
longer in it) and a liveness gate that fails a perfectly good teardown (the pids it
|
||||
does list are long dead). So pids and published ports are both resolved from the
|
||||
container runtime at the moment they are needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
# Production containers. Anything running inside one of these is production.
|
||||
PROD_CONTAINERS = ("openfut-fut-backend", "openfut-bridge-1", "openfut-core-1")
|
||||
|
||||
# Reserved ports: staging may never bind one of these, whether or not it is
|
||||
# currently published. 8199 (Python oracle) and 18080 (Core) belonged to the
|
||||
# retired host-process deployment and are kept so an old port map cannot be
|
||||
# silently reused by staging.
|
||||
PROD_PORTS = frozenset(
|
||||
{8080, 8081, 8085, 8094, 8099, 8199, 8443, 4216, 18080, 42127, 42130, 42131}
|
||||
)
|
||||
|
||||
|
||||
class ProductionError(RuntimeError):
|
||||
"""Production could not be observed, or is not healthy."""
|
||||
|
||||
|
||||
def _inspect(container: str, template: str) -> str:
|
||||
"""One `docker inspect -f` field.
|
||||
|
||||
Any failure is fatal by design: a script that cannot see production must
|
||||
refuse to signal anything rather than assume the best.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "inspect", "-f", template, container],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise ProductionError(
|
||||
f"cannot inspect production container {container!r}: {exc}"
|
||||
) from exc
|
||||
if result.returncode != 0:
|
||||
detail = result.stderr.strip() or f"docker exited {result.returncode}"
|
||||
raise ProductionError(
|
||||
f"cannot inspect production container {container!r}: {detail}"
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def listening_ports() -> set[int]:
|
||||
"""Ports in state LISTEN, from the kernel socket table.
|
||||
|
||||
A trial bind() would report EADDRINUSE for a stopped server's TIME_WAIT
|
||||
sockets and so wrongly claim a port is still served.
|
||||
"""
|
||||
ports: set[int] = set()
|
||||
for path in ("/proc/net/tcp", "/proc/net/tcp6"):
|
||||
try:
|
||||
with open(path) as fh:
|
||||
next(fh, None) # header
|
||||
for line in fh:
|
||||
fields = line.split()
|
||||
if len(fields) < 4 or fields[3] != "0A": # TCP_LISTEN
|
||||
continue
|
||||
ports.add(int(fields[1].rsplit(":", 1)[1], 16))
|
||||
except OSError:
|
||||
continue
|
||||
return ports
|
||||
|
||||
|
||||
class ProductionState:
|
||||
"""A snapshot of production as the container runtime reports it."""
|
||||
|
||||
__slots__ = ("pids", "published")
|
||||
|
||||
def __init__(self, pids: dict[int, str], published: dict[int, str]) -> None:
|
||||
self.pids = pids
|
||||
self.published = published
|
||||
|
||||
def describe(self) -> list[str]:
|
||||
return [f"{what} pid {pid}" for pid, what in sorted(self.pids.items())]
|
||||
|
||||
def assert_serving(self) -> None:
|
||||
"""Every port production publishes must actually be listening."""
|
||||
listening = listening_ports()
|
||||
silent = sorted(port for port in self.published if port not in listening)
|
||||
if silent:
|
||||
raise ProductionError(
|
||||
"production port(s) no longer listening: "
|
||||
+ ", ".join(f"{port} ({self.published[port]})" for port in silent)
|
||||
)
|
||||
|
||||
def assert_unchanged(self, before: "ProductionState") -> None:
|
||||
"""Production must be the same processes serving the same ports."""
|
||||
if self.pids != before.pids:
|
||||
raise ProductionError(
|
||||
f"production pids CHANGED: before={before.pids}, after={self.pids}"
|
||||
)
|
||||
if set(self.published) != set(before.published):
|
||||
raise ProductionError(
|
||||
"production published ports CHANGED: "
|
||||
f"before={sorted(before.published)}, after={sorted(self.published)}"
|
||||
)
|
||||
|
||||
|
||||
def production_state() -> ProductionState:
|
||||
"""Resolve production's current pids and published ports, proving every
|
||||
production container is running."""
|
||||
pids: dict[int, str] = {}
|
||||
published: dict[int, str] = {}
|
||||
for container in PROD_CONTAINERS:
|
||||
status = _inspect(container, "{{.State.Status}}")
|
||||
if status != "running":
|
||||
raise ProductionError(
|
||||
f"production container {container} is {status!r}, not running"
|
||||
)
|
||||
pid = int(_inspect(container, "{{.State.Pid}}") or 0)
|
||||
if pid <= 0:
|
||||
raise ProductionError(
|
||||
f"production container {container} is running but reports no pid"
|
||||
)
|
||||
pids[pid] = f"prod {container}"
|
||||
ports = _inspect(
|
||||
container,
|
||||
"{{range $port, $bindings := .NetworkSettings.Ports}}"
|
||||
"{{range $bindings}}{{.HostPort}} {{end}}{{end}}",
|
||||
)
|
||||
for field in ports.split():
|
||||
published[int(field)] = container
|
||||
return ProductionState(pids, published)
|
||||
@@ -10,10 +10,12 @@ ones. So there is no pattern matching here at all:
|
||||
* before any signal, /proc/<pid>/cmdline is read and MUST contain the staging
|
||||
directory -- production's cmdline never can, because staging runs binaries
|
||||
copied into that directory;
|
||||
* the known production pids are refused explicitly, as a second gate;
|
||||
* production's CURRENT pids, resolved from the container runtime, are refused
|
||||
explicitly as a second gate;
|
||||
* only the process GROUP the up script created (pgid == pid, via
|
||||
start_new_session) is signalled, so a responder thread/child cannot be orphaned;
|
||||
* afterwards every staging port is proven free and production is proven alive.
|
||||
* afterwards every staging port is proven free, and production is proven to be
|
||||
the same running containers serving the same ports as before.
|
||||
|
||||
python3 scripts/sold-staging-down.py
|
||||
python3 scripts/sold-staging-down.py --purge # also delete the staging dir
|
||||
@@ -29,20 +31,20 @@ import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from openfut_production import ( # noqa: E402
|
||||
PROD_PORTS,
|
||||
ProductionError,
|
||||
listening_ports,
|
||||
production_state,
|
||||
)
|
||||
|
||||
DEFAULT_STAGING_DIR = "/home/alex/openfut-sold-staging"
|
||||
|
||||
FORBIDDEN_PATHS = ("/home/alex/openfut-promotion/state",)
|
||||
# Production processes that MUST be alive before and after this script runs. These
|
||||
# two are the ones the batch contract names, and they live in the host pid view.
|
||||
PROD_PIDS = {3631953: "prod utas-host", 3374264: "prod Core"}
|
||||
# Reported but not gated: container pids change when the operator restarts the
|
||||
# container, and a stale entry here would turn a successful teardown into a FATAL.
|
||||
PROD_PIDS_INFO = {2090886: "prod blaze", 2091170: "prod python oracle",
|
||||
2090888: "prod pow"}
|
||||
PROD_PORTS = (8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216, 8080, 8081, 8094)
|
||||
|
||||
|
||||
class Fatal(RuntimeError):
|
||||
class Fatal(ProductionError):
|
||||
pass
|
||||
|
||||
|
||||
@@ -87,37 +89,17 @@ def cmdline_of(pid: int) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def listening_ports() -> set[int]:
|
||||
"""Ports in state LISTEN, from the kernel socket table. A trial bind() would
|
||||
report EADDRINUSE for a stopped server's TIME_WAIT sockets and wrongly claim the
|
||||
teardown failed."""
|
||||
ports: set[int] = set()
|
||||
for path in ("/proc/net/tcp", "/proc/net/tcp6"):
|
||||
try:
|
||||
with open(path) as fh:
|
||||
next(fh, None) # header
|
||||
for line in fh:
|
||||
fields = line.split()
|
||||
if len(fields) < 4 or fields[3] != "0A": # TCP_LISTEN
|
||||
continue
|
||||
ports.add(int(fields[1].rsplit(":", 1)[1], 16))
|
||||
except OSError:
|
||||
continue
|
||||
return ports
|
||||
|
||||
|
||||
def port_free(port: int) -> bool:
|
||||
return port not in listening_ports()
|
||||
|
||||
|
||||
def stop_one(rec: dict, staging_dir: str) -> str:
|
||||
def stop_one(rec: dict, staging_dir: str, prod_pids: dict[int, str]) -> str:
|
||||
"""Stop exactly one recorded process. Returns a human-readable outcome."""
|
||||
name, pid = rec["name"], int(rec["pid"])
|
||||
|
||||
known_prod = {**PROD_PIDS, **PROD_PIDS_INFO}
|
||||
if pid in known_prod:
|
||||
if pid in prod_pids:
|
||||
raise Fatal(
|
||||
f"manifest entry {name} names PRODUCTION pid {pid} ({known_prod[pid]}). "
|
||||
f"manifest entry {name} names PRODUCTION pid {pid} ({prod_pids[pid]}). "
|
||||
"REFUSING to signal anything from this manifest."
|
||||
)
|
||||
if not pid_alive(pid):
|
||||
@@ -192,8 +174,14 @@ def main() -> int:
|
||||
)
|
||||
step(f"variant : {manifest.get('variant')}")
|
||||
|
||||
# Resolved BEFORE anything is signalled: the refusal gate below is only
|
||||
# meaningful if it knows production's pids as they are right now.
|
||||
before = production_state()
|
||||
for line in before.describe():
|
||||
step(f"production : {line}")
|
||||
|
||||
for rec in manifest.get("processes", []):
|
||||
ok(stop_one(rec, staging_dir))
|
||||
ok(stop_one(rec, staging_dir, before.pids))
|
||||
|
||||
banner("PROVE STAGING IS GONE")
|
||||
ports = manifest.get("ports", {})
|
||||
@@ -214,16 +202,13 @@ def main() -> int:
|
||||
ok("no recorded staging process is alive")
|
||||
|
||||
banner("PROVE PRODUCTION IS STILL UP")
|
||||
dead = [f"{what} pid {pid}" for pid, what in PROD_PIDS.items()
|
||||
if not pid_alive(pid)]
|
||||
for pid, what in PROD_PIDS.items():
|
||||
if pid_alive(pid):
|
||||
ok(f"{what} pid {pid} alive")
|
||||
if dead:
|
||||
raise Fatal("production process(es) NOT alive: " + ", ".join(dead))
|
||||
for pid, what in PROD_PIDS_INFO.items():
|
||||
state = "alive" if pid_alive(pid) else "not found (informational only)"
|
||||
step(f"{what} pid {pid} {state}")
|
||||
after = production_state()
|
||||
for line in after.describe():
|
||||
ok(f"{line} alive")
|
||||
after.assert_unchanged(before)
|
||||
after.assert_serving()
|
||||
ok(f"all {len(after.published)} published production ports still listening: "
|
||||
+ ", ".join(str(port) for port in sorted(after.published)))
|
||||
|
||||
if args.purge:
|
||||
shutil.rmtree(safe_path(staging_dir), ignore_errors=True)
|
||||
@@ -243,7 +228,7 @@ def main() -> int:
|
||||
print()
|
||||
print(" then RELAUNCH the FIFA 17 client. See docs/SOLD_STAGING_RUNBOOK.md.")
|
||||
return 0
|
||||
except Fatal as exc:
|
||||
except ProductionError as exc:
|
||||
print(f"\nFATAL: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
@@ -21,11 +21,20 @@ answers `{"id": 0}` and does NOT echo the squad.
|
||||
|
||||
python3 scripts/sold-staging-seed-squad.py # seed, then verify
|
||||
python3 scripts/sold-staging-seed-squad.py --show # read-only
|
||||
|
||||
This is a FIXTURE tool. `PUT /squad/0` is a FULL REPLACEMENT, so running it against
|
||||
the operator's real imported club would overwrite that club's own squad with an
|
||||
auto-picked XI -- a destructive, unrecoverable-in-place edit of real data. It
|
||||
therefore refuses to run when the staging manifest says the real club is installed,
|
||||
unless `--force` is given. `--show` stays available in every mode.
|
||||
"""
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
STAGING_MANIFEST = "/home/alex/openfut-sold-staging/manifest.json"
|
||||
|
||||
HOST, PORT = "127.0.0.1", 8299
|
||||
FORBIDDEN = {8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216}
|
||||
HEADERS = {"X-OpenFUT-Game": "fifa17", "Content-Type": "application/json"}
|
||||
@@ -153,11 +162,37 @@ def show():
|
||||
return len(filled)
|
||||
|
||||
|
||||
def refuse_on_real_club():
|
||||
"""`PUT /squad/0` replaces the whole squad. Against the real club that destroys
|
||||
the operator's own lineup, so the fixture seeder must not be runnable there by
|
||||
accident -- the staging manifest records which club is installed."""
|
||||
if "--force" in sys.argv:
|
||||
print(" --force: seeding over the installed club as instructed")
|
||||
return
|
||||
try:
|
||||
with open(STAGING_MANIFEST) as fh:
|
||||
manifest = json.load(fh)
|
||||
except FileNotFoundError:
|
||||
return # no manifest: nothing claims a real club is installed
|
||||
if manifest.get("club") != "real":
|
||||
return
|
||||
club = manifest.get("real_club") or {}
|
||||
raise SystemExit(
|
||||
"REFUSING: staging is running the operator's REAL club "
|
||||
f"({club.get('username')}, {club.get('owned_cards')} items) and "
|
||||
"PUT /squad/0 is a FULL REPLACEMENT -- this would overwrite the real squad "
|
||||
f"({club.get('squad_players')} players) with an auto-picked XI.\n"
|
||||
" Read it instead: python3 scripts/sold-staging-seed-squad.py --show\n"
|
||||
" Override only if you mean it: --force"
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
print("=== staging squad, before ===")
|
||||
before = show()
|
||||
if "--show" in sys.argv:
|
||||
return 0
|
||||
refuse_on_real_club()
|
||||
players = club_players()
|
||||
print(f"=== owned players available: {len(players)} ===")
|
||||
chosen, missing = pick_xi(players)
|
||||
|
||||
+558
-83
@@ -12,10 +12,10 @@ ONE entry point. It starts, in order:
|
||||
and then prints the three `openfut.cfg` lines the operator must put on the client.
|
||||
Tear the whole thing down with `scripts/sold-staging-down.py`.
|
||||
|
||||
WHY the client only needs Blaze ports: FIFA 17 learns the UTAS base URL from Blaze
|
||||
(`blaze_responder_v3b.py`'s `UTAS_BASE`, where the `8099` is HARDCODED). This script
|
||||
copies the responder into the staging dir and rewrites that literal to the staging
|
||||
UTAS port, so pointing the client at staging Blaze is sufficient to move UTAS too.
|
||||
WHY the client only needs Blaze ports: FIFA 17 learns both the UTAS base URL and
|
||||
the roster URL from Blaze. This script copies the responder into the staging dir,
|
||||
rewrites the hardcoded UTAS port, and passes the independently configurable roster
|
||||
host through `OPENFUT_ROSTER_HOST`.
|
||||
|
||||
ISOLATION, enforced not assumed:
|
||||
* production ports 8099 8199 18080 8443 42127 42130 42131 4216 8080 8081 8094 are
|
||||
@@ -33,10 +33,20 @@ ISOLATION, enforced not assumed:
|
||||
edits `openfut.cfg` by hand, using the block this script prints.
|
||||
|
||||
Read-only reuse of production: staging Blaze advertises the production roster
|
||||
(10.10.0.120:8081) and POW content/API hosts (10.10.0.120:8085 / :8094) verbatim, the
|
||||
same values production Blaze advertises. Those services hold NO economy state (roster
|
||||
XML and POW content are static), staging never connects to them itself -- it only
|
||||
hands the client the same strings -- and they are therefore shared deliberately.
|
||||
service through a certificate dNSName (`winter15.gosredirector.ea.com:8081` by
|
||||
default) and advertises the production POW content/API hosts
|
||||
(`10.10.0.120:8085` / `:8094`) verbatim. These services hold NO economy state.
|
||||
The client must resolve the roster hostname to this server without changing the
|
||||
advertised URL.
|
||||
|
||||
WHICH CLUB the seller plays with is chosen by `--club`:
|
||||
* `fixture` (default) seeds a 14-item synthetic club -- 11 starters, one
|
||||
disposable item to sell, two kits -- which is all the SOLD experiment needs;
|
||||
* `real` installs the operator's own imported club from the snapshot produced by
|
||||
`scripts/club-snapshot.py`, including its coins, its squad and the identity
|
||||
store that keeps every item's wire id stable. This script still never reads
|
||||
production state: the snapshot lives outside it, which is what makes the
|
||||
`safe_path()` refusal above compatible with playing the real club.
|
||||
|
||||
python3 scripts/sold-staging-up.py --variant highest
|
||||
python3 scripts/sold-staging-up.py --variant buyNow --coins-processed 1 \
|
||||
@@ -59,16 +69,24 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from openfut_production import ( # noqa: E402
|
||||
PROD_PORTS,
|
||||
ProductionError,
|
||||
ProductionState,
|
||||
listening_ports,
|
||||
production_state,
|
||||
)
|
||||
|
||||
# --- fixed facts -------------------------------------------------------------------
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Production. Never bind, never connect, never open.
|
||||
FORBIDDEN_PORTS = frozenset(
|
||||
{8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216, 8080, 8081, 8094}
|
||||
)
|
||||
# Production. Never bind, never connect, never open. Production's pids are NOT
|
||||
# listed here: they live in containers and change on every restart, so they are
|
||||
# resolved from the container runtime by openfut_production.production_state().
|
||||
FORBIDDEN_PORTS = PROD_PORTS
|
||||
FORBIDDEN_PATHS = ("/home/alex/openfut-promotion/state",)
|
||||
PROD_PIDS = {3631953: "prod utas-host", 3374264: "prod Core"}
|
||||
|
||||
# The staging port block. One obvious place; every one of these is asserted free.
|
||||
# 42227 (the first choice for the redirector) is permanently occupied by
|
||||
@@ -98,6 +116,7 @@ ADVERTISE = "10.10.0.120"
|
||||
BIND = "0.0.0.0"
|
||||
POW_CONTENT_HOST = "10.10.0.120:8085"
|
||||
POW_HOST = "10.10.0.120:8094"
|
||||
DEFAULT_ROSTER_HOST = "winter15.gosredirector.ea.com:8081"
|
||||
|
||||
BLAZE_SRC = os.path.join(REPO, "fifa17-recon", "tools", "blaze_responder_v3b.py")
|
||||
BLAZE_ASSETS = ("redir_cert.pem", "redir_key.pem")
|
||||
@@ -109,6 +128,16 @@ CONTENT_SRC = "/home/alex/openfut-post-p1/staging/emit/content"
|
||||
CARDS_NAME = "fifa17-production-cards.json"
|
||||
CATALOG_NAME = "fifa17-production-catalog.json"
|
||||
|
||||
# The operator's REAL club (the 1986-item CAGE import), as staged by
|
||||
# scripts/club-snapshot.py. That snapshot lives OUTSIDE the production state
|
||||
# directory precisely so this script never has to read production state:
|
||||
# FORBIDDEN_PATHS stays absolute and safe_path() still refuses the live directory.
|
||||
CLUB_SNAPSHOT_DIR = "/home/alex/openfut-club-snapshot"
|
||||
SNAP_CORE_DB = "core.db"
|
||||
SNAP_IDENTITY = "identity.json"
|
||||
SNAP_CLIENTDATA = "clientdata.json"
|
||||
SNAP_MANIFEST = "snapshot.json"
|
||||
|
||||
GAME = "fifa17"
|
||||
PERSONA_ID = "33068179"
|
||||
PERSONA_NAME = "CAGE"
|
||||
@@ -147,6 +176,38 @@ SELLER_SQUAD_CARDS = [
|
||||
DISPOSABLE_ITEM = "owned-a-disposable"
|
||||
DISPOSABLE_CARD = "fifa17_232273" # Nelson Atiagli LB 51, rareflag 1
|
||||
|
||||
# Two authoritative modern kit-card definitions for one real source team. These
|
||||
# are staging fixtures derived from fcc_kitcards, not synthetic FIFA identities.
|
||||
KIT_TEAM_ID = 21
|
||||
STAGING_KITS = [
|
||||
("home", "owned-a-kit-home", "fifa17_6300006", 6_300_006),
|
||||
("away", "owned-a-kit-away", "fifa17_6400003", 6_400_003),
|
||||
]
|
||||
|
||||
# The club manager. FIFA refuses to start a match without one ("your player or
|
||||
# managers contracts have expired"), and NEITHER club owns a manager: the real
|
||||
# import has 1992 players and exactly 3 staff items, all coaches (2 fitness, 1 GK),
|
||||
# which the client's own club/stats confirms with staffManager:0. So a manager is
|
||||
# minted here rather than restored.
|
||||
#
|
||||
# Every value below is read out of the client's OWN tables, never invented:
|
||||
# managercards.carddbid 1000509 (assetid == carddbid on all 417 rows)
|
||||
# managercards.nation 45 -> the flag and the nation half of chemistry
|
||||
# manager[509].surname "Luis Enrique", teamid 241
|
||||
# leagueteamlinks[241].leagueid 53 -> the badge and the league half of chemistry
|
||||
# League 53 is also the dominant league in the restored squad (12 of 23 players),
|
||||
# so this manager is the chemistry-correct choice for it, not an arbitrary one.
|
||||
MANAGER_SUBTYPE = 4
|
||||
STAGING_MANAGER = {
|
||||
"owned_id": "owned-a-manager",
|
||||
"card_id": "fifa17_1000509",
|
||||
"resource_id": 1_000_509,
|
||||
"nation": 45,
|
||||
"league_id": 53,
|
||||
"team_id": 241,
|
||||
"label": "Luis Enrique",
|
||||
}
|
||||
|
||||
READY_TIMEOUT_S = 60.0
|
||||
|
||||
|
||||
@@ -168,7 +229,7 @@ def ok(msg: str) -> None:
|
||||
print(f" [ OK ] {msg}")
|
||||
|
||||
|
||||
class Fatal(RuntimeError):
|
||||
class Fatal(ProductionError):
|
||||
"""Anything that must abort bring-up loudly rather than degrade."""
|
||||
|
||||
|
||||
@@ -190,31 +251,12 @@ def check_port_allowed(port: int, what: str) -> None:
|
||||
raise Fatal(f"REFUSING: {what} port {port} is a PRODUCTION port")
|
||||
|
||||
|
||||
def listening_ports() -> set[int]:
|
||||
"""Every TCP port in state LISTEN in this network namespace, read straight from
|
||||
the kernel socket table.
|
||||
|
||||
A trial bind() is the wrong test: after a server exits, its accepted sockets sit
|
||||
in TIME_WAIT holding the same local port, so bind() reports EADDRINUSE for a
|
||||
minute even though nothing is serving -- and every server here sets SO_REUSEADDR
|
||||
and would bind fine. This mirrors `ss -ltn` (and host-lifecycle.sh's
|
||||
hl_port_listening), which is the question actually being asked."""
|
||||
ports: set[int] = set()
|
||||
for path in ("/proc/net/tcp", "/proc/net/tcp6"):
|
||||
try:
|
||||
with open(path) as fh:
|
||||
next(fh, None) # header
|
||||
for line in fh:
|
||||
fields = line.split()
|
||||
if len(fields) < 4 or fields[3] != "0A": # TCP_LISTEN
|
||||
continue
|
||||
ports.add(int(fields[1].rsplit(":", 1)[1], 16))
|
||||
except OSError:
|
||||
continue
|
||||
return ports
|
||||
|
||||
|
||||
def port_free(port: int) -> bool:
|
||||
"""A trial bind() is the wrong test: after a server exits, its accepted sockets
|
||||
sit in TIME_WAIT holding the same local port, so bind() reports EADDRINUSE for a
|
||||
minute even though nothing is serving -- and every server here sets SO_REUSEADDR
|
||||
and would bind fine. listening_ports() mirrors `ss -ltn` (and
|
||||
host-lifecycle.sh's hl_port_listening), which is the question actually asked."""
|
||||
return port not in listening_ports()
|
||||
|
||||
|
||||
@@ -261,14 +303,25 @@ def cmdline_of(pid: int) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
_PROD_BASELINE: ProductionState | None = None
|
||||
|
||||
|
||||
def assert_prod_alive(where: str) -> None:
|
||||
for pid, what in PROD_PIDS.items():
|
||||
if not pid_alive(pid):
|
||||
raise Fatal(f"{what} pid {pid} is NOT alive at {where} -- stop and investigate")
|
||||
ok(
|
||||
f"production untouched at {where}: "
|
||||
+ ", ".join(f"{what} pid {pid} alive" for pid, what in PROD_PIDS.items())
|
||||
)
|
||||
"""Prove production is untouched.
|
||||
|
||||
The first call records the baseline; every later call must observe the SAME
|
||||
container pids publishing the SAME ports, and every published port must still
|
||||
be listening. Pids are read from the container runtime each time because a
|
||||
restarted container gets a new one.
|
||||
"""
|
||||
global _PROD_BASELINE
|
||||
state = production_state()
|
||||
state.assert_serving()
|
||||
if _PROD_BASELINE is None:
|
||||
_PROD_BASELINE = state
|
||||
else:
|
||||
state.assert_unchanged(_PROD_BASELINE)
|
||||
ok(f"production untouched at {where}: " + ", ".join(state.describe()))
|
||||
|
||||
|
||||
# --- HTTP ---------------------------------------------------------------------------
|
||||
@@ -437,27 +490,172 @@ def materialise(lay: Layout) -> None:
|
||||
shutil.copy2(safe_path(src), safe_path(dst))
|
||||
ok(f"FIFA17 content copied from {CONTENT_SRC} (outside production state)")
|
||||
|
||||
with open(safe_path(lay.cards)) as fh:
|
||||
definitions = json.load(fh)
|
||||
with open(safe_path(lay.catalog)) as fh:
|
||||
catalog = json.load(fh)
|
||||
existing = {definition["id"] for definition in definitions}
|
||||
for _slot, _owned_id, card_id, resource_id in STAGING_KITS:
|
||||
if card_id not in existing:
|
||||
definitions.append({
|
||||
"id": card_id,
|
||||
"name": "Kit",
|
||||
"overall": 0,
|
||||
"position": "",
|
||||
"nation": "",
|
||||
"league": "",
|
||||
"club": "",
|
||||
"pace": 0,
|
||||
"shooting": 0,
|
||||
"passing": 0,
|
||||
"dribbling": 0,
|
||||
"defending": 0,
|
||||
"physical": 0,
|
||||
"rarity": "bronze",
|
||||
"image_path": None,
|
||||
})
|
||||
catalog["cards"][card_id] = {
|
||||
"asset_id": resource_id,
|
||||
"version": 0,
|
||||
"rareflag": 0,
|
||||
"kind": "kit",
|
||||
"subtype": 9,
|
||||
"card_asset_id": 35,
|
||||
"team_id": KIT_TEAM_ID,
|
||||
}
|
||||
|
||||
def assert_seed_cards_resolvable(lay: Layout) -> None:
|
||||
mgr = STAGING_MANAGER
|
||||
if mgr["card_id"] not in existing:
|
||||
definitions.append({
|
||||
"id": mgr["card_id"],
|
||||
"name": mgr["label"],
|
||||
"overall": 0,
|
||||
"position": "",
|
||||
"nation": "",
|
||||
"league": "",
|
||||
"club": "",
|
||||
"pace": 0,
|
||||
"shooting": 0,
|
||||
"passing": 0,
|
||||
"dribbling": 0,
|
||||
"defending": 0,
|
||||
"physical": 0,
|
||||
"rarity": "bronze",
|
||||
"image_path": None,
|
||||
})
|
||||
# `version` MUST stay 0 and `asset_id` MUST be the raw carddbid: the client's
|
||||
# managercards merge reads the wire resourceId as a u32 WITHOUT masking off a
|
||||
# version byte (players are the only family that is masked), and the manager
|
||||
# branch has no else-arm, so a wrong key fails silently with a blank card.
|
||||
catalog["cards"][mgr["card_id"]] = {
|
||||
"asset_id": mgr["resource_id"],
|
||||
"version": 0,
|
||||
"rareflag": 0,
|
||||
"kind": "staff",
|
||||
"subtype": MANAGER_SUBTYPE,
|
||||
"team_id": mgr["team_id"],
|
||||
"nation": mgr["nation"],
|
||||
"league_id": mgr["league_id"],
|
||||
}
|
||||
with open(safe_path(lay.cards), "w") as fh:
|
||||
json.dump(definitions, fh, indent=2)
|
||||
fh.write("\n")
|
||||
with open(safe_path(lay.catalog), "w") as fh:
|
||||
json.dump(catalog, fh, indent=2)
|
||||
fh.write("\n")
|
||||
ok(
|
||||
"added ownership-backed home/away kit fixtures from fcc_kitcards, and the "
|
||||
f"manager {STAGING_MANAGER['label']} (carddbid "
|
||||
f"{STAGING_MANAGER['resource_id']}, nation {STAGING_MANAGER['nation']}, "
|
||||
f"league {STAGING_MANAGER['league_id']}) from managercards"
|
||||
)
|
||||
|
||||
|
||||
def install_real_club(lay: Layout) -> dict:
|
||||
"""Install the operator's real club from the snapshot in place of the fixture.
|
||||
|
||||
The snapshot database is a schema generation behind (it predates
|
||||
match_completions, squad managers and kit assignments), so it is installed
|
||||
BEFORE migrate_core and Core brings the COPY forward. Production's own files
|
||||
are never opened here: scripts/club-snapshot.py already took them out, which is
|
||||
why this script can keep refusing the production state directory outright.
|
||||
"""
|
||||
manifest_path = os.path.join(CLUB_SNAPSHOT_DIR, SNAP_MANIFEST)
|
||||
if not os.path.isfile(manifest_path):
|
||||
raise Fatal(
|
||||
f"no club snapshot at {CLUB_SNAPSHOT_DIR}.\n"
|
||||
" Take one first (it is read-only on production state):\n"
|
||||
" python3 scripts/club-snapshot.py"
|
||||
)
|
||||
with open(manifest_path) as fh:
|
||||
manifest = json.load(fh)
|
||||
for name, dst in ((SNAP_CORE_DB, lay.core_db),
|
||||
(SNAP_IDENTITY, lay.identity),
|
||||
(SNAP_CLIENTDATA, lay.clientdata)):
|
||||
src = os.path.join(CLUB_SNAPSHOT_DIR, name)
|
||||
if not os.path.isfile(src):
|
||||
raise Fatal(f"club snapshot is incomplete: missing {src}")
|
||||
shutil.copy2(src, safe_path(dst))
|
||||
os.chmod(safe_path(dst), 0o644)
|
||||
club = manifest["club"]
|
||||
ok(
|
||||
f"installed the real club from {CLUB_SNAPSHOT_DIR} (taken "
|
||||
f"{manifest['taken_at']}): {club['username']}, {club['owned_cards']} items, "
|
||||
f"{club['coins']:,} coins, schema v{club['schema_version']}"
|
||||
)
|
||||
ok(
|
||||
"wire ids come from the snapshot identity store, so item ids the client "
|
||||
f"already cached still resolve ({manifest['identity_rows']} rows, "
|
||||
f"watermarks {manifest['watermarks']})"
|
||||
)
|
||||
return club
|
||||
|
||||
|
||||
def assert_seed_cards_resolvable(lay: Layout, real_club: dict | None) -> None:
|
||||
"""Core's content preflight rejects any owned card whose card_id is not a loaded
|
||||
CardDefinition, and the host refuses to shape a /club item with no catalog
|
||||
identity. Prove BOTH memberships now, not via a startup crash later."""
|
||||
identity. Prove BOTH memberships now, not via a startup crash later.
|
||||
|
||||
For the real club this is the check that matters most: a card_id missing from
|
||||
the pack is not an error at read time, it is silently filter_map-dropped, so the
|
||||
symptom is an EMPTY /collection with all the rows still sitting in the database.
|
||||
"""
|
||||
with open(safe_path(lay.cards)) as fh:
|
||||
pack_ids = {c["id"] for c in json.load(fh)}
|
||||
with open(safe_path(lay.catalog)) as fh:
|
||||
catalog_ids = set(json.load(fh)["cards"])
|
||||
wanted = [c for _, c in SELLER_SQUAD_CARDS] + [DISPOSABLE_CARD]
|
||||
missing_pack = sorted(set(wanted) - pack_ids)
|
||||
missing_cat = sorted(set(wanted) - catalog_ids)
|
||||
if real_club is None:
|
||||
wanted = set(
|
||||
[card for _, card in SELLER_SQUAD_CARDS]
|
||||
+ [DISPOSABLE_CARD]
|
||||
+ [card for _, _, card, _ in STAGING_KITS]
|
||||
+ [STAGING_MANAGER["card_id"]]
|
||||
)
|
||||
what = f"all {len(wanted)} fixture seed card ids"
|
||||
else:
|
||||
conn = sqlite3.connect(f"file:{safe_path(lay.core_db)}?mode=ro", uri=True)
|
||||
try:
|
||||
wanted = {row[0] for row in
|
||||
conn.execute("SELECT DISTINCT card_id FROM owned_cards")}
|
||||
finally:
|
||||
conn.close()
|
||||
wanted |= {card for _, _, card, _ in STAGING_KITS}
|
||||
wanted.add(STAGING_MANAGER["card_id"])
|
||||
what = (f"all {len(wanted)} distinct card ids owned by the real club "
|
||||
"(plus the kit and manager fixtures)")
|
||||
missing_pack = sorted(wanted - pack_ids)
|
||||
missing_cat = sorted(wanted - catalog_ids)
|
||||
if missing_pack or missing_cat:
|
||||
raise Fatal(
|
||||
"seed card ids are not resolvable -- Core or the host would fail at "
|
||||
f"startup.\n absent from content pack: {missing_pack}"
|
||||
f"\n absent from identity catalog: {missing_cat}"
|
||||
"card ids are not resolvable. Core drops an owned card whose definition "
|
||||
"is missing instead of failing, so this would surface as an EMPTY club "
|
||||
"with every row still in the database.\n"
|
||||
f" absent from content pack ({len(missing_pack)}): {missing_pack[:10]}\n"
|
||||
f" absent from identity catalog ({len(missing_cat)}): {missing_cat[:10]}"
|
||||
)
|
||||
ok(
|
||||
f"all {len(wanted)} seed card ids present in BOTH the content pack "
|
||||
f"({len(pack_ids)} defs) and the identity catalog ({len(catalog_ids)} entries)"
|
||||
f"{what} present in BOTH the content pack ({len(pack_ids)} defs) and the "
|
||||
f"identity catalog ({len(catalog_ids)} entries)"
|
||||
)
|
||||
|
||||
|
||||
@@ -550,43 +748,192 @@ def verify_blaze_patch(lay: Layout) -> None:
|
||||
|
||||
|
||||
# --- seeding ------------------------------------------------------------------------
|
||||
# FIFA refuses to kick off unless the squad has 11 starters AND 7 substitutes:
|
||||
# "your squad must have at least 11 players and 7 subs … currently below the
|
||||
# minimum number of players (18)". The imported club's squad has only its 11
|
||||
# starters, so a freshly installed real club is UNPLAYABLE until the bench is
|
||||
# filled. The 23 slots are 0..10 pitch, 11..22 bench/reserves, derived from the
|
||||
# index alone.
|
||||
CLIENT_MIN_SQUAD = 18
|
||||
SQUAD_SLOTS = 23
|
||||
|
||||
|
||||
def seed_core_db(lay: Layout) -> None:
|
||||
"""Two identities by direct SQL, against the schema Core just migrated.
|
||||
def _squad_fingerprint(squad_id: str, formation: str, slots) -> str:
|
||||
"""Recompute Core's canonical squad fingerprint.
|
||||
|
||||
Mirrors `openfut-core/src/services/squad.rs::squad_fingerprint` exactly:
|
||||
`v1|<squad>|<formation>|` plus SORTED `slot:owned:captain:bench` items joined
|
||||
by `;`, hashed with FNV-1a-64 and printed as 16 lowercase hex digits.
|
||||
|
||||
Core stamps this whenever the canonical squad changes, and the FIFA17 opaque
|
||||
extension is only applied when its stored fingerprint still matches. Writing
|
||||
squad rows directly therefore INVALIDATES the extension, and the host then
|
||||
projects `stale_integrity` — no players and no manager at all. Re-stamping is
|
||||
what a squad replace through Core would have done.
|
||||
"""
|
||||
items = sorted(
|
||||
f"{slot}:{owned}:{int(captain)}:{int(bench)}" for slot, owned, captain, bench in slots
|
||||
)
|
||||
canon = f"v1|{squad_id}|{formation}|" + ";".join(items)
|
||||
h = 0xCBF29CE484222325
|
||||
for b in canon.encode():
|
||||
h = ((h ^ b) * 0x00000100000001B3) & 0xFFFFFFFFFFFFFFFF
|
||||
return f"{h:016x}"
|
||||
|
||||
|
||||
def restamp_squad_extension(conn, squad_id: str) -> bool:
|
||||
"""Bring the stored FIFA17 extension back in step with the canonical squad.
|
||||
|
||||
Returns False when the squad has no extension row (nothing to re-stamp),
|
||||
which is the fixture club's normal state.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT formation FROM squads WHERE id = ?", (squad_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
slots = conn.execute(
|
||||
"SELECT position_index, owned_card_id, is_captain, is_on_bench "
|
||||
"FROM squad_players WHERE squad_id = ?", (squad_id,)
|
||||
).fetchall()
|
||||
fingerprint = _squad_fingerprint(squad_id, row[0], slots)
|
||||
changed = conn.execute(
|
||||
"UPDATE game_entity_ext SET canonical_fingerprint = ? "
|
||||
"WHERE entity_kind = 'squad' AND entity_id = ?",
|
||||
(fingerprint, squad_id),
|
||||
).rowcount
|
||||
return changed > 0
|
||||
|
||||
|
||||
def fill_bench_to_minimum(conn, club_id: str, squad_id: str, lay: Layout) -> int:
|
||||
"""Top the squad up to the client's minimum with the club's best spare
|
||||
players, filling empty bench slots from index 11 upward.
|
||||
|
||||
Only EMPTY slots are written, so the starting XI — and any bench the
|
||||
operator has already chosen — is never touched, and a re-run is a no-op.
|
||||
Only real PLAYER definitions are eligible: a kit, a manager or a consumable
|
||||
in a squad slot would be nonsense, and the client would drop it anyway.
|
||||
"""
|
||||
taken = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
"SELECT owned_card_id FROM squad_players WHERE squad_id = ?", (squad_id,)
|
||||
)
|
||||
}
|
||||
used_slots = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
"SELECT position_index FROM squad_players WHERE squad_id = ?", (squad_id,)
|
||||
)
|
||||
}
|
||||
if len(taken) >= CLIENT_MIN_SQUAD:
|
||||
return 0
|
||||
|
||||
with open(safe_path(lay.catalog)) as fh:
|
||||
catalog = json.load(fh)["cards"]
|
||||
with open(safe_path(lay.cards)) as fh:
|
||||
overall = {c["id"]: c.get("overall", 0) for c in json.load(fh)}
|
||||
|
||||
# Best first, then a stable id tiebreak so a re-run picks the same bench.
|
||||
candidates = [
|
||||
(overall.get(card_id, 0), owned_id, card_id)
|
||||
for owned_id, card_id in conn.execute(
|
||||
"SELECT id, card_id FROM owned_cards WHERE club_id = ? AND is_loan = 0",
|
||||
(club_id,),
|
||||
)
|
||||
if owned_id not in taken
|
||||
and catalog.get(card_id, {}).get("kind", "player") == "player"
|
||||
]
|
||||
candidates.sort(key=lambda c: (-c[0], c[1]))
|
||||
|
||||
free_slots = [i for i in range(11, SQUAD_SLOTS) if i not in used_slots]
|
||||
need = CLIENT_MIN_SQUAD - len(taken)
|
||||
if need > len(free_slots) or need > len(candidates):
|
||||
raise Fatal(
|
||||
f"cannot reach the client's {CLIENT_MIN_SQUAD}-player minimum: need "
|
||||
f"{need} more, but {len(free_slots)} free bench slots and "
|
||||
f"{len(candidates)} eligible spare players"
|
||||
)
|
||||
|
||||
rows = [
|
||||
(f"sp-bench-{slot}", squad_id, owned_id, slot, 0, 1)
|
||||
for slot, (_ovr, owned_id, _card) in zip(free_slots, candidates[:need])
|
||||
]
|
||||
conn.executemany(
|
||||
"INSERT INTO squad_players (id, squad_id, owned_card_id, position_index, "
|
||||
"is_captain, is_on_bench) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
rows,
|
||||
)
|
||||
return len(rows)
|
||||
|
||||
|
||||
|
||||
|
||||
def seed_core_db(lay: Layout, real_club: dict | None) -> None:
|
||||
"""Seed the identities the experiment needs, against the schema Core just
|
||||
migrated.
|
||||
|
||||
Column sets are from openfut-core/migrations/0001_initial.sql plus 0016's
|
||||
profiles.game_id. Seller A carries game_id `fifa17` so the retail client (which
|
||||
sends X-OpenFUT-Game: fifa17) resolves to it; Buyer B is parked on its own
|
||||
game_id so it can never shadow the seller as the active fifa17 profile.
|
||||
|
||||
With the real club installed, Seller A already exists -- it IS the imported
|
||||
persona, with its own club id, coins, items and squad -- so only Buyer B is
|
||||
added, and the kit fixtures are attached to the real club so the kit work stays
|
||||
exercisable. The real squad is never touched: it is the operator's own.
|
||||
"""
|
||||
conn = sqlite3.connect(safe_path(lay.core_db), timeout=15)
|
||||
try:
|
||||
conn.execute("PRAGMA busy_timeout = 15000")
|
||||
with conn:
|
||||
profiles = [(BUYER_PROFILE, "BUYER-B", TS, TS, BUYER_GAME)]
|
||||
clubs = [(BUYER_CLUB, BUYER_PROFILE, "Buyer B FC", BUYER_COINS, TS, TS)]
|
||||
if real_club is None:
|
||||
profiles.insert(0, (SELLER_PROFILE, PERSONA_NAME, TS, TS, GAME))
|
||||
clubs.insert(0, (SELLER_CLUB, SELLER_PROFILE, f"{PERSONA_NAME} FC",
|
||||
SELLER_COINS, TS, TS))
|
||||
conn.executemany(
|
||||
"INSERT INTO profiles (id, username, level, xp, created_at, "
|
||||
"updated_at, game_id) VALUES (?, ?, 1, 0, ?, ?, ?)",
|
||||
[
|
||||
(SELLER_PROFILE, PERSONA_NAME, TS, TS, GAME),
|
||||
(BUYER_PROFILE, "BUYER-B", TS, TS, BUYER_GAME),
|
||||
],
|
||||
profiles,
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO clubs (id, profile_id, name, coins, level, created_at, "
|
||||
"updated_at) VALUES (?, ?, ?, ?, 1, ?, ?)",
|
||||
[
|
||||
(SELLER_CLUB, SELLER_PROFILE, f"{PERSONA_NAME} FC", SELLER_COINS,
|
||||
TS, TS),
|
||||
(BUYER_CLUB, BUYER_PROFILE, "Buyer B FC", BUYER_COINS, TS, TS),
|
||||
],
|
||||
clubs,
|
||||
)
|
||||
|
||||
seller_club = SELLER_CLUB if real_club is None else real_club["club_id"]
|
||||
# Core classifies ownership generically (migration 0025), so every
|
||||
# seeded row states what it IS. Getting this wrong would leave Core
|
||||
# calling a kit a player, and Core is the ownership authority.
|
||||
owned = [
|
||||
(owned_id, seller_club, card_id, "kit", TS)
|
||||
for _slot, owned_id, card_id, _resource_id in STAGING_KITS
|
||||
]
|
||||
owned.append(
|
||||
(
|
||||
STAGING_MANAGER["owned_id"],
|
||||
seller_club,
|
||||
STAGING_MANAGER["card_id"],
|
||||
"manager",
|
||||
TS,
|
||||
)
|
||||
)
|
||||
if real_club is None:
|
||||
owned = (
|
||||
[(item, SELLER_CLUB, card, "player", TS) for item, card in SELLER_SQUAD_CARDS]
|
||||
+ [(DISPOSABLE_ITEM, SELLER_CLUB, DISPOSABLE_CARD, "player", TS)]
|
||||
+ owned
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, "
|
||||
"acquired_at) VALUES (?, ?, ?, 0, ?)",
|
||||
[(item, SELLER_CLUB, card, TS) for item, card in SELLER_SQUAD_CARDS]
|
||||
+ [(DISPOSABLE_ITEM, SELLER_CLUB, DISPOSABLE_CARD, TS)],
|
||||
"content_kind, acquired_at) VALUES (?, ?, ?, 0, ?, ?)",
|
||||
owned,
|
||||
)
|
||||
|
||||
if real_club is None:
|
||||
conn.execute(
|
||||
"INSERT INTO squads (id, club_id, name, formation, created_at, "
|
||||
"updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
@@ -600,12 +947,77 @@ def seed_core_db(lay: Layout) -> None:
|
||||
for idx, (item, _) in enumerate(SELLER_SQUAD_CARDS)
|
||||
],
|
||||
)
|
||||
|
||||
# Core generalised the two-slot kit table into slot-keyed active club
|
||||
# designations (migration 0026), so the fixture writes 'home_kit' /
|
||||
# 'away_kit' rather than 'home' / 'away'.
|
||||
conn.executemany(
|
||||
"INSERT INTO club_active_items (club_id, slot, owned_card_id, updated_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
[
|
||||
(seller_club, f"{slot}_kit", owned_id, TS)
|
||||
for slot, owned_id, _card_id, _resource_id in STAGING_KITS
|
||||
],
|
||||
)
|
||||
|
||||
# Assign the manager to whichever squad the club actually has: the
|
||||
# fixture's own, or the real club's imported squad. Migration 0023
|
||||
# keys squad_managers by squad_id, so the assignment must name a real
|
||||
# squad row rather than the club.
|
||||
squad_row = conn.execute(
|
||||
"SELECT id FROM squads WHERE club_id = ? ORDER BY updated_at DESC "
|
||||
"LIMIT 1", (seller_club,)
|
||||
).fetchone()
|
||||
if squad_row is None:
|
||||
raise Fatal(
|
||||
f"club {seller_club} has no squad, so the manager cannot be "
|
||||
"assigned; FIFA refuses to start a match without one"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO squad_managers (squad_id, owned_card_id, "
|
||||
"updated_at) VALUES (?, ?, ?)",
|
||||
(squad_row[0], STAGING_MANAGER["owned_id"], TS),
|
||||
)
|
||||
|
||||
# Only the REAL club is meant to be played. The 14-item fixture is a
|
||||
# market test bed with a single spare player, so demanding 18 there
|
||||
# would abort a bring-up that never needed to kick off.
|
||||
filled = (
|
||||
fill_bench_to_minimum(conn, seller_club, squad_row[0], lay)
|
||||
if real_club is not None
|
||||
else 0
|
||||
)
|
||||
# Squad rows were just written behind Core's back (bench fill, and
|
||||
# the manager designation above), so the opaque FIFA17 extension is
|
||||
# now stale and the host would drop it — projecting an EMPTY squad.
|
||||
restamped = restamp_squad_extension(conn, squad_row[0])
|
||||
finally:
|
||||
conn.close()
|
||||
if real_club is None:
|
||||
ok(
|
||||
f"seeded Seller A ({PERSONA_NAME}, persona {PERSONA_ID}, {SELLER_COINS} coins, "
|
||||
f"{len(SELLER_SQUAD_CARDS)} starters + 1 disposable) and Buyer B "
|
||||
f"({BUYER_COINS} coins)"
|
||||
f"seeded Seller A ({PERSONA_NAME}, persona {PERSONA_ID}, {SELLER_COINS} "
|
||||
f"coins, {len(SELLER_SQUAD_CARDS)} starters + 1 disposable + "
|
||||
f"{len(STAGING_KITS)} active kits) and Buyer B ({BUYER_COINS} coins)"
|
||||
)
|
||||
else:
|
||||
ok(
|
||||
f"kept the real club untouched ({real_club['owned_cards']} items, "
|
||||
f"{real_club['coins']:,} coins, squad {real_club['squads'][0]['name']!r} "
|
||||
f"with {real_club['squad_players']} players); added "
|
||||
f"{len(STAGING_KITS)} active kits, the manager "
|
||||
f"{STAGING_MANAGER['label']} and Buyer B ({BUYER_COINS} coins)"
|
||||
)
|
||||
if filled:
|
||||
ok(
|
||||
f"filled {filled} empty bench slot(s) with the club's best spare "
|
||||
f"players: FIFA refuses to kick off below {CLIENT_MIN_SQUAD} "
|
||||
"(11 starters + 7 subs), and the imported squad carries only its XI"
|
||||
)
|
||||
if restamped:
|
||||
ok(
|
||||
"re-stamped the squad's FIFA17 extension fingerprint so Core still "
|
||||
"considers it fresh after the squad changed (a stale extension "
|
||||
"projects as an empty squad with no manager)"
|
||||
)
|
||||
|
||||
|
||||
@@ -729,11 +1141,12 @@ def start_host(lay: Layout, variant: str, coins_processed: str, count_mode: str)
|
||||
return Launched("staging-utas-host", proc, lay.host_log, f"{BIND}:{HOST_PORT}")
|
||||
|
||||
|
||||
def start_blaze(lay: Layout) -> Launched:
|
||||
def start_blaze(lay: Layout, roster_host: str) -> Launched:
|
||||
env = dict(
|
||||
os.environ,
|
||||
OPENFUT_ADVERTISE=ADVERTISE,
|
||||
OPENFUT_BIND=BIND,
|
||||
OPENFUT_ROSTER_HOST=roster_host,
|
||||
# Read-only reuse of the production auxiliary services: static content, no
|
||||
# economy state, and staging never connects to them -- it only advertises
|
||||
# the same strings production Blaze advertises.
|
||||
@@ -766,7 +1179,8 @@ def start_blaze(lay: Layout) -> Launched:
|
||||
)
|
||||
ok(
|
||||
f"staging blaze ready: redirector {BLAZE_REDIR_PORT}, main {BLAZE_MAIN_PORT}, "
|
||||
f"nucleus {BLAZE_NUCLEUS_PORT} (pid {proc.pid}); logged {want!r}"
|
||||
f"nucleus {BLAZE_NUCLEUS_PORT} (pid {proc.pid}); roster {roster_host}; "
|
||||
f"logged {want!r}"
|
||||
)
|
||||
return Launched(
|
||||
"staging-blaze", proc, lay.blaze_log,
|
||||
@@ -803,7 +1217,7 @@ def host_banner_line(lay: Layout) -> str:
|
||||
return hits[-1]
|
||||
|
||||
|
||||
def verify(lay: Layout, variant: str) -> None:
|
||||
def verify(lay: Layout, variant: str, real_club_expected: bool) -> None:
|
||||
line = host_banner_line(lay)
|
||||
ok(f"host banner: {line}")
|
||||
if variant == "off":
|
||||
@@ -822,6 +1236,26 @@ def verify(lay: Layout, variant: str) -> None:
|
||||
status, body = http_get(CORE_PORT, "/health")
|
||||
ok(f"GET 127.0.0.1:{CORE_PORT}/health -> HTTP {status} {body}")
|
||||
|
||||
# A stale opaque extension does not fail anything loudly — the host just
|
||||
# drops it and the client sees an empty squad with no manager. Prove the
|
||||
# real club actually projects before handing the rig over.
|
||||
if real_club_expected:
|
||||
status, body = http_get(HOST_PORT, f"/ut/game/{GAME}/squad/0")
|
||||
squad = json.loads(body) if status == 200 else {}
|
||||
occupied = sum(1 for p in squad.get("players", []) if p.get("itemData", {}).get("id"))
|
||||
manager = squad.get("manager") or []
|
||||
ok(
|
||||
f"GET /ut/game/{GAME}/squad/0 -> HTTP {status} "
|
||||
f"{occupied} occupied slot(s), {len(manager)} manager"
|
||||
)
|
||||
if occupied < CLIENT_MIN_SQUAD or not manager:
|
||||
raise Fatal(
|
||||
f"the squad projected {occupied} player(s) and {len(manager)} "
|
||||
f"manager(s); expected at least {CLIENT_MIN_SQUAD} and one "
|
||||
"manager. A 'stale_integrity' line in the host log means the "
|
||||
"squad changed without re-stamping its extension fingerprint"
|
||||
)
|
||||
|
||||
# Nothing this process ever opened may live under the production state dir.
|
||||
leaked = []
|
||||
for fd in os.listdir(f"/proc/{os.getpid()}/fd"):
|
||||
@@ -850,8 +1284,23 @@ def cfg_block() -> list[str]:
|
||||
|
||||
|
||||
def print_summary(lay: Layout, variant: str, coins_processed: str, count_mode: str,
|
||||
records: list[dict]) -> None:
|
||||
roster_host: str, records: list[dict],
|
||||
real_club: dict | None) -> None:
|
||||
banner("STAGING STACK IS UP")
|
||||
if real_club is None:
|
||||
print(" club: the 14-item synthetic fixture "
|
||||
"(--club real installs the operator's own)")
|
||||
else:
|
||||
squad = real_club["squads"][0] if real_club["squads"] else None
|
||||
print(f" club: THE REAL ONE -- {real_club['username']} "
|
||||
f"({real_club['club_name']}), {real_club['owned_cards']} items, "
|
||||
f"{real_club['coins']:,} coins")
|
||||
if squad is not None:
|
||||
print(f" squad {squad['name']!r} ({squad['formation']}) with "
|
||||
f"{real_club['squad_players']} players")
|
||||
print(" DO NOT run sold-staging-seed-squad.py: it would overwrite "
|
||||
"this squad with the fixture XI")
|
||||
print()
|
||||
rows = [
|
||||
("staging Core", f"127.0.0.1:{CORE_PORT}", "loopback only; client never talks to it"),
|
||||
("staging utas-host", f"{BIND}:{HOST_PORT}", "UTAS the client reaches"),
|
||||
@@ -888,6 +1337,7 @@ def print_summary(lay: Layout, variant: str, coins_processed: str, count_mode: s
|
||||
print(f" experiment variant : {variant}")
|
||||
print(f" coinsProcessed : {coins_processed}")
|
||||
print(f" count mode : {count_mode}")
|
||||
print(f" roster host : {roster_host}")
|
||||
print(f" banner : {host_banner_line(lay)}")
|
||||
print(f" seeded state : {db_summary(lay)}")
|
||||
print(f" disposable item to sell : {DISPOSABLE_ITEM} ({DISPOSABLE_CARD})")
|
||||
@@ -931,9 +1381,20 @@ def main() -> int:
|
||||
ap.add_argument("--count-mode", choices=["active", "active_plus_sold"],
|
||||
default="active",
|
||||
help="what /tradePile/counts.count reports (default: active)")
|
||||
ap.add_argument(
|
||||
"--roster-host",
|
||||
default=os.environ.get("OPENFUT_ROSTER_HOST", DEFAULT_ROSTER_HOST),
|
||||
help=f"host:port advertised for roster HTTPS (default: {DEFAULT_ROSTER_HOST})",
|
||||
)
|
||||
ap.add_argument("--dir", default=os.environ.get("OPENFUT_SOLD_STAGING_DIR",
|
||||
DEFAULT_STAGING_DIR),
|
||||
help=f"staging directory (default: {DEFAULT_STAGING_DIR})")
|
||||
ap.add_argument(
|
||||
"--club", choices=["fixture", "real"], default="fixture",
|
||||
help="which club the seller plays with: the 14-item synthetic fixture "
|
||||
"(default) or the operator's real imported club, installed from the "
|
||||
"snapshot taken by scripts/club-snapshot.py",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
lay = Layout(args.dir)
|
||||
@@ -944,6 +1405,7 @@ def main() -> int:
|
||||
step(f"repo : {REPO}")
|
||||
step(f"forbidden ports : {sorted(FORBIDDEN_PORTS)}")
|
||||
step(f"forbidden paths : {list(FORBIDDEN_PATHS)}")
|
||||
step(f"club : {args.club}")
|
||||
assert_prod_alive("preflight")
|
||||
refuse_if_up(lay)
|
||||
assert_ports_free()
|
||||
@@ -951,16 +1413,25 @@ def main() -> int:
|
||||
banner("MATERIALISE STAGING DIRECTORY")
|
||||
materialise(lay)
|
||||
reset_throwaway_state(lay)
|
||||
assert_seed_cards_resolvable(lay)
|
||||
# The real club is installed BEFORE Core first runs, so Core migrates the
|
||||
# snapshot forward (it is a schema generation behind) rather than being
|
||||
# handed a database it has already opened.
|
||||
real_club = install_real_club(lay) if args.club == "real" else None
|
||||
assert_seed_cards_resolvable(lay, real_club)
|
||||
|
||||
banner("PATCH THE BLAZE RESPONDER COPY")
|
||||
patch_blaze(lay)
|
||||
|
||||
banner("STAGING CORE")
|
||||
if real_club is None:
|
||||
if os.path.exists(lay.core_db):
|
||||
raise Fatal(f"{lay.core_db} should have been removed by the state reset")
|
||||
raise Fatal(
|
||||
f"{lay.core_db} should have been removed by the state reset"
|
||||
)
|
||||
elif not os.path.exists(lay.core_db):
|
||||
raise Fatal(f"{lay.core_db} should have been installed from the snapshot")
|
||||
migrate_core(lay)
|
||||
seed_core_db(lay)
|
||||
seed_core_db(lay, real_club)
|
||||
started.append(start_core(lay))
|
||||
|
||||
banner("STAGING UTAS-HOST")
|
||||
@@ -968,7 +1439,7 @@ def main() -> int:
|
||||
args.count_mode))
|
||||
|
||||
banner("STAGING BLAZE")
|
||||
started.append(start_blaze(lay))
|
||||
started.append(start_blaze(lay, args.roster_host))
|
||||
|
||||
records = [item.record() for item in started]
|
||||
for rec in records:
|
||||
@@ -984,8 +1455,11 @@ def main() -> int:
|
||||
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"staging_dir": lay.root,
|
||||
"variant": args.variant,
|
||||
"club": args.club,
|
||||
"real_club": real_club,
|
||||
"coins_processed": args.coins_processed,
|
||||
"count_mode": args.count_mode,
|
||||
"roster_host": args.roster_host,
|
||||
"ports": {
|
||||
"core": CORE_PORT,
|
||||
"utas_host": HOST_PORT,
|
||||
@@ -1010,11 +1484,12 @@ def main() -> int:
|
||||
ok(f"manifest written: {lay.manifest}")
|
||||
|
||||
banner("VERIFY ISOLATION")
|
||||
verify(lay, args.variant)
|
||||
verify(lay, args.variant, real_club is not None)
|
||||
|
||||
print_summary(lay, args.variant, args.coins_processed, args.count_mode, records)
|
||||
print_summary(lay, args.variant, args.coins_processed, args.count_mode,
|
||||
args.roster_host, records, real_club)
|
||||
return 0
|
||||
except Fatal as exc:
|
||||
except ProductionError as exc:
|
||||
print(f"\nFATAL: {exc}", file=sys.stderr)
|
||||
if started:
|
||||
print(" rolling back the partial bring-up...", file=sys.stderr)
|
||||
|
||||
@@ -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 ;;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# OpenFUT FIFA 17 - native Windows client
|
||||
|
||||
The FIFA 17 client host (`10.10.0.105`, Windows 11 Pro) runs FIFA 17 **natively**
|
||||
(no Wine/Proton/umu). This directory holds the read-only preflight verifier and
|
||||
documents the native launch/routing/rollback model.
|
||||
|
||||
## Install layout (`C:\FIFA 17`)
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `FIFA17.exe` | retail game exe (sha256 `29C31CEF…`). **Never modify/patch.** ImageBase `0x140000000`. |
|
||||
| `_fifa17.exe` | native crack loader (Chemicalflood). This is what you launch. `asInvoker` manifest -> must be elevated externally. |
|
||||
| `version.dll` | **OpenFUT hook** (in-process via the version.dll load-order hijack). ImageBase `0x180000000`. |
|
||||
| `version.dll.stale-849k.bak` | **rollback** copy of the previous hook. |
|
||||
| `CardsDLL_Win64_retail.dll` | FUT card/SBC/kit logic. ImageBase `0x180000000`. |
|
||||
| `powdll_Win64_retail.dll` | Pack-Opening-World (EASFC store). ImageBase `0x180000000`. |
|
||||
| `sysdll_Win64_retail.dll` | EA networking / ProtoSSL (cert, ea.com). |
|
||||
| `stp-origin_emu.dll` + `stp-origin_emu.ini` | Origin/LSX login emulator (in-process; opens LSX `:4216` locally at runtime). Persona configured in the `.ini`. |
|
||||
| `stp-selector.exe` | ssl/LSX selector companion. |
|
||||
| `openfut.cfg` | operator-facing routing override (see below). |
|
||||
|
||||
## Launch (native - there is NO launcher script by design)
|
||||
|
||||
Run `C:\FIFA 17\_fifa17.exe` **as Administrator**. The correct, reproducible way:
|
||||
|
||||
- Double-click the **"FIFA 17 (OpenFUT)"** shortcut (Desktop and Start Menu).
|
||||
It targets `_fifa17.exe`, working dir `C:\FIFA 17`, with the RunAsAdmin bit set.
|
||||
- `_fifa17.exe` is also flagged `RUNASADMIN` in
|
||||
`HKCU\Software\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers`,
|
||||
so any launch (Explorer double-click included) elevates via UAC.
|
||||
|
||||
On launch the Windows loader maps `version.dll` from the game directory (hijack),
|
||||
`stp-origin_emu.dll` emulates Origin login for the configured persona, and the
|
||||
hook redirects EA endpoints to the OpenFUT backend.
|
||||
|
||||
> Do **not** wrap the launch in a script. The elevation + shortcut is the
|
||||
> supported mechanism. FIFA under native Windows also ignores synthetic input,
|
||||
> so in-game steps are performed by the operator one at a time.
|
||||
|
||||
### OpenFUT Launcher (GUI)
|
||||
|
||||
The `openfut-launcher` egui app runs natively on Windows (built for
|
||||
`x86_64-pc-windows-gnu`; the Linux Proton path is `cfg`-gated out). It is the
|
||||
one-button front end: it checks the backend, reconciles `openfut.cfg` from its
|
||||
settings, and starts the game.
|
||||
|
||||
- Binary: `C:\OpenFUT\openfut-launcher.exe`; config: `%APPDATA%\openfut-launcher\config.json`.
|
||||
- Launch it from the **"OpenFUT Launcher"** shortcut (Desktop / Start Menu). The
|
||||
launcher itself is flagged `RUNASADMIN`, so it elevates once at start; the game
|
||||
it spawns inherits that elevation (no second UAC prompt, no crack loader
|
||||
"run as admin" failure).
|
||||
- On Windows the launcher does NOT spawn LSX/autopatch (they are in-process:
|
||||
`stp-origin_emu.dll` + the `version.dll` hook) and does NOT arm the host
|
||||
(routing is purely `openfut.cfg`, which it writes into `C:\FIFA 17`).
|
||||
- Rebuild from Linux: `cargo build -p openfut-launcher --release --target x86_64-pc-windows-gnu`.
|
||||
|
||||
## Routing (`openfut.cfg`)
|
||||
|
||||
```
|
||||
host=10.10.0.120
|
||||
https_port=8443
|
||||
blaze_redirector_port=42127
|
||||
blaze_main_port=42130
|
||||
```
|
||||
|
||||
The hook carries `10.10.0.120` as its baked-in default; `openfut.cfg` is the
|
||||
override. `10.10.0.120` hosts **both** production and staging.
|
||||
|
||||
> **Production safety:** `blaze_main_port=42130` is the **production** Blaze.
|
||||
> Before any match/economy exercise, repoint `blaze_main_port` (and the matching
|
||||
> UTAS/HTTPS route) to the staging port so no traffic reaches the prod container.
|
||||
> The preflight raises a WARN whenever `42130` is configured.
|
||||
|
||||
## Rollback
|
||||
|
||||
The hook is a single file swap; no installer state.
|
||||
|
||||
```powershell
|
||||
# disable OpenFUT hook (restore previous DLL)
|
||||
Copy-Item 'C:\FIFA 17\version.dll' 'C:\FIFA 17\version.dll.disabled.bak' -Force
|
||||
Copy-Item 'C:\FIFA 17\version.dll.stale-849k.bak' 'C:\FIFA 17\version.dll' -Force
|
||||
# re-arm: copy the desired hook build over version.dll again
|
||||
```
|
||||
|
||||
Always keep a `*.bak` of the live hook before redeploying (the preflight checks
|
||||
that a rollback backup exists and differs from the live DLL).
|
||||
|
||||
## Preflight
|
||||
|
||||
`openfut-client-preflight.ps1` is **read-only**: it never launches the game,
|
||||
never elevates, never writes game files, never mutates economy state. It verifies
|
||||
the retail exe hash, companion DLLs, hook + rollback, routing + backend
|
||||
reachability, login persona, launcher elevation, and the RE toolchain
|
||||
(x64dbg, cargo). Exit 0 = OK, 1 = blocking failure.
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\openfut-client-preflight.ps1
|
||||
```
|
||||
|
||||
## Runtime RE (x64dbg)
|
||||
|
||||
See the Vault note **`02 Reverse Engineering/FIFA 17/Windows Client Runtime & x64dbg.md`**
|
||||
for the attach workflow and the RVA<->VA (ASLR) math. In short: these modules'
|
||||
preferred ImageBase is `0x180000000` (`0x140000000` for `FIFA17.exe`); in x64dbg
|
||||
a module name evaluates to its runtime (ASLR) base, so a Ghidra address maps to a
|
||||
breakpoint as `bp CardsDLL_Win64_retail.dll+<RVA>` where
|
||||
`RVA = ghidra_addr - 0x180000000`.
|
||||
@@ -0,0 +1,129 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
OpenFUT FIFA 17 Windows client preflight - READ ONLY.
|
||||
|
||||
.DESCRIPTION
|
||||
Non-destructive verification of the native Windows FIFA 17 OpenFUT client on
|
||||
this machine. It NEVER launches the game, never elevates, never writes to
|
||||
game files, and never mutates any economy state. It only reads files,
|
||||
registry, and performs TCP connect probes to the configured backend.
|
||||
|
||||
Exit code 0 = all PASS/WARN, 1 = one or more FAIL.
|
||||
|
||||
Native launch model (there is NO launcher script by design):
|
||||
run C:\FIFA 17\_fifa17.exe as Administrator
|
||||
(use the "FIFA 17 (OpenFUT)" shortcut, which carries the RunAsAdmin bit).
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$FifaRoot = 'C:\FIFA 17'
|
||||
)
|
||||
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
|
||||
# --- known-good fingerprints -------------------------------------------------
|
||||
# Retail executable MUST NOT be modified/patched (Denuvo + anti-cheat sensitive).
|
||||
$EXPECT_FIFA17_EXE_SHA256 = '29C31CEF12B0C3C2A7305220617C7B4FA139AB76B8C857851BDBE88987962899'
|
||||
# Currently deployed OpenFUT hook (base-supply + VEH build). Update on redeploy.
|
||||
$EXPECT_HOOK_SHA256 = '8844A6BCEE7BE37DD55B989246B312AF52FA711CCBC1220D25FEC64CBAD077D8'
|
||||
$ROLLBACK_BAK = Join-Path $FifaRoot 'version.dll.stale-849k.bak'
|
||||
|
||||
$script:fail = 0
|
||||
function Say([string]$level, [string]$msg) {
|
||||
switch ($level) {
|
||||
'PASS' { $c = 'Green' }
|
||||
'WARN' { $c = 'Yellow' }
|
||||
'FAIL' { $c = 'Red'; $script:fail++ }
|
||||
default { $c = 'Gray' }
|
||||
}
|
||||
Write-Host ('[{0}] {1}' -f $level, $msg) -ForegroundColor $c
|
||||
}
|
||||
function Sha([string]$p) { if (Test-Path $p) { (Get-FileHash $p -Algorithm SHA256).Hash } else { $null } }
|
||||
|
||||
Write-Host '=== OpenFUT FIFA 17 Windows client preflight (read-only) ===' -ForegroundColor Cyan
|
||||
Say 'INFO' ("host={0} user={1} {2}" -f $env:COMPUTERNAME, $env:USERNAME, (Get-CimInstance Win32_OperatingSystem).Caption)
|
||||
|
||||
# --- 1. FIFA install + retail exe integrity ---------------------------------
|
||||
$exe = Join-Path $FifaRoot 'FIFA17.exe'
|
||||
$loader = Join-Path $FifaRoot '_fifa17.exe'
|
||||
if (Test-Path $exe) {
|
||||
$h = Sha $exe
|
||||
if ($h -eq $EXPECT_FIFA17_EXE_SHA256) { Say 'PASS' "FIFA17.exe present and unmodified ($($h.Substring(0,16))...)" }
|
||||
else { Say 'FAIL' "FIFA17.exe hash MISMATCH - retail exe changed! got $($h.Substring(0,16))... expected $($EXPECT_FIFA17_EXE_SHA256.Substring(0,16))..." }
|
||||
} else { Say 'FAIL' "FIFA17.exe missing at $exe" }
|
||||
if (Test-Path $loader) { Say 'PASS' "native loader _fifa17.exe present ($((Sha $loader).Substring(0,16))...)" }
|
||||
else { Say 'FAIL' "_fifa17.exe (native loader) missing - cannot launch" }
|
||||
|
||||
# --- 2. companion DLLs -------------------------------------------------------
|
||||
$companions = 'CardsDLL_Win64_retail.dll','powdll_Win64_retail.dll','sysdll_Win64_retail.dll',
|
||||
'FootballCompEng_Win64_retail.dll','stp-origin_emu.dll','stp-selector.exe'
|
||||
foreach ($c in $companions) {
|
||||
$p = Join-Path $FifaRoot $c
|
||||
if (Test-Path $p) { Say 'PASS' "companion present: $c" } else { Say 'FAIL' "companion MISSING: $c" }
|
||||
}
|
||||
|
||||
# --- 3. OpenFUT hook (version.dll) + rollback backup ------------------------
|
||||
$hook = Join-Path $FifaRoot 'version.dll'
|
||||
if (Test-Path $hook) {
|
||||
$hh = Sha $hook
|
||||
if ($hh -eq $EXPECT_HOOK_SHA256) { Say 'PASS' "hook version.dll deployed (expected build $($hh.Substring(0,16))...)" }
|
||||
else { Say 'WARN' "hook version.dll present but hash differs from recorded build ($($hh.Substring(0,16))...) - may be a newer/older hook" }
|
||||
$bytes = [IO.File]::ReadAllBytes($hook); $ascii = [Text.Encoding]::ASCII.GetString($bytes)
|
||||
$markers = @('OpenFUT','fifa17','CardsDLL','SBC_DISPATCH') | Where-Object { $ascii -match [regex]::Escape($_) }
|
||||
if ($markers.Count -ge 3) { Say 'PASS' "hook markers found: $($markers -join ', ')" }
|
||||
else { Say 'WARN' "hook markers thin: $($markers -join ', ') - is this the OpenFUT hook?" }
|
||||
} else { Say 'FAIL' "hook version.dll NOT deployed - client will run vanilla (no OpenFUT)" }
|
||||
if (Test-Path $ROLLBACK_BAK) {
|
||||
if ((Sha $ROLLBACK_BAK) -ne (Sha $hook)) { Say 'PASS' "rollback backup present and differs from live: $(Split-Path $ROLLBACK_BAK -Leaf)" }
|
||||
else { Say 'WARN' "rollback backup equals live version.dll - rollback would be a no-op" }
|
||||
} else { Say 'WARN' "no rollback backup ($(Split-Path $ROLLBACK_BAK -Leaf)) - keep one before redeploying the hook" }
|
||||
|
||||
# --- 4. routing config + backend reachability -------------------------------
|
||||
$cfg = Join-Path $FifaRoot 'openfut.cfg'
|
||||
$host120 = $null; $ports = @()
|
||||
if (Test-Path $cfg) {
|
||||
$kv = @{}; foreach ($l in Get-Content $cfg) { if ($l -match '^\s*([^=#]+)=(.+)$') { $kv[$matches[1].Trim()] = $matches[2].Trim() } }
|
||||
$host120 = $kv['host']
|
||||
Say 'PASS' "openfut.cfg routing: host=$($kv['host']) https=$($kv['https_port']) redirector=$($kv['blaze_redirector_port']) blaze=$($kv['blaze_main_port'])"
|
||||
foreach ($k in 'https_port','blaze_redirector_port','blaze_main_port') { if ($kv[$k]) { $ports += [int]$kv[$k] } }
|
||||
if ($kv['blaze_main_port'] -eq '42130') { Say 'WARN' 'blaze_main_port=42130 targets PRODUCTION - repoint to a staging port before match/economy testing' }
|
||||
} else { Say 'WARN' "openfut.cfg absent - hook uses its baked-in default host" }
|
||||
if ($host120) {
|
||||
foreach ($p in $ports) {
|
||||
$t = New-Object Net.Sockets.TcpClient
|
||||
try {
|
||||
$ar = $t.BeginConnect($host120, $p, $null, $null)
|
||||
if ($ar.AsyncWaitHandle.WaitOne(2500) -and $t.Connected) { Say 'PASS' "backend reachable ${host120}:$p" }
|
||||
else { Say 'FAIL' "backend UNREACHABLE ${host120}:$p" }
|
||||
} catch { Say 'FAIL' "backend probe error ${host120}:$p - $($_.Exception.Message)" } finally { $t.Close() }
|
||||
}
|
||||
}
|
||||
|
||||
# --- 5. login persona (stp origin emulator) ---------------------------------
|
||||
$ini = Join-Path $FifaRoot 'stp-origin_emu.ini'
|
||||
if (Test-Path $ini) {
|
||||
$persona = (Get-Content $ini | Select-String 'PersonaId|PersonaName') -join ' '
|
||||
Say 'PASS' "login emulator config: $persona"
|
||||
} else { Say 'FAIL' "stp-origin_emu.ini missing - no login persona" }
|
||||
|
||||
# --- 6. native launcher elevation setup -------------------------------------
|
||||
$lk = 'HKCU:\Software\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers'
|
||||
$layer = (Get-ItemProperty $lk).$loader
|
||||
if ($layer -match 'RUNASADMIN') { Say 'PASS' "_fifa17.exe RUNASADMIN flag set ($layer)" }
|
||||
else { Say 'WARN' '_fifa17.exe has no RUNASADMIN flag - launch must be manual "Run as administrator"' }
|
||||
$sc = Join-Path $env:USERPROFILE 'Desktop\FIFA 17 (OpenFUT).lnk'
|
||||
if (Test-Path $sc) { Say 'PASS' "desktop launcher shortcut present: $(Split-Path $sc -Leaf)" }
|
||||
else { Say 'WARN' 'no desktop launcher shortcut' }
|
||||
|
||||
# --- 7. RE toolchain ---------------------------------------------------------
|
||||
$x = Get-ChildItem "$env:LOCALAPPDATA\Microsoft\WinGet\Packages" -Recurse -Depth 4 -Include x64dbg.exe -Attributes !ReparsePoint -EA SilentlyContinue | Select-Object -First 1 -Expand FullName
|
||||
if (-not $x) { $x = (Get-Command x64dbg.exe -EA SilentlyContinue).Source }
|
||||
if ($x) { Say 'PASS' "x64dbg present: $x (v$((Get-Item $x).VersionInfo.FileVersion))" } else { Say 'WARN' 'x64dbg not located - install for runtime RE' }
|
||||
$cargo = (Get-Command cargo -EA SilentlyContinue).Source
|
||||
if ($cargo) { Say 'PASS' "rust toolchain: $cargo" } else { Say 'WARN' 'cargo not found - needed to rebuild the hook natively' }
|
||||
|
||||
# --- summary -----------------------------------------------------------------
|
||||
Write-Host ''
|
||||
if ($script:fail -eq 0) { Write-Host 'PREFLIGHT: PASS (no blocking failures)' -ForegroundColor Green; exit 0 }
|
||||
else { Write-Host "PREFLIGHT: FAIL ($script:fail blocking issue(s))" -ForegroundColor Red; exit 1 }
|
||||
Reference in New Issue
Block a user