feat(staging): let staging serve the operator's real club, not just the fixture

The operator could not field a starting XI because staging has only ever held the
14-item synthetic fixture (11 auto-picked starters, one disposable, two kits). The
real club -- the 1986-item CAGE import, 29,843,976 coins -- was never lost, but it
sits in `/home/alex/openfut-promotion/state`, which BOTH staging lifecycle scripts
list in FORBIDDEN_PATHS and refuse to open. That guard is correct and stays.

Worth recording while looking for the club: the LIVE production Core container
serves an EMPTY database (0 owned cards, schema predating even the game_id column).
The real club is not being served anywhere right now; it exists as state on disk.
So restoring it into staging is not a convenience, it is the only way to play it.

Two pieces:

`scripts/club-snapshot.py` is the ONE place allowed to read production state, and
it is read-only by construction: the Core database is opened `mode=ro` and copied
with sqlite's online backup API (a plain file copy can tear a database with a hot
WAL), every destination is asserted to be outside the production directory before
anything is opened for writing, and the sha256 of every source is compared before
and after -- a mismatch aborts, because that would mean the snapshot modified
production. It then proves the copy is faithful (same counts, coins, squad) and
that the identity store maps EVERY owned card to a wire id, since an unmapped card
would reappear under a freshly minted id and break the client's cached squad.

`sold-staging-up.py --club real` installs that snapshot. It is installed BEFORE
Core first starts, so Core migrates the copy forward from schema v19 through
match_completions, squad managers and kit assignments. Seller A is then already
present -- it IS the imported persona -- so only Buyer B is seeded, the kit
fixtures are attached to the real club so the kit work stays exercisable, and the
real squad is left alone. The up script still never reads production state: the
snapshot lives outside it, which is precisely what makes `--club real` compatible
with the `safe_path()` refusal.

The resolvability preflight now covers whichever club will actually be served. This
is the check that matters most for the real one: Core does not fail on an owned card
whose definition is missing, it silently filter_map-drops it, so a gap shows up as
an EMPTY club with all 1986 rows still in the database. Verified: all 1712 distinct
card ids resolve in both the content pack and the identity catalog, 0 missing.

`sold-staging-seed-squad.py` now REFUSES to run when the manifest says the real club
is installed. `PUT /squad/0` is a full replacement, so the fixture seeder would have
overwritten the operator's own lineup with an auto-picked XI -- destructive and not
recoverable in place. `--show` still works in every mode; `--force` overrides.

Verified end to end against the restored club: /club 29,843,976 coins, /collection
1988, 1966 players + 2 kits served over the UTAS wire, squad 'OpenFUT' (f433) rated
90 with 11 players carrying contract 7 / fitness 99, and every wire id stable from
the snapshot identity store. The fixture path was re-run afterwards and still seeds
exactly 14 items, so the SOLD experiment is unaffected.
This commit is contained in:
funman300
2026-08-21 16:35:06 +00:00
parent dddcfb917c
commit de747b79e6
3 changed files with 534 additions and 56 deletions
+309
View File
@@ -0,0 +1,309 @@
#!/usr/bin/env python3
"""Take a READ-ONLY snapshot of the operator's real imported FIFA-17 club.
Why this script exists at all
----------------------------
The real club -- the 1986-item CAGE import, persona 33068179 -- lives in the
production state directory `/home/alex/openfut-promotion/state`. That directory is
listed in FORBIDDEN_PATHS by both staging lifecycle scripts, which refuse to open
any path underneath it. That guard is deliberate and stays absolute: the staging
scripts must not be able to reach production state even by accident.
So the club cannot enter staging directly. This script is the ONE place allowed to
read production state, it is read-only by construction, and its only output is a
snapshot in a directory OUTSIDE the guard. `sold-staging-up.py --club real` then
installs from the snapshot and never learns where it came from.
Read-only by construction
-------------------------
* The Core database is opened `mode=ro` and copied with sqlite3's online backup
API, so the copy is transactionally consistent and the source is never written
(a plain file copy of a database with a hot WAL can tear).
* Every destination is asserted to be outside the production state directory
before anything is opened for writing.
* The sha256 of every source file is taken before and after the copy and compared.
A mismatch aborts loudly -- that would mean this script, or something racing it,
modified production state.
The snapshot is a point-in-time artifact, not a live mirror. Re-run it to refresh.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import sqlite3
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from openfut_production import ProductionError, production_state # noqa: E402
# --- fixed facts -------------------------------------------------------------------
# The production state directory. This script READS these files and writes nothing
# back. It is the same path both staging scripts refuse outright.
PROD_STATE = "/home/alex/openfut-promotion/state"
SRC_CORE_DB = os.path.join(PROD_STATE, "prod-core.db")
SRC_IDENTITY = os.path.join(PROD_STATE, "prod-identity.json")
SRC_CLIENTDATA = os.path.join(PROD_STATE, "clientdata.json")
DEFAULT_SNAPSHOT_DIR = "/home/alex/openfut-club-snapshot"
# Snapshot member names. `sold-staging-up.py` knows these and nothing else.
SNAP_CORE_DB = "core.db"
SNAP_IDENTITY = "identity.json"
SNAP_CLIENTDATA = "clientdata.json"
SNAP_MANIFEST = "snapshot.json"
GAME = "fifa17"
def banner(title: str) -> None:
print()
print("=" * 78)
print(f" {title}")
print("=" * 78)
def step(msg: str) -> None:
print(f" {msg}")
def ok(msg: str) -> None:
print(f" [ OK ] {msg}")
class Fatal(ProductionError):
"""Anything that must abort the snapshot loudly rather than degrade."""
def sha256(path: str) -> str:
digest = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def assert_outside_production(path: str, what: str) -> str:
"""Every write target goes through here. The snapshot must never be able to
land inside the directory this script is reading."""
resolved = os.path.abspath(path)
if resolved == PROD_STATE or resolved.startswith(PROD_STATE + os.sep):
raise Fatal(
f"REFUSING: {what} {resolved!r} is inside the production state "
f"directory {PROD_STATE!r} -- this script never writes there"
)
return resolved
def read_club_facts(db_path: str) -> dict:
"""Describe the club in a database WITHOUT modifying it. Used on the source (to
record what was taken) and on the copy (to prove the copy is faithful)."""
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
try:
profiles = conn.execute(
"SELECT id, username, game_id, import_fingerprint FROM profiles "
"WHERE game_id = ?", (GAME,)
).fetchall()
if len(profiles) != 1:
raise Fatal(
f"expected exactly one {GAME} profile in {db_path}, found "
f"{len(profiles)} -- refusing to snapshot an ambiguous club"
)
profile_id, username, game_id, fingerprint = profiles[0]
club = conn.execute(
"SELECT id, name, coins FROM clubs WHERE profile_id = ?", (profile_id,)
).fetchone()
if club is None:
raise Fatal(f"{GAME} profile {profile_id} has no club in {db_path}")
club_id, club_name, coins = club
owned, distinct = conn.execute(
"SELECT COUNT(*), COUNT(DISTINCT card_id) FROM owned_cards WHERE club_id = ?",
(club_id,),
).fetchone()
squads = conn.execute(
"SELECT id, name, formation FROM squads WHERE club_id = ?", (club_id,)
).fetchall()
squad_players = conn.execute(
"SELECT COUNT(*) FROM squad_players sp JOIN squads s ON s.id = sp.squad_id "
"WHERE s.club_id = ?", (club_id,)
).fetchone()[0]
migration = conn.execute(
"SELECT MAX(version) FROM _sqlx_migrations"
).fetchone()[0]
return {
"profile_id": profile_id,
"username": username,
"game_id": game_id,
"import_fingerprint": fingerprint,
"club_id": club_id,
"club_name": club_name,
"coins": coins,
"owned_cards": owned,
"distinct_card_ids": distinct,
"squads": [
{"id": s[0], "name": s[1], "formation": s[2]} for s in squads
],
"squad_players": squad_players,
"schema_version": migration,
}
finally:
conn.close()
def copy_database(src: str, dst: str) -> None:
"""Online-backup copy. The source is opened read-only, so this cannot write to
production state even if the backup API wanted to."""
source = sqlite3.connect(f"file:{src}?mode=ro", uri=True)
try:
if os.path.exists(dst):
os.remove(dst)
target = sqlite3.connect(dst)
try:
source.backup(target)
finally:
target.close()
finally:
source.close()
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--dir", default=os.environ.get("OPENFUT_CLUB_SNAPSHOT_DIR",
DEFAULT_SNAPSHOT_DIR),
help=f"snapshot directory (default: {DEFAULT_SNAPSHOT_DIR})")
args = ap.parse_args()
try:
banner("SNAPSHOT THE REAL CLUB (read-only on production state)")
dest = assert_outside_production(args.dir, "snapshot directory")
step(f"source (read-only): {PROD_STATE}")
step(f"destination : {dest}")
# Production is not involved in a file copy -- its Core runs from a docker
# volume, not from this directory -- but proving the freeze held across the
# operation costs nothing and keeps the evidence uniform with the other
# lifecycle scripts.
before_state = production_state()
ok("production before: " + ", ".join(before_state.describe()))
missing = [p for p in (SRC_CORE_DB, SRC_IDENTITY, SRC_CLIENTDATA)
if not os.path.isfile(p)]
if missing:
raise Fatal("missing production artifact(s):\n " + "\n ".join(missing))
before = {p: sha256(p) for p in (SRC_CORE_DB, SRC_IDENTITY, SRC_CLIENTDATA)}
for path, digest in before.items():
step(f"{os.path.basename(path):20s} {os.path.getsize(path):>9,d} B "
f"sha256 {digest[:16]}")
facts = read_club_facts(SRC_CORE_DB)
ok(
f"club to snapshot: {facts['username']} ({facts['club_name']}) "
f"{facts['owned_cards']} items, {facts['distinct_card_ids']} distinct "
f"card ids, {facts['coins']:,} coins, schema v{facts['schema_version']}"
)
banner("COPY")
os.makedirs(dest, exist_ok=True)
snap_db = assert_outside_production(os.path.join(dest, SNAP_CORE_DB), "core db")
copy_database(SRC_CORE_DB, snap_db)
ok(f"{SNAP_CORE_DB} written by sqlite online backup (consistent copy)")
for src, name in ((SRC_IDENTITY, SNAP_IDENTITY),
(SRC_CLIENTDATA, SNAP_CLIENTDATA)):
dst = assert_outside_production(os.path.join(dest, name), name)
shutil.copy2(src, dst)
# The source is root-owned; the copy must be writable by the staging user
# that installs it.
os.chmod(dst, 0o644)
ok(f"{SNAP_IDENTITY} and {SNAP_CLIENTDATA} copied")
banner("PROVE THE COPY IS FAITHFUL AND THE SOURCE IS UNTOUCHED")
after = {p: sha256(p) for p in before}
changed = [os.path.basename(p) for p in before if before[p] != after[p]]
if changed:
raise Fatal(
"PRODUCTION STATE WAS MODIFIED during the snapshot: "
f"{changed} -- this must never happen"
)
ok("every source file byte-identical before and after (sha256)")
copy_facts = read_club_facts(snap_db)
differences = {
key: (facts[key], copy_facts[key])
for key in facts
if facts[key] != copy_facts[key]
}
if differences:
raise Fatal(f"snapshot does not match the source: {differences}")
ok(
f"snapshot matches source exactly: {copy_facts['owned_cards']} owned "
f"cards, {copy_facts['coins']:,} coins, "
f"{copy_facts['squad_players']} squad players"
)
identity = json.load(open(os.path.join(dest, SNAP_IDENTITY)))
rows = identity.get("rows", [])
owned_rows = [r for r in rows if r.get("entity_kind") == "owned-item"]
conn = sqlite3.connect(f"file:{snap_db}?mode=ro", uri=True)
try:
owned_ids = {r[0] for r in conn.execute("SELECT id FROM owned_cards")}
finally:
conn.close()
unmapped = owned_ids - {r["core_id"] for r in owned_rows}
if unmapped:
raise Fatal(
f"{len(unmapped)} owned cards have no wire id in the identity store; "
"the client would see them appear under freshly minted ids and its "
f"cached squad would break. Sample: {sorted(unmapped)[:5]}"
)
ok(
f"identity store maps all {len(owned_ids)} owned cards to stable wire "
f"ids ({len(owned_rows)} rows, watermarks {identity.get('watermarks')})"
)
manifest = {
"taken_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"source_dir": PROD_STATE,
"sources": {
os.path.basename(p): {"sha256": before[p], "bytes": os.path.getsize(p)}
for p in before
},
"members": {
"core_db": SNAP_CORE_DB,
"identity": SNAP_IDENTITY,
"clientdata": SNAP_CLIENTDATA,
},
"club": copy_facts,
"identity_rows": len(rows),
"watermarks": identity.get("watermarks"),
}
with open(os.path.join(dest, SNAP_MANIFEST), "w") as fh:
json.dump(manifest, fh, indent=2)
fh.write("\n")
ok(f"manifest written: {os.path.join(dest, SNAP_MANIFEST)}")
after_state = production_state()
ok("production after : " + ", ".join(after_state.describe()))
banner("SNAPSHOT READY")
print(f" {dest}")
print()
print(" Install it into staging with:")
print(" python3 scripts/sold-staging-up.py --club real "
"--variant highest \\")
print(" --roster-host winter15.gosredirector.ea.com:8081")
return 0
except ProductionError as exc:
print(f"\nFATAL: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
+35
View File
@@ -21,11 +21,20 @@ answers `{"id": 0}` and does NOT echo the squad.
python3 scripts/sold-staging-seed-squad.py # seed, then verify
python3 scripts/sold-staging-seed-squad.py --show # read-only
This is a FIXTURE tool. `PUT /squad/0` is a FULL REPLACEMENT, so running it against
the operator's real imported club would overwrite that club's own squad with an
auto-picked XI -- a destructive, unrecoverable-in-place edit of real data. It
therefore refuses to run when the staging manifest says the real club is installed,
unless `--force` is given. `--show` stays available in every mode.
"""
import http.client
import json
import os
import sys
STAGING_MANIFEST = "/home/alex/openfut-sold-staging/manifest.json"
HOST, PORT = "127.0.0.1", 8299
FORBIDDEN = {8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216}
HEADERS = {"X-OpenFUT-Game": "fifa17", "Content-Type": "application/json"}
@@ -153,11 +162,37 @@ def show():
return len(filled)
def refuse_on_real_club():
"""`PUT /squad/0` replaces the whole squad. Against the real club that destroys
the operator's own lineup, so the fixture seeder must not be runnable there by
accident -- the staging manifest records which club is installed."""
if "--force" in sys.argv:
print(" --force: seeding over the installed club as instructed")
return
try:
with open(STAGING_MANIFEST) as fh:
manifest = json.load(fh)
except FileNotFoundError:
return # no manifest: nothing claims a real club is installed
if manifest.get("club") != "real":
return
club = manifest.get("real_club") or {}
raise SystemExit(
"REFUSING: staging is running the operator's REAL club "
f"({club.get('username')}, {club.get('owned_cards')} items) and "
"PUT /squad/0 is a FULL REPLACEMENT -- this would overwrite the real squad "
f"({club.get('squad_players')} players) with an auto-picked XI.\n"
" Read it instead: python3 scripts/sold-staging-seed-squad.py --show\n"
" Override only if you mean it: --force"
)
def main():
print("=== staging squad, before ===")
before = show()
if "--show" in sys.argv:
return 0
refuse_on_real_club()
players = club_players()
print(f"=== owned players available: {len(players)} ===")
chosen, missing = pick_xi(players)
+190 -56
View File
@@ -39,6 +39,15 @@ default) and advertises the production POW content/API hosts
The client must resolve the roster hostname to this server without changing the
advertised URL.
WHICH CLUB the seller plays with is chosen by `--club`:
* `fixture` (default) seeds a 14-item synthetic club -- 11 starters, one
disposable item to sell, two kits -- which is all the SOLD experiment needs;
* `real` installs the operator's own imported club from the snapshot produced by
`scripts/club-snapshot.py`, including its coins, its squad and the identity
store that keeps every item's wire id stable. This script still never reads
production state: the snapshot lives outside it, which is what makes the
`safe_path()` refusal above compatible with playing the real club.
python3 scripts/sold-staging-up.py --variant highest
python3 scripts/sold-staging-up.py --variant buyNow --coins-processed 1 \
--count-mode active_plus_sold
@@ -119,6 +128,16 @@ CONTENT_SRC = "/home/alex/openfut-post-p1/staging/emit/content"
CARDS_NAME = "fifa17-production-cards.json"
CATALOG_NAME = "fifa17-production-catalog.json"
# The operator's REAL club (the 1986-item CAGE import), as staged by
# scripts/club-snapshot.py. That snapshot lives OUTSIDE the production state
# directory precisely so this script never has to read production state:
# FORBIDDEN_PATHS stays absolute and safe_path() still refuses the live directory.
CLUB_SNAPSHOT_DIR = "/home/alex/openfut-club-snapshot"
SNAP_CORE_DB = "core.db"
SNAP_IDENTITY = "identity.json"
SNAP_CLIENTDATA = "clientdata.json"
SNAP_MANIFEST = "snapshot.json"
GAME = "fifa17"
PERSONA_ID = "33068179"
PERSONA_NAME = "CAGE"
@@ -489,30 +508,89 @@ def materialise(lay: Layout) -> None:
ok("added ownership-backed home/away kit fixtures from fcc_kitcards")
def assert_seed_cards_resolvable(lay: Layout) -> None:
def install_real_club(lay: Layout) -> dict:
"""Install the operator's real club from the snapshot in place of the fixture.
The snapshot database is a schema generation behind (it predates
match_completions, squad managers and kit assignments), so it is installed
BEFORE migrate_core and Core brings the COPY forward. Production's own files
are never opened here: scripts/club-snapshot.py already took them out, which is
why this script can keep refusing the production state directory outright.
"""
manifest_path = os.path.join(CLUB_SNAPSHOT_DIR, SNAP_MANIFEST)
if not os.path.isfile(manifest_path):
raise Fatal(
f"no club snapshot at {CLUB_SNAPSHOT_DIR}.\n"
" Take one first (it is read-only on production state):\n"
" python3 scripts/club-snapshot.py"
)
with open(manifest_path) as fh:
manifest = json.load(fh)
for name, dst in ((SNAP_CORE_DB, lay.core_db),
(SNAP_IDENTITY, lay.identity),
(SNAP_CLIENTDATA, lay.clientdata)):
src = os.path.join(CLUB_SNAPSHOT_DIR, name)
if not os.path.isfile(src):
raise Fatal(f"club snapshot is incomplete: missing {src}")
shutil.copy2(src, safe_path(dst))
os.chmod(safe_path(dst), 0o644)
club = manifest["club"]
ok(
f"installed the real club from {CLUB_SNAPSHOT_DIR} (taken "
f"{manifest['taken_at']}): {club['username']}, {club['owned_cards']} items, "
f"{club['coins']:,} coins, schema v{club['schema_version']}"
)
ok(
"wire ids come from the snapshot identity store, so item ids the client "
f"already cached still resolve ({manifest['identity_rows']} rows, "
f"watermarks {manifest['watermarks']})"
)
return club
def assert_seed_cards_resolvable(lay: Layout, real_club: dict | None) -> None:
"""Core's content preflight rejects any owned card whose card_id is not a loaded
CardDefinition, and the host refuses to shape a /club item with no catalog
identity. Prove BOTH memberships now, not via a startup crash later."""
identity. Prove BOTH memberships now, not via a startup crash later.
For the real club this is the check that matters most: a card_id missing from
the pack is not an error at read time, it is silently filter_map-dropped, so the
symptom is an EMPTY /collection with all the rows still sitting in the database.
"""
with open(safe_path(lay.cards)) as fh:
pack_ids = {c["id"] for c in json.load(fh)}
with open(safe_path(lay.catalog)) as fh:
catalog_ids = set(json.load(fh)["cards"])
wanted = (
[card for _, card in SELLER_SQUAD_CARDS]
+ [DISPOSABLE_CARD]
+ [card for _, _, card, _ in STAGING_KITS]
)
missing_pack = sorted(set(wanted) - pack_ids)
missing_cat = sorted(set(wanted) - catalog_ids)
if real_club is None:
wanted = set(
[card for _, card in SELLER_SQUAD_CARDS]
+ [DISPOSABLE_CARD]
+ [card for _, _, card, _ in STAGING_KITS]
)
what = f"all {len(wanted)} fixture seed card ids"
else:
conn = sqlite3.connect(f"file:{safe_path(lay.core_db)}?mode=ro", uri=True)
try:
wanted = {row[0] for row in
conn.execute("SELECT DISTINCT card_id FROM owned_cards")}
finally:
conn.close()
wanted |= {card for _, _, card, _ in STAGING_KITS}
what = (f"all {len(wanted)} distinct card ids owned by the real club "
"(plus the kit fixtures)")
missing_pack = sorted(wanted - pack_ids)
missing_cat = sorted(wanted - catalog_ids)
if missing_pack or missing_cat:
raise Fatal(
"seed card ids are not resolvable -- Core or the host would fail at "
f"startup.\n absent from content pack: {missing_pack}"
f"\n absent from identity catalog: {missing_cat}"
"card ids are not resolvable. Core drops an owned card whose definition "
"is missing instead of failing, so this would surface as an EMPTY club "
"with every row still in the database.\n"
f" absent from content pack ({len(missing_pack)}): {missing_pack[:10]}\n"
f" absent from identity catalog ({len(missing_cat)}): {missing_cat[:10]}"
)
ok(
f"all {len(wanted)} seed card ids present in BOTH the content pack "
f"({len(pack_ids)} defs) and the identity catalog ({len(catalog_ids)} entries)"
f"{what} present in BOTH the content pack ({len(pack_ids)} defs) and the "
f"identity catalog ({len(catalog_ids)} entries)"
)
@@ -607,73 +685,96 @@ def verify_blaze_patch(lay: Layout) -> None:
# --- seeding ------------------------------------------------------------------------
def seed_core_db(lay: Layout) -> None:
"""Two identities by direct SQL, against the schema Core just migrated.
def seed_core_db(lay: Layout, real_club: dict | None) -> None:
"""Seed the identities the experiment needs, against the schema Core just
migrated.
Column sets are from openfut-core/migrations/0001_initial.sql plus 0016's
profiles.game_id. Seller A carries game_id `fifa17` so the retail client (which
sends X-OpenFUT-Game: fifa17) resolves to it; Buyer B is parked on its own
game_id so it can never shadow the seller as the active fifa17 profile.
With the real club installed, Seller A already exists -- it IS the imported
persona, with its own club id, coins, items and squad -- so only Buyer B is
added, and the kit fixtures are attached to the real club so the kit work stays
exercisable. The real squad is never touched: it is the operator's own.
"""
conn = sqlite3.connect(safe_path(lay.core_db), timeout=15)
try:
conn.execute("PRAGMA busy_timeout = 15000")
with conn:
profiles = [(BUYER_PROFILE, "BUYER-B", TS, TS, BUYER_GAME)]
clubs = [(BUYER_CLUB, BUYER_PROFILE, "Buyer B FC", BUYER_COINS, TS, TS)]
if real_club is None:
profiles.insert(0, (SELLER_PROFILE, PERSONA_NAME, TS, TS, GAME))
clubs.insert(0, (SELLER_CLUB, SELLER_PROFILE, f"{PERSONA_NAME} FC",
SELLER_COINS, TS, TS))
conn.executemany(
"INSERT INTO profiles (id, username, level, xp, created_at, "
"updated_at, game_id) VALUES (?, ?, 1, 0, ?, ?, ?)",
[
(SELLER_PROFILE, PERSONA_NAME, TS, TS, GAME),
(BUYER_PROFILE, "BUYER-B", TS, TS, BUYER_GAME),
],
profiles,
)
conn.executemany(
"INSERT INTO clubs (id, profile_id, name, coins, level, created_at, "
"updated_at) VALUES (?, ?, ?, ?, 1, ?, ?)",
[
(SELLER_CLUB, SELLER_PROFILE, f"{PERSONA_NAME} FC", SELLER_COINS,
TS, TS),
(BUYER_CLUB, BUYER_PROFILE, "Buyer B FC", BUYER_COINS, TS, TS),
],
clubs,
)
seller_club = SELLER_CLUB if real_club is None else real_club["club_id"]
owned = [
(owned_id, seller_club, card_id, TS)
for _slot, owned_id, card_id, _resource_id in STAGING_KITS
]
if real_club is None:
owned = (
[(item, SELLER_CLUB, card, TS) for item, card in SELLER_SQUAD_CARDS]
+ [(DISPOSABLE_ITEM, SELLER_CLUB, DISPOSABLE_CARD, TS)]
+ owned
)
conn.executemany(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, "
"acquired_at) VALUES (?, ?, ?, 0, ?)",
[(item, SELLER_CLUB, card, TS) for item, card in SELLER_SQUAD_CARDS]
+ [(DISPOSABLE_ITEM, SELLER_CLUB, DISPOSABLE_CARD, TS)]
+ [
(owned_id, SELLER_CLUB, card_id, TS)
for _slot, owned_id, card_id, _resource_id in STAGING_KITS
],
)
conn.execute(
"INSERT INTO squads (id, club_id, name, formation, created_at, "
"updated_at) VALUES (?, ?, ?, ?, ?, ?)",
(SELLER_SQUAD, SELLER_CLUB, "Staging XI", "4-3-3", TS, TS),
)
conn.executemany(
"INSERT INTO squad_players (id, squad_id, owned_card_id, "
"position_index, is_captain, is_on_bench) VALUES (?, ?, ?, ?, ?, 0)",
[
(f"sp-{idx}", SELLER_SQUAD, item, idx, 1 if idx == 0 else 0)
for idx, (item, _) in enumerate(SELLER_SQUAD_CARDS)
],
owned,
)
if real_club is None:
conn.execute(
"INSERT INTO squads (id, club_id, name, formation, created_at, "
"updated_at) VALUES (?, ?, ?, ?, ?, ?)",
(SELLER_SQUAD, SELLER_CLUB, "Staging XI", "4-3-3", TS, TS),
)
conn.executemany(
"INSERT INTO squad_players (id, squad_id, owned_card_id, "
"position_index, is_captain, is_on_bench) VALUES (?, ?, ?, ?, ?, 0)",
[
(f"sp-{idx}", SELLER_SQUAD, item, idx, 1 if idx == 0 else 0)
for idx, (item, _) in enumerate(SELLER_SQUAD_CARDS)
],
)
conn.executemany(
"INSERT INTO club_kit_assignments (club_id, slot, owned_card_id, updated_at) "
"VALUES (?, ?, ?, ?)",
[
(SELLER_CLUB, slot, owned_id, TS)
(seller_club, slot, owned_id, TS)
for slot, owned_id, _card_id, _resource_id in STAGING_KITS
],
)
finally:
conn.close()
ok(
f"seeded Seller A ({PERSONA_NAME}, persona {PERSONA_ID}, {SELLER_COINS} coins, "
f"{len(SELLER_SQUAD_CARDS)} starters + 1 disposable + "
f"{len(STAGING_KITS)} active kits) and Buyer B ({BUYER_COINS} coins)"
)
if real_club is None:
ok(
f"seeded Seller A ({PERSONA_NAME}, persona {PERSONA_ID}, {SELLER_COINS} "
f"coins, {len(SELLER_SQUAD_CARDS)} starters + 1 disposable + "
f"{len(STAGING_KITS)} active kits) and Buyer B ({BUYER_COINS} coins)"
)
else:
ok(
f"kept the real club untouched ({real_club['owned_cards']} items, "
f"{real_club['coins']:,} coins, squad {real_club['squads'][0]['name']!r} "
f"with {real_club['squad_players']} players); added "
f"{len(STAGING_KITS)} active kits and Buyer B ({BUYER_COINS} coins)"
)
def db_summary(lay: Layout) -> str:
@@ -919,8 +1020,23 @@ def cfg_block() -> list[str]:
def print_summary(lay: Layout, variant: str, coins_processed: str, count_mode: str,
roster_host: str, records: list[dict]) -> None:
roster_host: str, records: list[dict],
real_club: dict | None) -> None:
banner("STAGING STACK IS UP")
if real_club is None:
print(" club: the 14-item synthetic fixture "
"(--club real installs the operator's own)")
else:
squad = real_club["squads"][0] if real_club["squads"] else None
print(f" club: THE REAL ONE -- {real_club['username']} "
f"({real_club['club_name']}), {real_club['owned_cards']} items, "
f"{real_club['coins']:,} coins")
if squad is not None:
print(f" squad {squad['name']!r} ({squad['formation']}) with "
f"{real_club['squad_players']} players")
print(" DO NOT run sold-staging-seed-squad.py: it would overwrite "
"this squad with the fixture XI")
print()
rows = [
("staging Core", f"127.0.0.1:{CORE_PORT}", "loopback only; client never talks to it"),
("staging utas-host", f"{BIND}:{HOST_PORT}", "UTAS the client reaches"),
@@ -1009,6 +1125,12 @@ def main() -> int:
ap.add_argument("--dir", default=os.environ.get("OPENFUT_SOLD_STAGING_DIR",
DEFAULT_STAGING_DIR),
help=f"staging directory (default: {DEFAULT_STAGING_DIR})")
ap.add_argument(
"--club", choices=["fixture", "real"], default="fixture",
help="which club the seller plays with: the 14-item synthetic fixture "
"(default) or the operator's real imported club, installed from the "
"snapshot taken by scripts/club-snapshot.py",
)
args = ap.parse_args()
lay = Layout(args.dir)
@@ -1019,6 +1141,7 @@ def main() -> int:
step(f"repo : {REPO}")
step(f"forbidden ports : {sorted(FORBIDDEN_PORTS)}")
step(f"forbidden paths : {list(FORBIDDEN_PATHS)}")
step(f"club : {args.club}")
assert_prod_alive("preflight")
refuse_if_up(lay)
assert_ports_free()
@@ -1026,16 +1149,25 @@ def main() -> int:
banner("MATERIALISE STAGING DIRECTORY")
materialise(lay)
reset_throwaway_state(lay)
assert_seed_cards_resolvable(lay)
# The real club is installed BEFORE Core first runs, so Core migrates the
# snapshot forward (it is a schema generation behind) rather than being
# handed a database it has already opened.
real_club = install_real_club(lay) if args.club == "real" else None
assert_seed_cards_resolvable(lay, real_club)
banner("PATCH THE BLAZE RESPONDER COPY")
patch_blaze(lay)
banner("STAGING CORE")
if os.path.exists(lay.core_db):
raise Fatal(f"{lay.core_db} should have been removed by the state reset")
if real_club is None:
if os.path.exists(lay.core_db):
raise Fatal(
f"{lay.core_db} should have been removed by the state reset"
)
elif not os.path.exists(lay.core_db):
raise Fatal(f"{lay.core_db} should have been installed from the snapshot")
migrate_core(lay)
seed_core_db(lay)
seed_core_db(lay, real_club)
started.append(start_core(lay))
banner("STAGING UTAS-HOST")
@@ -1059,6 +1191,8 @@ def main() -> int:
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"staging_dir": lay.root,
"variant": args.variant,
"club": args.club,
"real_club": real_club,
"coins_processed": args.coins_processed,
"count_mode": args.count_mode,
"roster_host": args.roster_host,
@@ -1089,7 +1223,7 @@ def main() -> int:
verify(lay, args.variant)
print_summary(lay, args.variant, args.coins_processed, args.count_mode,
args.roster_host, records)
args.roster_host, records, real_club)
return 0
except ProductionError as exc:
print(f"\nFATAL: {exc}", file=sys.stderr)