Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
@@ -3166,7 +3166,6 @@ dependencies = [
|
||||
name = "openfut-bridge"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"anyhow",
|
||||
"axum",
|
||||
"bytes",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# OpenFUT
|
||||
|
||||
> ⚠️ **Status — see [`docs/PROJECT_STATE.md`](./docs/PROJECT_STATE.md) (canonical).** The working, actively-developed target is **FIFA 17**, not FIFA 23. Everything below this banner describes the **superseded FIFA 23 `bridge` lineage** and is kept for historical context.
|
||||
>
|
||||
> **Run the server (canonical):** `cd fifa17-recon/docker/fifa17-python && docker compose up -d` — see [`fifa17-recon/FUT-RUNBOOK.md`](./fifa17-recon/FUT-RUNBOOK.md). `openfut-core` is the shared offline backend (still used by the FIFA 17 path); `openfut-bridge` is the retired FIFA 23 integration.
|
||||
|
||||
**Offline Ultimate Team — like SPT, but for FIFA 23.**
|
||||
|
||||
OpenFUT replaces EA's retired FUT servers with a fully offline, single-player backend. You own FIFA 23 legitimately. You just want to keep playing after EA shut down the servers.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# ============================================================================
|
||||
# ⚠️ LEGACY (FIFA 23 lineage). This compose runs core + bridge for the
|
||||
# superseded FIFA 23 direction. It is NOT the canonical server bring-up.
|
||||
#
|
||||
# Canonical server (FIFA 17):
|
||||
# cd fifa17-recon/docker/fifa17-python && docker compose up -d
|
||||
# (runbook: fifa17-recon/FUT-RUNBOOK.md)
|
||||
#
|
||||
# `core` (openfut-core) IS still the shared, game-independent backend and is
|
||||
# used by the FIFA 17 UTAS host (OPENFUT_CORE_URL). `bridge` (openfut-bridge)
|
||||
# is the retired FIFA 23 integration, kept for reference.
|
||||
# Status source of truth: docs/PROJECT_STATE.md
|
||||
# ============================================================================
|
||||
# OpenFUT server stack — offline FUT backend (Core) + FIFA proxy (Bridge).
|
||||
#
|
||||
# Bring up: docker compose up -d
|
||||
# Tear down: docker compose down (keeps data/captures volumes)
|
||||
# Wipe state: docker compose down -v (also drops volumes)
|
||||
# Rebuild: docker compose build (or ./scripts/registry.sh build)
|
||||
# Logs: docker compose logs -f
|
||||
#
|
||||
# Images are pulled from / pushed to the Gitea container registry. Override the
|
||||
# registry, namespace, or tag in .env (see .env.example). When REGISTRY is set,
|
||||
# `up` pulls prebuilt images; the build: blocks let you rebuild locally too.
|
||||
|
||||
name: openfut
|
||||
|
||||
services:
|
||||
core:
|
||||
image: ${REGISTRY:-git.aleshym.co}/${NAMESPACE:-openfut}/openfut-core:${TAG:-latest}
|
||||
build:
|
||||
context: ./openfut-core
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LISTEN_ADDR: 0.0.0.0:8080
|
||||
DATABASE_URL: sqlite:///app/db/openfut.db
|
||||
DATA_DIR: /app/data
|
||||
RUST_LOG: ${CORE_LOG:-openfut_core=info,tower_http=info}
|
||||
volumes:
|
||||
- core-db:/app/db
|
||||
# Bound to localhost by default — the bridge reaches core over the internal
|
||||
# network, so core need not be world-exposed. Set CORE_PUBLISH=0.0.0.0 in
|
||||
# .env if you want to hit the REST API directly from other hosts.
|
||||
ports:
|
||||
- "${CORE_PUBLISH:-127.0.0.1}:8080:8080"
|
||||
networks:
|
||||
- openfut
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
|
||||
interval: 15s
|
||||
timeout: 4s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
bridge:
|
||||
image: ${REGISTRY:-git.aleshym.co}/${NAMESPACE:-openfut}/openfut-bridge:${TAG:-latest}
|
||||
build:
|
||||
context: ./openfut-bridge
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
core:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
BRIDGE_LISTEN_ADDR: 0.0.0.0:8443
|
||||
CORE_URL: http://core:8080
|
||||
CAPTURES_DIR: /app/captures
|
||||
PLACEHOLDER_MODE: ${PLACEHOLDER_MODE:-true}
|
||||
TLS_ENABLED: "true"
|
||||
RUST_LOG: ${BRIDGE_LOG:-openfut_bridge=info,tower_http=info}
|
||||
volumes:
|
||||
- bridge-captures:/app/captures
|
||||
# The FIFA client connects here — publish on all interfaces by default so
|
||||
# LAN clients (e.g. 10.10.0.0/24) can reach it.
|
||||
ports:
|
||||
- "${BRIDGE_PUBLISH:-0.0.0.0}:8443:8443"
|
||||
networks:
|
||||
- openfut
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsSk", "https://127.0.0.1:8443/_bridge/health"]
|
||||
interval: 15s
|
||||
timeout: 4s
|
||||
retries: 5
|
||||
start_period: 8s
|
||||
|
||||
networks:
|
||||
openfut:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
core-db:
|
||||
bridge-captures:
|
||||
@@ -9,3 +9,25 @@ OPENFUT_ADVERTISE=203.0.113.10 # <- REPLACE with this host's LAN IP
|
||||
# Defaults to 0.0.0.0 (container-facing); the original all-on-localhost flow
|
||||
# uses the loopback default baked into the responders when unset.
|
||||
OPENFUT_BIND=0.0.0.0
|
||||
|
||||
# OPENFUT_SERVERS — which Python responders Docker runs (space/comma separated).
|
||||
# Default (unset) = the server-side set: "blaze roster utas pow".
|
||||
#
|
||||
# This host is the SERVER (.120). Docker runs ONLY components that have NOT been
|
||||
# migrated to a Rust host. During migration the Rust hosts (redirector / roster
|
||||
# / utas) run OUTSIDE Docker; as each Python component is replaced, remove its
|
||||
# name here so the two never serve the same role at once.
|
||||
# blaze Blaze redirector + main + nucleus (bundled) :42127 :42130 :42131
|
||||
# roster FUT roster-update XML :8081
|
||||
# utas FUT/UTAS RS4 API :8099
|
||||
# (Rust utas-host still proxies its non-/club routes here for now)
|
||||
# pow POW / EASFC :8094 (+ content :8080)
|
||||
# lsx Origin LSX bootstrap :4216
|
||||
# CLIENT-SIDE: LSX runs on the game machine (.105) with autopatch, NOT
|
||||
# on this server. Leave it OUT unless client and server share one box.
|
||||
#
|
||||
# Example — Rust already owns roster, so Docker should not also serve it:
|
||||
# OPENFUT_SERVERS=blaze utas pow
|
||||
# When you drop a component, also stop advertising / DNAT'ing its port to this
|
||||
# container so the client is routed to the Rust host instead.
|
||||
#OPENFUT_SERVERS=blaze roster utas pow
|
||||
|
||||
@@ -36,10 +36,14 @@ services:
|
||||
FUT_PROFILE_ROOT: "/state/accounts"
|
||||
FUT_SETTINGS: "off"
|
||||
FUT_MODES: "1"
|
||||
# Which Python responders this SERVER runs. Default excludes lsx (that is
|
||||
# a client-side responder — see below). Drop a name once it is migrated to
|
||||
# a Rust host (run outside Docker) so the two never overlap. See .env.example.
|
||||
OPENFUT_SERVERS: "${OPENFUT_SERVERS:-blaze roster utas pow}"
|
||||
volumes:
|
||||
- "../state:/state"
|
||||
ports:
|
||||
- "4216:4216" # LSX (Origin bootstrap)
|
||||
- "4216:4216" # LSX — CLIENT-SIDE (.105); only used if lsx is enabled for all-on-one-box
|
||||
- "42127:42127" # Blaze redirector (TLS)
|
||||
- "42130:42130" # Blaze main
|
||||
- "42131:42131" # Nucleus OAuth stub
|
||||
|
||||
@@ -57,10 +57,40 @@ declare -a SERVERS=(
|
||||
"pow|pow_server.py|-"
|
||||
)
|
||||
|
||||
# ── Component selection ──────────────────────────────────────────────────────
|
||||
# OPENFUT_SERVERS picks which Python responders run (space- or comma-separated).
|
||||
# This container is the SERVER side (.120). It serves ONLY components that have
|
||||
# NOT been migrated to a Rust host — as each moves to Rust (which runs OUTSIDE
|
||||
# Docker during migration), drop its name so the two never serve the same role.
|
||||
# blaze Blaze redirector + main + nucleus (bundled) :42127 :42130 :42131
|
||||
# roster FUT roster-update XML :8081
|
||||
# utas FUT/UTAS RS4 API :8099
|
||||
# (the Rust utas-host currently reverse-proxies its non-/club routes
|
||||
# back here, so keep this enabled until UTAS is fully migrated)
|
||||
# pow POW / EASFC :8094 (+ content :8080)
|
||||
# lsx Origin LSX bootstrap :4216
|
||||
# CLIENT-SIDE — LSX runs on the game machine (.105) with autopatch,
|
||||
# NOT on the server. Excluded by default; enable ONLY for an
|
||||
# all-on-one-box dev setup where client and server share a host.
|
||||
OPENFUT_SERVERS="${OPENFUT_SERVERS:-blaze roster utas pow}"
|
||||
want=" ${OPENFUT_SERVERS//,/ } "
|
||||
known=" lsx blaze roster utas pow "
|
||||
for w in $want; do
|
||||
case "$known" in
|
||||
*" $w "*) ;;
|
||||
*) echo "[openfut] unknown component '$w' in OPENFUT_SERVERS (valid: lsx blaze roster utas pow)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
echo "[openfut] servers=$OPENFUT_SERVERS"
|
||||
|
||||
pids=()
|
||||
names=()
|
||||
for entry in "${SERVERS[@]}"; do
|
||||
IFS='|' read -r name script env <<<"$entry"
|
||||
case "$want" in
|
||||
*" $name "*) ;;
|
||||
*) echo "[openfut] skipping $name (not in OPENFUT_SERVERS)"; continue ;;
|
||||
esac
|
||||
envprefix=""; [ "$env" != "-" ] && envprefix="env $env"
|
||||
echo "[openfut] starting $name ($script)"
|
||||
# shellcheck disable=SC2086
|
||||
@@ -69,6 +99,11 @@ for entry in "${SERVERS[@]}"; do
|
||||
names+=("$name")
|
||||
done
|
||||
|
||||
if [ "${#pids[@]}" -eq 0 ]; then
|
||||
echo "[openfut] OPENFUT_SERVERS selected no components; nothing to run" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Propagate SIGTERM/SIGINT to children so `docker stop` is clean.
|
||||
term() {
|
||||
echo "[openfut] shutting down…"
|
||||
|
||||
@@ -424,6 +424,25 @@ Chemistry/rating/nation/league-count constraints (`teamChemistry 0x307`, `starRa
|
||||
generically as `{eligibilityKey, eligibilityOperation, eligibilityValue}` triples, **not** as
|
||||
named scalar fields on the record. **FREEZE-RISK: elgReq must be a JSON array of objects.**
|
||||
|
||||
> **2026-08-19 — `eligibilityKey`/`eligibilityOperation` are LOCALIZATION ORDINALS, not the
|
||||
> atom hex ids above.** Reversed from the pinned CardsDLL (`4706a881…`). The client's sole
|
||||
> confirmed consumer of these fields is the requirement-display string builder at
|
||||
> `~0x1800ef900`: it loads the eligibility int fields (`0x148(rcx)`) and formats them through
|
||||
> *indexed localization keys* — `ELIGIBILITY_STRING%d` (`0x1802186b8`), `LOC_SBC_ELG_KEY_%d`
|
||||
> (`0x180226710`), `ELIGIBILITY_OPERATION` (`0x1802186e8`) — appending to a string builder via
|
||||
> vtable `*0x10`/`*0x20`. There is **no comparison/branch**: the client does not validate on
|
||||
> these ints, it renders `LOC_SBC_ELG_KEY_<eligibilityKey>` (and an operation string) as
|
||||
> display text. Therefore `eligibilityKey` is a small ordinal that indexes the **packed FIFA17
|
||||
> locale**, NOT `0x307`/`0x22f`/etc. (those hex values are the atom ids of the *named* fields
|
||||
> the encoding replaces, not the ordinal values). CONSEQUENCE: correct projection needs the
|
||||
> ordinal→locale-string map, which lives only in the packed locale (absent from CardsDLL and
|
||||
> every `fifa17-recon/data` file; a game-dir locale probe on the live client found none) or a
|
||||
> real EA `elgReq` capture (unavailable on a private server). Emitting a *guessed* ordinal
|
||||
> renders the WRONG requirement text to the player, so `elgReq` stays `[]` until the ordinal
|
||||
> map is recovered. This is a display-only gap: SBC submission is fully validated server-side
|
||||
> (Core), and an invalid squad's generic comms modal originates from the server 400, not from
|
||||
> the empty `elgReq`.
|
||||
|
||||
**awards / grantedAwards** — nested array of reward objects (atoms: `rewardType 0x28e`,
|
||||
`rewardValue 0x28f`, `rewardQuantity 0x28d`, `rewardMultiplier 0x28c`, `awardCount 0x40`,
|
||||
`awardSet 0x45`, `awardSetId 0x46`, `prizeSet 0x253`). **FREEZE-RISK: must be array.**
|
||||
|
||||
@@ -55,6 +55,34 @@ verify_exports() {
|
||||
done
|
||||
}
|
||||
|
||||
# Refuse any DLL that is not a FIFA-17-profile build.
|
||||
#
|
||||
# openfut-hook builds TWO mutually exclusive injection paths from one crate: the
|
||||
# default (FIFA 23) path installs getaddrinfo/connect/ProtoSSL/origin hooks, while
|
||||
# `--features fifa17` installs ONLY the FIFA-17-safe logic (module map, FIFA 17
|
||||
# cert-verify, SBC dispatch, store tab bind). Deploying a default-feature build
|
||||
# into FIFA 17 hijacks the login transport and the client reports "Unable to
|
||||
# connect to the EA servers", with none of the FIFA 17 repairs present.
|
||||
#
|
||||
# That exact mistake happened on 2026-08-19 (artifact 1c71a17a, hand-built without
|
||||
# the feature): two failed launches, diagnosed only by comparing embedded strings.
|
||||
# `build` below passes the feature, but a hand-built DLL can reach `stage`/`deploy`
|
||||
# via OPENFUT_FIFA17_HOOK_DLL, so assert the profile on the bytes themselves.
|
||||
verify_fifa17_profile() {
|
||||
local dll=$1 marker
|
||||
# Markers that MUST be present: the FIFA 17 target module and its repairs.
|
||||
for marker in 'CardsDLL_Win64_retail.dll' 'SBC_DISPATCH'; do
|
||||
grep -qaF -- "$marker" "$dll" ||
|
||||
die "$dll is not a --features fifa17 build (missing $marker); refusing to stage/deploy"
|
||||
done
|
||||
# Markers that MUST be absent: the FIFA-23-only transport hooking.
|
||||
for marker in 'getaddrinfo IAT patched' 'connect: inline-hooked' 'origin_spy'; do
|
||||
if grep -qaF -- "$marker" "$dll"; then
|
||||
die "$dll contains FIFA-23-only hook '$marker'; build with --features fifa17"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
verify_inputs() {
|
||||
command -v sha256sum >/dev/null || die "sha256sum is required"
|
||||
command -v x86_64-w64-mingw32-objdump >/dev/null ||
|
||||
@@ -62,6 +90,7 @@ verify_inputs() {
|
||||
need_file "$hook_dll"
|
||||
need_file "$system_version"
|
||||
verify_pe64 "$hook_dll"
|
||||
verify_fifa17_profile "$hook_dll"
|
||||
}
|
||||
|
||||
inspect() {
|
||||
@@ -129,6 +158,7 @@ deploy() {
|
||||
need_file "$manifest"
|
||||
verify_pe64 "$staged"
|
||||
verify_exports "$staged"
|
||||
verify_fifa17_profile "$staged"
|
||||
local recorded actual
|
||||
recorded="$(awk -F= '$1=="artifact_sha256"{print $2}' "$manifest")"
|
||||
actual="$(sha256 "$staged")"
|
||||
@@ -158,7 +188,7 @@ launch() {
|
||||
local trace_enabled=0
|
||||
local request_trace_enabled=0
|
||||
local notifier_trace_enabled=0
|
||||
local commit_enabled=0
|
||||
local dispatch_enabled=0
|
||||
case "$mode" in
|
||||
baseline)
|
||||
[[ "${OPENFUT_FIFA17_LAUNCH:-}" == "I_ACCEPT_M1_BASELINE_LAUNCH" ]] ||
|
||||
@@ -177,14 +207,11 @@ launch() {
|
||||
request_trace_enabled=1
|
||||
notifier_trace_enabled=1
|
||||
;;
|
||||
commit)
|
||||
[[ "${OPENFUT_FIFA17_COMMIT:-}" == "I_ACCEPT_POST_PARSE_READY_BYTE" ]] ||
|
||||
die "launch-commit requires OPENFUT_FIFA17_COMMIT=I_ACCEPT_POST_PARSE_READY_BYTE"
|
||||
hook_enabled=1
|
||||
trace_enabled=1
|
||||
dispatch)
|
||||
[[ "${OPENFUT_FIFA17_DISPATCH:-}" == "I_ACCEPT_GUARDED_NATIVE_DISPATCH" ]] ||
|
||||
die "launch-dispatch requires OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH"
|
||||
request_trace_enabled=1
|
||||
notifier_trace_enabled=1
|
||||
commit_enabled=1
|
||||
dispatch_enabled=1
|
||||
;;
|
||||
*) die "unknown launch mode: $mode" ;;
|
||||
esac
|
||||
@@ -207,7 +234,7 @@ launch() {
|
||||
done
|
||||
mkdir -p "${wine_prefix}/dosdevices"
|
||||
ln -sfn /mnt "${wine_prefix}/dosdevices/w:"
|
||||
note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; SBC_COMMIT=$commit_enabled); log=/tmp/fifa17-hook-m1-launch.log"
|
||||
note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; SBC_DISPATCH=$dispatch_enabled); log=/tmp/fifa17-hook-m1-launch.log"
|
||||
cd "$game_dir"
|
||||
env \
|
||||
GAMEID=fifa17 \
|
||||
@@ -218,8 +245,8 @@ launch() {
|
||||
OPENFUT_SBC_TRACE="$trace_enabled" \
|
||||
OPENFUT_SBC_REQUEST_TRACE="$request_trace_enabled" \
|
||||
OPENFUT_SBC_NOTIFIER_TRACE="$notifier_trace_enabled" \
|
||||
OPENFUT_SBC_DISPATCH=0 \
|
||||
OPENFUT_SBC_COMMIT="$commit_enabled" \
|
||||
OPENFUT_SBC_DISPATCH="$dispatch_enabled" \
|
||||
OPENFUT_SBC_DISPATCH_TRACE=0 \
|
||||
OPENFUT_SBC_ARM_ONLY=0 \
|
||||
OPENFUT_SBC_POPULATE=0 \
|
||||
umu-run _fifa17.exe 2>&1 | tee /tmp/fifa17-hook-m1-launch.log
|
||||
@@ -227,7 +254,7 @@ launch() {
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace|launch-commit]
|
||||
Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace|launch-dispatch]
|
||||
|
||||
inspect Read-only PE/hash/export preflight (default).
|
||||
build Cross-build the inert FIFA17 hook, then run inspect.
|
||||
@@ -240,11 +267,11 @@ Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launc
|
||||
Start M2 resolve-only mode (guarded reads/logging, no detours/writes); requires:
|
||||
OPENFUT_FIFA17_RESOLVE=I_ACCEPT_M2_RESOLVE_LAUNCH
|
||||
launch-trace
|
||||
Start the single M3 passive factory/deserializer trace; requires:
|
||||
Start the M3-M6 passive parser/request/notifier trace; requires:
|
||||
OPENFUT_FIFA17_TRACE=I_ACCEPT_M3_PASSIVE_TRACE
|
||||
launch-commit
|
||||
Trace and arm the SBC cache only after a validated native parse; requires:
|
||||
OPENFUT_FIFA17_COMMIT=I_ACCEPT_POST_PARSE_READY_BYTE
|
||||
launch-dispatch
|
||||
Trace and repair only a fully validated native status-999 completion; requires:
|
||||
OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH
|
||||
|
||||
Optional path overrides:
|
||||
OPENFUT_FIFA17_HOOK_DLL, OPENFUT_FIFA17_GAME_DIR,
|
||||
@@ -260,7 +287,7 @@ case "${1:-inspect}" in
|
||||
launch) launch baseline ;;
|
||||
launch-resolve) launch resolve ;;
|
||||
launch-trace) launch trace ;;
|
||||
launch-commit) launch commit ;;
|
||||
launch-dispatch) launch dispatch ;;
|
||||
-h|--help|help) usage ;;
|
||||
*) usage >&2; die "unknown command: $1" ;;
|
||||
esac
|
||||
|
||||
@@ -379,7 +379,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn honest_zero_club_items_present() {
|
||||
let g = global(&club_stats_body(&[player(90, false, None)], ContextField::Nation));
|
||||
let g = global(&club_stats_body(
|
||||
&[player(90, false, None)],
|
||||
ContextField::Nation,
|
||||
));
|
||||
for atom in [
|
||||
"stadia",
|
||||
"balls",
|
||||
@@ -411,7 +414,10 @@ mod tests {
|
||||
.filter(|r| r["contextId"] == 3 && r["contextValue"] == 13)
|
||||
.collect();
|
||||
assert_eq!(league_rows.len(), 6);
|
||||
let gold = league_rows.iter().find(|r| r["type"] == "playersGold").unwrap();
|
||||
let gold = league_rows
|
||||
.iter()
|
||||
.find(|r| r["type"] == "playersGold")
|
||||
.unwrap();
|
||||
assert_eq!(gold["typeValue"], 1);
|
||||
|
||||
// league screen -> team (teamid) buckets: players/kits/badgeDBid (3 rows).
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
//! 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,
|
||||
})
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub mod economy;
|
||||
pub mod economy_policy;
|
||||
pub mod entities;
|
||||
pub mod item;
|
||||
pub mod match_wire;
|
||||
pub mod non_economy;
|
||||
pub mod owned_query;
|
||||
pub mod pack_content;
|
||||
|
||||
@@ -24,48 +24,9 @@ pub fn accountinfo_body() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
/// `GET …/settings` — the FUT client applies this `configs` array via the applier
|
||||
/// `FUN_18011dc50`, the ONLY writer of the `IS_*` UI gate bytes (each
|
||||
/// `byte = (field == 1)`). A flag never sent is a gate never opened — which is why
|
||||
/// Single-Player Seasons/Draft refuse to open while issuing ZERO server requests
|
||||
/// (`friendlySeasonsEnabled` → slot `[0x16]` → model byte `0x1fd3a`
|
||||
/// `IS_FRIENDLY_SEASON_ENABLED`; RE-confirmed via pyghidra on CardsDLL).
|
||||
///
|
||||
/// Default (`OPENFUT_FUT_SETTINGS` unset/`off`) is the historical empty baseline.
|
||||
/// `OPENFUT_FUT_SETTINGS=gates` populates the array. SAFETY: populating it is what
|
||||
/// makes the applier RUN, and it then writes EVERY gate byte, so the already-live
|
||||
/// store flags MUST be re-asserted here or enabling Seasons would clear the working
|
||||
/// Store (those reach the client today via the Blaze FUT_RS4_CONFIG store, not
|
||||
/// here). Mirrors the audited list in `utas_server.py` `_SETTINGS_KEEP`/`_GATES`.
|
||||
/// `GET …/settings` — production oracle returns an empty config list.
|
||||
pub fn settings_body() -> Value {
|
||||
if std::env::var("OPENFUT_FUT_SETTINGS").as_deref() != Ok("gates") {
|
||||
return json!({ "configs": [] });
|
||||
}
|
||||
// Already-live store flags re-asserted (pinned to their working state).
|
||||
const KEEP: &[&str] = &[
|
||||
"storeEnabled",
|
||||
"storeEnabled_JP",
|
||||
"coinEnabled",
|
||||
"coinEnabled_JP",
|
||||
"cardPackStoreEnabled",
|
||||
"cardPackStoreEnabled_JP",
|
||||
"pointsPackStoreEnabled",
|
||||
"tradingEnabled",
|
||||
];
|
||||
// Mode gates that nothing has ever populated (the point of the change).
|
||||
const GATES: &[&str] = &[
|
||||
"friendlySeasonsEnabled",
|
||||
"enableDraftMode",
|
||||
"enableSinglePlayerDraftMode",
|
||||
"enableOfflineDraftMode",
|
||||
"tournamentQuitEnabled",
|
||||
];
|
||||
let configs: Vec<Value> = KEEP
|
||||
.iter()
|
||||
.chain(GATES.iter())
|
||||
.map(|k| json!({ "type": k, "value": 1 }))
|
||||
.collect();
|
||||
json!({ "configs": configs })
|
||||
json!({ "configs": [] })
|
||||
}
|
||||
|
||||
/// `GET …/leaderboards/options` — production oracle (FUT_MODES off) returns an
|
||||
@@ -106,12 +67,19 @@ pub fn feature_off_body() -> Value {
|
||||
pub fn item_def(resource_id: i64) -> Value {
|
||||
let asset = resource_id & 0xff_ffff;
|
||||
// (name, rating, position, nation, leagueId, teamid, [6 attrs])
|
||||
let (name, rating, pos, nation, league, team, attrs): (&str, i64, &str, i64, i64, i64, [i64; 6]) =
|
||||
if asset == 20801 {
|
||||
("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80])
|
||||
} else {
|
||||
("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70])
|
||||
};
|
||||
let (name, rating, pos, nation, league, team, attrs): (
|
||||
&str,
|
||||
i64,
|
||||
&str,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
[i64; 6],
|
||||
) = if asset == 20801 {
|
||||
("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80])
|
||||
} else {
|
||||
("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70])
|
||||
};
|
||||
let attribute_list: Vec<Value> = attrs
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -404,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;
|
||||
@@ -667,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.
|
||||
|
||||
@@ -90,14 +90,30 @@ pub fn generate_pack_contents(
|
||||
}
|
||||
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 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);
|
||||
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);
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,15 @@ pub fn challenges_body(set_id: i64, challenges: &[ChallengeView]) -> Value {
|
||||
"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": []
|
||||
})
|
||||
})
|
||||
@@ -277,10 +286,11 @@ mod tests {
|
||||
times_completed: 1,
|
||||
};
|
||||
|
||||
let repeatable_view = challenges_body(CHALLENGES[0].set_id, &[repeatable.clone()]);
|
||||
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, &[once.clone()]);
|
||||
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]);
|
||||
@@ -360,8 +370,16 @@ mod tests {
|
||||
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_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"
|
||||
|
||||
@@ -141,6 +141,13 @@ pub struct ProposedSquad {
|
||||
/// never used to derive slot layout.
|
||||
pub formation: Option<String>,
|
||||
pub slots: Vec<ProposedSlot>,
|
||||
/// 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. A manager wire id the resolver cannot map is reported
|
||||
/// in `unresolved_wire_ids` — a save is refused rather than assigning a
|
||||
/// manager the club does not own.
|
||||
pub manager_owned_card_id: Option<String>,
|
||||
/// 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.
|
||||
@@ -180,12 +187,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 +214,22 @@ 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. An unresolvable manager is an unresolved wire id (refused), not a
|
||||
// silently dropped assignment — you cannot manage with a card you don't own.
|
||||
let mut manager_owned_card_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.push(wire),
|
||||
}
|
||||
}
|
||||
ProposedSquad {
|
||||
squad_id: put.id,
|
||||
name: put.squad_name.clone(),
|
||||
formation: put.formation.clone(),
|
||||
slots,
|
||||
manager_owned_card_id,
|
||||
unresolved_wire_ids: unresolved,
|
||||
}
|
||||
}
|
||||
@@ -237,9 +257,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 +305,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,32 @@ 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_refuses_an_unowned_manager() {
|
||||
// Manager wire id present but NOT resolvable → refused, never assigned a
|
||||
// manager the club does not own.
|
||||
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
||||
let mut ids = full_resolver().0;
|
||||
ids.remove(&100000427);
|
||||
let err = build_squad_write(&put, &MapResolver(ids)).unwrap_err();
|
||||
assert_eq!(err, SquadBuildError::UnresolvedWireIds(vec![100000427]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_schema_version_is_rejected_not_coerced() {
|
||||
let payload = built().extension.to_payload();
|
||||
|
||||
@@ -77,6 +77,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 +176,17 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
||||
}
|
||||
}
|
||||
|
||||
// Manager: the ownership-backed assignment, resolved to its FIFA wire ref.
|
||||
// Emitted as the STATIC_REVERSED `[{id, dream}]` shape (the same shape the
|
||||
// client sends on save). An owned manager with no resolvable FIFA identity
|
||||
// is omitted (non-fatal, like /club dropping an unrenderable card) rather
|
||||
// than emitted with a fabricated id. NOTE: a richer manager itemData
|
||||
// (contract/leagueId/nation) is INFERRED-only from the offline-season
|
||||
// diagnosis and deliberately NOT invented here pending wire reversal.
|
||||
let manager = match input.manager.as_ref().and_then(|m| ident.resolve(m)) {
|
||||
Some(id) => json!([{ "id": id.item_id, "dream": false }]),
|
||||
None => json!([]),
|
||||
};
|
||||
let squad = json!({
|
||||
"id": input.fifa_squad_id,
|
||||
"squadName": input.name,
|
||||
@@ -180,7 +196,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,
|
||||
@@ -262,6 +278,7 @@ mod tests {
|
||||
}],
|
||||
ext,
|
||||
owned,
|
||||
manager: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +289,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(),
|
||||
}
|
||||
@@ -403,6 +419,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 +440,65 @@ 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,
|
||||
},
|
||||
),
|
||||
(
|
||||
"oc-mgr".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000427,
|
||||
asset_id: 5001,
|
||||
resource_id: 5001,
|
||||
rareflag: 1,
|
||||
},
|
||||
),
|
||||
]));
|
||||
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");
|
||||
};
|
||||
assert_eq!(
|
||||
v["manager"],
|
||||
json!([{ "id": 100000427, "dream": false }]),
|
||||
"manager is the ownership-backed wire ref, resolved from the owned 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,
|
||||
},
|
||||
)]));
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,45 +68,153 @@ impl PackDef {
|
||||
/// never purchasable/openable.
|
||||
pub const PACK_CATALOG: &[PackDef] = &[
|
||||
// ── Bronze category ──
|
||||
PackDef { id: 1, name: "Bronze Pack", price: 400,
|
||||
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 },
|
||||
PackDef {
|
||||
id: 1,
|
||||
name: "Bronze Pack",
|
||||
price: 400,
|
||||
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 },
|
||||
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,
|
||||
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 Pack", price: 7500,
|
||||
n_bronze: 0, n_silver: 2, n_gold: 10, rares: 3, category: "gold",
|
||||
special_chance: 0.06, owned_only: false },
|
||||
PackDef {
|
||||
id: 5,
|
||||
name: "Gold Pack",
|
||||
price: 5000,
|
||||
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 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 Gold Pack", price: 0,
|
||||
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 },
|
||||
PackDef {
|
||||
id: 70,
|
||||
name: "Reward Gold Pack",
|
||||
price: 0,
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
/// Look up a catalogue pack by id (the 65534 sentinel is never present).
|
||||
|
||||
@@ -199,7 +199,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 70,
|
||||
"assetId": 5,
|
||||
"id": 70,
|
||||
"packType": "GOLD",
|
||||
"description": "Reward Gold Pack",
|
||||
@@ -212,6 +212,13 @@
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 0,
|
||||
"finalFunds": 0
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
|
||||
@@ -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)
|
||||
//!
|
||||
@@ -146,6 +147,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 +198,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 +274,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 +298,35 @@ 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 now ownership-backed: register its owned instance + identity
|
||||
// and pass it as the assignment, not as an opaque extension field.
|
||||
let mgr_wire = oracle["manager"][0]["id"].as_i64().unwrap();
|
||||
let mgr_oc = format!("oc-{mgr_wire}");
|
||||
ident.0.insert(
|
||||
mgr_oc.clone(),
|
||||
Fifa17Identity {
|
||||
item_id: mgr_wire as u32,
|
||||
asset_id: 5001,
|
||||
resource_id: 5001,
|
||||
rareflag: 1,
|
||||
},
|
||||
);
|
||||
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 +341,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 {
|
||||
|
||||
+1
-1
Submodule openfut-bridge updated: 0f581ebef3...c58e7326a1
+1
-1
Submodule openfut-core updated: 271c3639ed...2fb835200f
@@ -208,7 +208,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
|
||||
|
||||
+1
-1
Submodule openfut-launcher updated: 1cd4f18e92...8d5bb6202a
@@ -464,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 ────────────────────────────────────────────
|
||||
|
||||
@@ -599,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 ──────────────────────────────────
|
||||
|
||||
+266
-47
@@ -55,10 +55,8 @@ use openfut_adapter_fifa17::fut::club_response::{
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::club_stats::{club_stats_body, ClubStatInput, ContextField};
|
||||
use openfut_adapter_fifa17::fut::content_taxonomy::ContentKind;
|
||||
use openfut_adapter_fifa17::fut::economy_policy::{
|
||||
match_reward_total, result_from_end_reason, MatchResult,
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::entities::{Fifa17Entities, ReverseEntityResolver};
|
||||
use openfut_adapter_fifa17::fut::match_wire;
|
||||
use openfut_adapter_fifa17::fut::non_economy;
|
||||
use openfut_adapter_fifa17::fut::owned_query::{
|
||||
is_special_rareflag, map_to_core, parse_club_query, MapError,
|
||||
@@ -682,6 +680,24 @@ pub trait CoreAccess: Send + Sync {
|
||||
/// Replace the active squad's canonical slots + opaque extension atomically.
|
||||
fn replace_squad(&self, req: &CoreReplaceRequest) -> Result<CoreReplaceResult, CoreError>;
|
||||
|
||||
/// The owned instance id assigned as the active squad's **manager**, or
|
||||
/// `None` (`GET /club/manager`). Default: `None` — a transport without the
|
||||
/// endpoint simply projects no manager (non-fatal, like an absent
|
||||
/// assignment). The production `HttpCoreClient` overrides it.
|
||||
fn get_squad_manager(&self) -> Result<Option<String>, CoreError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Assign (`Some`) or clear (`None`) the active squad's **manager**
|
||||
/// (`PUT /club/manager`). Default: unimplemented — the production
|
||||
/// `HttpCoreClient` overrides it; a transport that cannot persist the
|
||||
/// assignment MUST fail loudly rather than silently drop it.
|
||||
fn set_squad_manager(&self, _owned_card_id: Option<&str>) -> Result<(), CoreError> {
|
||||
Err(CoreError::Parse(
|
||||
"Core squad manager write is not implemented".into(),
|
||||
))
|
||||
}
|
||||
|
||||
fn list_sbcs(&self) -> Result<Vec<CoreSbcDefinition>, CoreError> {
|
||||
Err(CoreError::Parse(
|
||||
"Core SBC access is not implemented".into(),
|
||||
@@ -821,6 +837,41 @@ impl CoreAccess for HttpCoreClient {
|
||||
})
|
||||
}
|
||||
|
||||
fn get_squad_manager(&self) -> Result<Option<String>, CoreError> {
|
||||
let url = format!("{}/club/manager", self.base_url);
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header("X-OpenFUT-Game", &self.game)
|
||||
.send()
|
||||
.map_err(|e| CoreError::Http(e.to_string()))?;
|
||||
let status = resp.status().as_u16();
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(CoreError::Status(status));
|
||||
}
|
||||
let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?;
|
||||
Ok(v.get("manager")
|
||||
.and_then(|m| m.get("owned_card_id"))
|
||||
.and_then(|x| x.as_str())
|
||||
.map(str::to_string))
|
||||
}
|
||||
|
||||
fn set_squad_manager(&self, owned_card_id: Option<&str>) -> Result<(), CoreError> {
|
||||
let url = format!("{}/club/manager", self.base_url);
|
||||
let resp = self
|
||||
.client
|
||||
.put(&url)
|
||||
.header("X-OpenFUT-Game", &self.game)
|
||||
.json(&json!({ "owned_card_id": owned_card_id }))
|
||||
.send()
|
||||
.map_err(|e| CoreError::Http(e.to_string()))?;
|
||||
let status = resp.status().as_u16();
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(CoreError::Status(status));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list_sbcs(&self) -> Result<Vec<CoreSbcDefinition>, CoreError> {
|
||||
let response = self
|
||||
.client
|
||||
@@ -1026,6 +1077,31 @@ pub struct EconomySaleReceipt {
|
||||
pub squad_slots_freed: u64,
|
||||
}
|
||||
|
||||
/// A finished match to apply to Core's authoritative, exactly-once
|
||||
/// `complete_match` transaction. `match_identity` is the durable per-match
|
||||
/// idempotency key (persona-scoped); `result` is the canonical Core token
|
||||
/// (`win`/`draw`/`loss`/`dnf`/`no_contest`) the adapter derived from `endReason`.
|
||||
pub struct CoreMatchCompletion<'a> {
|
||||
pub match_identity: &'a str,
|
||||
pub result: &'a str,
|
||||
pub squad_id: &'a str,
|
||||
pub opponent_name: &'a str,
|
||||
pub goals_for: i64,
|
||||
pub goals_against: i64,
|
||||
pub mode: &'a str,
|
||||
}
|
||||
|
||||
/// Core's authoritative answer for a match completion. `applied` is `false` on an
|
||||
/// idempotent replay; the coin figures are Core's, rendered straight onto the
|
||||
/// wire reward body.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CoreMatchReceipt {
|
||||
pub applied: bool,
|
||||
pub result: String,
|
||||
pub coins_awarded: i64,
|
||||
pub coins_balance: i64,
|
||||
}
|
||||
|
||||
/// The host's authoritative economy transport to Core. Every method is a single
|
||||
/// durable Core transaction. **Fail-closed:** on any transport/status/parse
|
||||
/// error the caller MUST surface a controlled error and NEVER fall back to
|
||||
@@ -1055,6 +1131,11 @@ pub trait CoreEconomy: Send + Sync {
|
||||
/// does not own the item, so a replayed settlement is refused, never
|
||||
/// double-paid.
|
||||
fn settle_sale(&self, sale: &EconomySale<'_>) -> Result<EconomySaleReceipt, CoreError>;
|
||||
/// Apply a finished match to Core's authoritative, atomic, exactly-once
|
||||
/// `complete_match` transaction. Core is the sole economy writer here — a
|
||||
/// replay/duplicate returns `applied = false` with the canonical result, and
|
||||
/// any error is surfaced (never a Python fallback).
|
||||
fn complete_match(&self, m: &CoreMatchCompletion<'_>) -> Result<CoreMatchReceipt, CoreError>;
|
||||
}
|
||||
|
||||
impl HttpCoreClient {
|
||||
@@ -1090,6 +1171,23 @@ impl HttpCoreClient {
|
||||
}
|
||||
resp.json().map_err(|e| CoreError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
/// POST to a non-`/economy/` Core endpoint (e.g. `matches/complete`), same
|
||||
/// game header + status/parse handling as [`Self::economy_post`].
|
||||
fn core_post(&self, tail: &str, body: &Value) -> Result<Value, CoreError> {
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{}/{}", self.base_url, tail))
|
||||
.header("X-OpenFUT-Game", &self.game)
|
||||
.json(body)
|
||||
.send()
|
||||
.map_err(|e| CoreError::Http(e.to_string()))?;
|
||||
let status = resp.status().as_u16();
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(CoreError::Status(status));
|
||||
}
|
||||
resp.json().map_err(|e| CoreError::Parse(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn json_i64(v: &Value, key: &str) -> Result<i64, CoreError> {
|
||||
@@ -1211,6 +1309,27 @@ impl CoreEconomy for HttpCoreClient {
|
||||
squad_slots_freed: json_u64(&v, "squad_slots_freed")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn complete_match(&self, m: &CoreMatchCompletion<'_>) -> Result<CoreMatchReceipt, CoreError> {
|
||||
let v = self.core_post(
|
||||
"matches/complete",
|
||||
&json!({
|
||||
"match_identity": m.match_identity,
|
||||
"result": m.result,
|
||||
"squad_id": m.squad_id,
|
||||
"opponent_name": m.opponent_name,
|
||||
"goals_for": m.goals_for,
|
||||
"goals_against": m.goals_against,
|
||||
"mode": m.mode,
|
||||
}),
|
||||
)?;
|
||||
Ok(CoreMatchReceipt {
|
||||
applied: v.get("applied").and_then(Value::as_bool).unwrap_or(false),
|
||||
result: json_str(&v, "result")?,
|
||||
coins_awarded: json_i64(&v, "coins_awarded")?,
|
||||
coins_balance: json_i64(&v, "coins_balance")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize an [`EconomySale`] into the `POST /economy/settle-sale` JSON body.
|
||||
@@ -1839,6 +1958,15 @@ fn project_active_squad(deps: &SquadDeps<'_>) -> HostProjection {
|
||||
is_on_bench: s.is_on_bench,
|
||||
})
|
||||
.collect();
|
||||
// The ownership-backed manager assignment (Core `squad_managers`). A fetch
|
||||
// error or absent endpoint yields no manager — non-fatal, a squad renders
|
||||
// without one, never fabricated.
|
||||
let manager = deps
|
||||
.core
|
||||
.get_squad_manager()
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|id| owned_by_id.get(&id).cloned());
|
||||
let input = SquadProjectionInput {
|
||||
fifa_squad_id: ACTIVE_SQUAD_WIRE_ID,
|
||||
name: read.name,
|
||||
@@ -1846,6 +1974,7 @@ fn project_active_squad(deps: &SquadDeps<'_>) -> HostProjection {
|
||||
slots,
|
||||
ext: SquadExtInput::Fresh(ext),
|
||||
owned: &owned_by_id,
|
||||
manager,
|
||||
};
|
||||
match project_squad(&input, deps.resolver, deps.entities) {
|
||||
Ok(SquadProjection::Projected(v)) => HostProjection::Squad(v),
|
||||
@@ -1932,6 +2061,18 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ
|
||||
);
|
||||
}
|
||||
}
|
||||
// The manager is a resolved, owned assignment too: authorize it like a slot.
|
||||
if let Some(mgr) = &build.canonical.manager_owned_card_id {
|
||||
if !owned_set.contains(mgr) {
|
||||
return (
|
||||
error_response(403, "not_owned"),
|
||||
SquadLog {
|
||||
outcome: "unauthorized_manager",
|
||||
detail: mgr.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
// Commit canonical + extension atomically. No Python fallback on failure.
|
||||
let req = CoreReplaceRequest {
|
||||
name: build.canonical.name.clone(),
|
||||
@@ -1956,22 +2097,37 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ
|
||||
ext_schema_version: EXT_SCHEMA_VERSION,
|
||||
ext_payload: build.extension.to_payload(),
|
||||
};
|
||||
match deps.core.replace_squad(&req) {
|
||||
Ok(_) => (
|
||||
json_response(&save_ack(put.id)),
|
||||
SquadLog {
|
||||
outcome: "ok",
|
||||
detail: String::new(),
|
||||
},
|
||||
),
|
||||
Err(e) => (
|
||||
if let Err(e) = deps.core.replace_squad(&req) {
|
||||
return (
|
||||
error_response(502, "core_error"),
|
||||
SquadLog {
|
||||
outcome: "core_error",
|
||||
detail: e.to_string(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
// Persist the ownership-backed manager assignment (migration 0023). It was
|
||||
// authorized above and Core re-validates club ownership; fail loudly on a
|
||||
// transport error rather than silently dropping the manager.
|
||||
if let Err(e) = deps
|
||||
.core
|
||||
.set_squad_manager(build.canonical.manager_owned_card_id.as_deref())
|
||||
{
|
||||
return (
|
||||
error_response(502, "core_error"),
|
||||
SquadLog {
|
||||
outcome: "manager_error",
|
||||
detail: e.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
(
|
||||
json_response(&save_ack(put.id)),
|
||||
SquadLog {
|
||||
outcome: "ok",
|
||||
detail: String::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// `GET …/squad/list` — served from Core + the ONE projector. Stale/Missing are
|
||||
@@ -2275,35 +2431,46 @@ pub fn overlay_massinfo_economy(root: &mut Value, coins: i64, unopened_count: us
|
||||
true
|
||||
}
|
||||
|
||||
/// Build the `destroy_match_body` reward response (oracle shape). `total` is the
|
||||
/// post-credit balance; `result_coins` is the per-result amount.
|
||||
pub fn build_match_reward_body(total: i64, result: MatchResult) -> Value {
|
||||
let result_coins = openfut_adapter_fifa17::fut::economy_policy::match_result_coins(result);
|
||||
let total_award = match_reward_total(result);
|
||||
json!({
|
||||
"allCoins": total,
|
||||
"matchCoins": result_coins,
|
||||
"seasonCoins": 0,
|
||||
"tournamentCoins": 0,
|
||||
"boostConis": 0,
|
||||
"participationAward": openfut_adapter_fifa17::fut::economy_policy::MATCH_PARTICIPATION,
|
||||
"teamOfTournamentWinner": false,
|
||||
"gameModeAward": { "coins": total_award },
|
||||
})
|
||||
/// Derive the per-match economic identity from a persona + the match-end body.
|
||||
/// This is the key for Core's durable exactly-once guard — NOT a FIFA HTTP
|
||||
/// receipt id. When the client sends a non-zero `matchReportId` it keys on that;
|
||||
/// otherwise (the observed live path reports `0`) it keys on a stable fingerprint
|
||||
/// of the body so an identical network retry dedupes in Core, while distinct
|
||||
/// decided matches (whose stat arrays differ) get distinct identities.
|
||||
fn match_identity(persona: i64, end: &match_wire::MatchEnd, body: &[u8]) -> String {
|
||||
if end.match_report_id != 0 {
|
||||
return format!("{persona}:report:{}", end.match_report_id);
|
||||
}
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
body.hash(&mut h);
|
||||
format!("{persona}:fp:{:016x}", h.finish())
|
||||
}
|
||||
|
||||
/// Handle the coin-crediting `/match` end call: derive the outcome from
|
||||
/// `endReason`, credit the reward through Core `grant_reward`, and render the
|
||||
/// oracle-shaped body. Fail-closed on Core error (503, never Python).
|
||||
pub fn handle_match_end(econ: &dyn CoreEconomy, body: &[u8]) -> WireResponse {
|
||||
let end_reason = serde_json::from_slice::<Value>(body).ok().and_then(|v| {
|
||||
v.get("endReason")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
});
|
||||
let result = result_from_end_reason(end_reason.as_deref());
|
||||
match econ.grant_reward(match_reward_total(result)) {
|
||||
Ok(total) => json_response(&build_match_reward_body(total, result)),
|
||||
/// Handle the coin-crediting match-end call: parse the FIFA wire, then apply the
|
||||
/// match to OpenFUT Core's authoritative, atomic, exactly-once `complete_match`
|
||||
/// transaction and render Core's authoritative coin numbers back onto the wire.
|
||||
/// FAIL-CLOSED: a malformed body is a 400 and ANY Core error is a 503 — the
|
||||
/// economy is never applied by Python (a second writer would break exactly-once).
|
||||
pub fn handle_match_end(econ: &dyn CoreEconomy, persona: i64, body: &[u8]) -> WireResponse {
|
||||
let Some(end) = match_wire::parse_match_end(body) else {
|
||||
return error_response(400, "bad_match_end");
|
||||
};
|
||||
let identity = match_identity(persona, &end, body);
|
||||
let completion = CoreMatchCompletion {
|
||||
match_identity: &identity,
|
||||
result: end.result.core_token(),
|
||||
squad_id: "",
|
||||
opponent_name: "",
|
||||
goals_for: end.goals_for,
|
||||
goals_against: end.goals_against,
|
||||
mode: "seasons",
|
||||
};
|
||||
match econ.complete_match(&completion) {
|
||||
Ok(receipt) => json_response(&match_wire::reward_response(
|
||||
receipt.coins_balance,
|
||||
receipt.coins_awarded,
|
||||
)),
|
||||
Err(_) => error_response(503, "core_unavailable"),
|
||||
}
|
||||
}
|
||||
@@ -3059,7 +3226,7 @@ impl Server {
|
||||
};
|
||||
handle_quick_sell_body(body, &deps)
|
||||
}
|
||||
EconomyRoute::MatchEnd => handle_match_end(svc.econ.as_ref(), body),
|
||||
EconomyRoute::MatchEnd => handle_match_end(svc.econ.as_ref(), self.persona_id, body),
|
||||
EconomyRoute::MoveItems => {
|
||||
let (bridge, piles, resolver, market) = (
|
||||
svc.bridge.clone(),
|
||||
@@ -4232,6 +4399,26 @@ mod tests {
|
||||
}
|
||||
Ok(self.balance)
|
||||
}
|
||||
fn complete_match(
|
||||
&self,
|
||||
m: &CoreMatchCompletion<'_>,
|
||||
) -> Result<CoreMatchReceipt, CoreError> {
|
||||
if self.fail {
|
||||
return Err(CoreError::Status(503));
|
||||
}
|
||||
let coins = match m.result {
|
||||
"win" => 400,
|
||||
"draw" => 200,
|
||||
"loss" | "dnf" => 100,
|
||||
_ => 0,
|
||||
};
|
||||
Ok(CoreMatchReceipt {
|
||||
applied: true,
|
||||
result: m.result.to_string(),
|
||||
coins_awarded: coins,
|
||||
coins_balance: self.balance,
|
||||
})
|
||||
}
|
||||
fn purchase_item(
|
||||
&self,
|
||||
_cost: i64,
|
||||
@@ -4410,26 +4597,58 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn match_reward_credits_core_and_shapes_body() {
|
||||
let econ = FakeEconomy::ok(5400, 0); // grant_reward echoes balance
|
||||
let resp = handle_match_end(&econ, br#"{"endReason":"WIN"}"#);
|
||||
let econ = FakeEconomy::ok(5400, 0);
|
||||
let resp = handle_match_end(
|
||||
&econ,
|
||||
42,
|
||||
br#"{"endReason":"WIN","myMatchStats":[2,0,0,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]}"#,
|
||||
);
|
||||
assert_eq!(resp.status, 200);
|
||||
let body: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
// Core's authoritative balance -> allCoins; granted coins -> matchCoins.
|
||||
assert_eq!(body["allCoins"], 5400);
|
||||
assert_eq!(body["matchCoins"], 400); // win
|
||||
assert_eq!(body["gameModeAward"]["coins"], 400);
|
||||
assert_eq!(body["seasonCoins"], 0);
|
||||
// Draw default on unknown reason.
|
||||
// The freeze traps are never emitted.
|
||||
assert!(body.get("bidTokens").is_none());
|
||||
assert!(body.get("qualifiedChampionEventId").is_none());
|
||||
// Draw default on a well-formed body without a known endReason.
|
||||
let draw: Value =
|
||||
serde_json::from_slice(&handle_match_end(&econ, br#"{"foo":1}"#).body).unwrap();
|
||||
serde_json::from_slice(&handle_match_end(&econ, 42, br#"{"foo":1}"#).body).unwrap();
|
||||
assert_eq!(draw["matchCoins"], 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_reward_fails_closed_on_core_error() {
|
||||
let resp = handle_match_end(&FakeEconomy::failing(), br#"{"endReason":"WIN"}"#);
|
||||
fn match_end_fails_closed_on_core_error() {
|
||||
// A Core failure NEVER falls back to Python — controlled 503.
|
||||
let resp = handle_match_end(&FakeEconomy::failing(), 42, br#"{"endReason":"WIN"}"#);
|
||||
assert_eq!(resp.status, 503);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_match_end_is_rejected() {
|
||||
let resp = handle_match_end(&FakeEconomy::ok(1000, 0), 42, b"not json");
|
||||
assert_eq!(resp.status, 400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_identity_dedupes_retries_and_separates_matches() {
|
||||
// An identical body (a network retry of the same match) yields the SAME
|
||||
// identity so Core dedupes it; two decided matches with different stats
|
||||
// get DIFFERENT identities so both credit.
|
||||
let a = br#"{"matchReportId":0,"endReason":"WIN","myMatchStats":[3,0,0,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 b = br#"{"matchReportId":0,"endReason":"WIN","myMatchStats":[2,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"opponentMatchStats":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}"#;
|
||||
let ea = match_wire::parse_match_end(a).unwrap();
|
||||
let eb = match_wire::parse_match_end(b).unwrap();
|
||||
assert_eq!(match_identity(42, &ea, a), match_identity(42, &ea, a));
|
||||
assert_ne!(match_identity(42, &ea, a), match_identity(42, &eb, b));
|
||||
// A non-zero report id keys on the report, independent of body bytes.
|
||||
let r = br#"{"matchReportId":99,"endReason":"WIN"}"#;
|
||||
let er = match_wire::parse_match_end(r).unwrap();
|
||||
assert_eq!(match_identity(7, &er, r), "7:report:99");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_filter_keeps_only_specials_and_paginates_filtered_set() {
|
||||
let mk = |id: i64, rf: i64| serde_json::json!({ "id": id, "rareflag": rf });
|
||||
|
||||
@@ -805,7 +805,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};
|
||||
@@ -959,6 +960,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 -----------------------------------------
|
||||
|
||||
@@ -436,8 +436,10 @@ fn case_d_two_market_buyers(h: &Harness) -> String {
|
||||
"POST",
|
||||
"/ut/game/fifa17/auctionhouse",
|
||||
&[],
|
||||
format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#)
|
||||
.as_bytes(),
|
||||
format!(
|
||||
r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#
|
||||
)
|
||||
.as_bytes(),
|
||||
None,
|
||||
)
|
||||
.expect("list routed");
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,33 @@ 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, 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-end body does NOT double-credit.
|
||||
let replay = handle_match_end(&client, 1, br#"{"endReason":"WIN"}"#);
|
||||
assert_eq!(replay.status, 200);
|
||||
assert_eq!(
|
||||
client.balance().unwrap(),
|
||||
after_match,
|
||||
"replay must not re-credit"
|
||||
);
|
||||
|
||||
// Buy a numeric entitlement "70" through the Core economy API (debit 600).
|
||||
post(
|
||||
@@ -169,11 +190,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_match - 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 +220,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 +267,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");
|
||||
|
||||
@@ -422,7 +450,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 +463,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 ->
|
||||
|
||||
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())
|
||||
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Attribute captured UTAS requests to labelled UI actions, and diff them.
|
||||
|
||||
utas-filter-diff.py --session <dir> [--baseline NO_FILTER]
|
||||
|
||||
The My Squad filter investigation needs to answer "which wire field changed
|
||||
when I changed exactly one thing in the UI". That is a diff between labelled
|
||||
groups of requests, so this tool needs both halves:
|
||||
|
||||
raw/utas.ofcap what the client sent
|
||||
labels.txt MARKER <label> <HH:MM:SS> UTC lines, written when the human
|
||||
said a search was done
|
||||
|
||||
Requests are attributed to the label whose marker most recently PRECEDES them.
|
||||
Anything before the first marker is 'boot'.
|
||||
|
||||
Why a separate tool: the observer must stay a dumb, byte-faithful tee. Anything
|
||||
that interprets traffic belongs outside it, so a mistake here can never affect
|
||||
what was recorded.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import collections
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
|
||||
def load(session):
|
||||
"""Rebuild per-connection streams, keeping the wall time of each chunk."""
|
||||
path = os.path.join(session, "raw", "utas.ofcap")
|
||||
conns = collections.defaultdict(
|
||||
lambda: {"c2s": bytearray(), "s2c": bytearray(), "marks": []}
|
||||
)
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
r = json.loads(line)
|
||||
if "b64" not in r:
|
||||
continue
|
||||
c = conns[r["conn"]]
|
||||
data = base64.b64decode(r["b64"])
|
||||
if r["dir"] == "c2s":
|
||||
c["marks"].append((len(c["c2s"]), r["unix"]))
|
||||
c[r["dir"]].extend(data)
|
||||
return conns
|
||||
|
||||
|
||||
def labels(session):
|
||||
out = []
|
||||
p = os.path.join(session, "labels.txt")
|
||||
if not os.path.exists(p):
|
||||
return out
|
||||
import datetime
|
||||
for line in open(p):
|
||||
m = re.match(r"MARKER\s+(\S+)\s+(\d\d):(\d\d):(\d\d)\s+UTC", line.strip())
|
||||
if m:
|
||||
name, h, mi, s = m.group(1), *map(int, m.groups()[1:])
|
||||
out.append((name, h * 3600 + mi * 60 + s))
|
||||
return out
|
||||
|
||||
|
||||
def split_requests(buf):
|
||||
"""(offset, method, target, headers, body) per request in a stream."""
|
||||
out, i = [], 0
|
||||
while True:
|
||||
sep = buf.find(b"\r\n\r\n", i)
|
||||
if sep < 0:
|
||||
break
|
||||
head = bytes(buf[i:sep]).decode("latin1")
|
||||
line0 = head.split("\r\n")[0]
|
||||
m = re.match(r"(\S+)\s+(\S+)\s+HTTP/", line0)
|
||||
if not m:
|
||||
break
|
||||
hdrs = [h.split(":", 1) for h in head.split("\r\n")[1:] if ":" in h]
|
||||
hdrs = [(k.strip(), v.strip()) for k, v in hdrs]
|
||||
cl = next((int(v) for k, v in hdrs if k.lower() == "content-length" and v.isdigit()), 0)
|
||||
start = sep + 4
|
||||
body = bytes(buf[start:start + cl])
|
||||
out.append((i, m.group(1), m.group(2), hdrs, body))
|
||||
i = start + cl
|
||||
return out
|
||||
|
||||
|
||||
def split_responses(buf):
|
||||
out, i = [], 0
|
||||
while True:
|
||||
sep = buf.find(b"\r\n\r\n", i)
|
||||
if sep < 0:
|
||||
break
|
||||
head = bytes(buf[i:sep]).decode("latin1")
|
||||
st = re.match(r"HTTP/\d\.\d\s+(\d+)", head)
|
||||
cl = re.search(r"(?im)^content-length:\s*(\d+)\s*$", head)
|
||||
te = re.search(r"(?im)^transfer-encoding:.*chunked", head)
|
||||
start = sep + 4
|
||||
if te:
|
||||
j, body = start, bytearray()
|
||||
while True:
|
||||
nl = buf.find(b"\r\n", j)
|
||||
if nl < 0:
|
||||
return out
|
||||
try:
|
||||
n = int(bytes(buf[j:nl]).split(b";")[0], 16)
|
||||
except ValueError:
|
||||
return out
|
||||
j = nl + 2
|
||||
if n == 0:
|
||||
j = buf.find(b"\r\n", j)
|
||||
j = j + 2 if j >= 0 else len(buf)
|
||||
break
|
||||
body.extend(buf[j:j + n])
|
||||
j += n + 2
|
||||
out.append((int(st.group(1)) if st else None, bytes(body)))
|
||||
i = j
|
||||
elif cl:
|
||||
n = int(cl.group(1))
|
||||
out.append((int(st.group(1)) if st else None, bytes(buf[start:start + n])))
|
||||
i = start + n
|
||||
else:
|
||||
out.append((int(st.group(1)) if st else None, b""))
|
||||
i = start
|
||||
return out
|
||||
|
||||
|
||||
def time_at(marks, off):
|
||||
t = marks[0][1] if marks else 0
|
||||
for pos, unix in marks:
|
||||
if pos > off:
|
||||
break
|
||||
t = unix
|
||||
return t
|
||||
|
||||
|
||||
def item_ids(body):
|
||||
"""Item ids in a response, if it looks like an item list."""
|
||||
try:
|
||||
d = json.loads(body)
|
||||
except Exception:
|
||||
return None
|
||||
ids = []
|
||||
|
||||
def walk(o):
|
||||
if isinstance(o, dict):
|
||||
if "id" in o and isinstance(o["id"], int):
|
||||
ids.append(o["id"])
|
||||
for v in o.values():
|
||||
walk(v)
|
||||
elif isinstance(o, list):
|
||||
for v in o:
|
||||
walk(v)
|
||||
|
||||
walk(d)
|
||||
return ids
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--session", required=True)
|
||||
ap.add_argument("--baseline", default="NO_FILTER")
|
||||
a = ap.parse_args()
|
||||
|
||||
import datetime
|
||||
conns = load(a.session)
|
||||
marks = labels(a.session)
|
||||
|
||||
rows = []
|
||||
for cid in sorted(conns):
|
||||
c = conns[cid]
|
||||
reqs = split_requests(c["c2s"])
|
||||
resps = split_responses(c["s2c"])
|
||||
for i, (off, method, target, hdrs, body) in enumerate(reqs):
|
||||
unix = time_at(c["marks"], off)
|
||||
secs = datetime.datetime.utcfromtimestamp(unix)
|
||||
secs = secs.hour * 3600 + secs.minute * 60 + secs.second
|
||||
label = "boot"
|
||||
for name, at in marks:
|
||||
if at <= secs:
|
||||
label = name
|
||||
status, rbody = resps[i] if i < len(resps) else (None, b"")
|
||||
path, _, query = target.partition("?")
|
||||
ids = item_ids(rbody)
|
||||
rows.append({
|
||||
"label": label,
|
||||
"time": datetime.datetime.utcfromtimestamp(unix).strftime("%H:%M:%S"),
|
||||
"conn": cid, "method": method, "path": path,
|
||||
"query": dict(parse_qsl(query, keep_blank_values=True)) if query else {},
|
||||
"query_raw": query,
|
||||
"req_body": body.decode("latin1") if len(body) < 2000 else "<%dB>" % len(body),
|
||||
"status": status,
|
||||
"resp_len": len(rbody),
|
||||
"resp_sha": hashlib.sha256(rbody).hexdigest()[:12],
|
||||
"item_count": len(ids) if ids is not None else None,
|
||||
"item_ids": ids[:40] if ids else None,
|
||||
})
|
||||
|
||||
print("=== requests by label ===")
|
||||
for r in rows:
|
||||
if r["label"] == "boot":
|
||||
continue
|
||||
print("%-18s %s conn%-3d %-5s %-46s %s %5dB sha=%s items=%s"
|
||||
% (r["label"], r["time"], r["conn"], r["method"], r["path"][:46],
|
||||
r["status"], r["resp_len"], r["resp_sha"],
|
||||
r["item_count"] if r["item_count"] is not None else "-"))
|
||||
if r["query"]:
|
||||
print("%-18s query: %s" % ("", r["query"]))
|
||||
if r["req_body"].strip():
|
||||
print("%-18s body : %s" % ("", r["req_body"][:200]))
|
||||
|
||||
# Field-level diff against the baseline label.
|
||||
base = [r for r in rows if r["label"] == a.baseline]
|
||||
if not base:
|
||||
print("\n(no %s rows yet; skipping diff)" % a.baseline)
|
||||
return 0
|
||||
print("\n=== query-field diff vs %s ===" % a.baseline)
|
||||
bq = base[-1]["query"]
|
||||
bpath = base[-1]["path"]
|
||||
seen = set()
|
||||
for r in rows:
|
||||
if r["label"] in ("boot", a.baseline) or r["label"] in seen:
|
||||
continue
|
||||
seen.add(r["label"])
|
||||
added = {k: v for k, v in r["query"].items() if bq.get(k) != v}
|
||||
removed = {k: v for k, v in bq.items() if k not in r["query"]}
|
||||
note = "" if r["path"] == bpath else " PATH DIFFERS: %s" % r["path"]
|
||||
print(" %-18s +%s -%s%s" % (r["label"], added or "{}", removed or "{}", note))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,5 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# OpenFUT Setup Script
|
||||
# OpenFUT Setup Script — ⚠️ LEGACY (FIFA 23 core+bridge lineage)
|
||||
#
|
||||
# SUPERSEDED. The working target is FIFA 17. This script builds/starts the old
|
||||
# FIFA 23 core+bridge stack and redirects EA domains for FIFA 23. Do NOT use it
|
||||
# for the current server. Canonical server bring-up (FIFA 17):
|
||||
# cd fifa17-recon/docker/fifa17-python && docker compose up -d
|
||||
# Status: docs/PROJECT_STATE.md · Runbook: fifa17-recon/FUT-RUNBOOK.md
|
||||
#
|
||||
# Builds, configures, and starts the OpenFUT offline FUT emulator for FIFA 23.
|
||||
# Run as a regular user; the script will sudo only for hosts/cert/iptables steps.
|
||||
|
||||
@@ -407,6 +414,11 @@ EOF
|
||||
}
|
||||
|
||||
# ── Dispatch ───────────────────────────────────────────────────────────────────
|
||||
# --- Legacy guard: this is the superseded FIFA 23 flow (see header banner). ---
|
||||
echo "[LEGACY] setup.sh drives the superseded FIFA 23 core+bridge stack." >&2
|
||||
echo "[LEGACY] Canonical FIFA 17 server: cd fifa17-recon/docker/fifa17-python && docker compose up -d" >&2
|
||||
echo "[LEGACY] Status: docs/PROJECT_STATE.md · Runbook: fifa17-recon/FUT-RUNBOOK.md" >&2
|
||||
|
||||
case "${1:-help}" in
|
||||
quickstart) cmd_quickstart ;;
|
||||
build) cmd_build ;;
|
||||
|
||||
Reference in New Issue
Block a user