diff --git a/scripts/club-snapshot.py b/scripts/club-snapshot.py new file mode 100755 index 0000000..fc3108c --- /dev/null +++ b/scripts/club-snapshot.py @@ -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()) diff --git a/scripts/sold-staging-seed-squad.py b/scripts/sold-staging-seed-squad.py index f9b060b..76d9e62 100755 --- a/scripts/sold-staging-seed-squad.py +++ b/scripts/sold-staging-seed-squad.py @@ -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) diff --git a/scripts/sold-staging-up.py b/scripts/sold-staging-up.py index b6983b0..7986ea4 100755 --- a/scripts/sold-staging-up.py +++ b/scripts/sold-staging-up.py @@ -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)