diff --git a/scripts/sold-staging-up.py b/scripts/sold-staging-up.py index 160f688..09ad314 100755 --- a/scripts/sold-staging-up.py +++ b/scripts/sold-staging-up.py @@ -758,6 +758,53 @@ CLIENT_MIN_SQUAD = 18 SQUAD_SLOTS = 23 +def _squad_fingerprint(squad_id: str, formation: str, slots) -> str: + """Recompute Core's canonical squad fingerprint. + + Mirrors `openfut-core/src/services/squad.rs::squad_fingerprint` exactly: + `v1|||` plus SORTED `slot:owned:captain:bench` items joined + by `;`, hashed with FNV-1a-64 and printed as 16 lowercase hex digits. + + Core stamps this whenever the canonical squad changes, and the FIFA17 opaque + extension is only applied when its stored fingerprint still matches. Writing + squad rows directly therefore INVALIDATES the extension, and the host then + projects `stale_integrity` — no players and no manager at all. Re-stamping is + what a squad replace through Core would have done. + """ + items = sorted( + f"{slot}:{owned}:{int(captain)}:{int(bench)}" for slot, owned, captain, bench in slots + ) + canon = f"v1|{squad_id}|{formation}|" + ";".join(items) + h = 0xCBF29CE484222325 + for b in canon.encode(): + h = ((h ^ b) * 0x00000100000001B3) & 0xFFFFFFFFFFFFFFFF + return f"{h:016x}" + + +def restamp_squad_extension(conn, squad_id: str) -> bool: + """Bring the stored FIFA17 extension back in step with the canonical squad. + + Returns False when the squad has no extension row (nothing to re-stamp), + which is the fixture club's normal state. + """ + row = conn.execute( + "SELECT formation FROM squads WHERE id = ?", (squad_id,) + ).fetchone() + if row is None: + return False + slots = conn.execute( + "SELECT position_index, owned_card_id, is_captain, is_on_bench " + "FROM squad_players WHERE squad_id = ?", (squad_id,) + ).fetchall() + fingerprint = _squad_fingerprint(squad_id, row[0], slots) + changed = conn.execute( + "UPDATE game_entity_ext SET canonical_fingerprint = ? " + "WHERE entity_kind = 'squad' AND entity_id = ?", + (fingerprint, squad_id), + ).rowcount + return changed > 0 + + def fill_bench_to_minimum(conn, club_id: str, squad_id: str, lay: Layout) -> int: """Top the squad up to the client's minimum with the club's best spare players, filling empty bench slots from index 11 upward. @@ -858,22 +905,31 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None: ) seller_club = SELLER_CLUB if real_club is None else real_club["club_id"] + # Core classifies ownership generically (migration 0025), so every + # seeded row states what it IS. Getting this wrong would leave Core + # calling a kit a player, and Core is the ownership authority. owned = [ - (owned_id, seller_club, card_id, TS) + (owned_id, seller_club, card_id, "kit", TS) for _slot, owned_id, card_id, _resource_id in STAGING_KITS ] owned.append( - (STAGING_MANAGER["owned_id"], seller_club, STAGING_MANAGER["card_id"], TS) + ( + STAGING_MANAGER["owned_id"], + seller_club, + STAGING_MANAGER["card_id"], + "manager", + TS, + ) ) 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)] + [(item, SELLER_CLUB, card, "player", TS) for item, card in SELLER_SQUAD_CARDS] + + [(DISPOSABLE_ITEM, SELLER_CLUB, DISPOSABLE_CARD, "player", TS)] + owned ) conn.executemany( "INSERT INTO owned_cards (id, club_id, card_id, is_loan, " - "acquired_at) VALUES (?, ?, ?, 0, ?)", + "content_kind, acquired_at) VALUES (?, ?, ?, 0, ?, ?)", owned, ) @@ -892,11 +948,14 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None: ], ) + # Core generalised the two-slot kit table into slot-keyed active club + # designations (migration 0026), so the fixture writes 'home_kit' / + # 'away_kit' rather than 'home' / 'away'. conn.executemany( - "INSERT INTO club_kit_assignments (club_id, slot, owned_card_id, updated_at) " + "INSERT INTO club_active_items (club_id, slot, owned_card_id, updated_at) " "VALUES (?, ?, ?, ?)", [ - (seller_club, slot, owned_id, TS) + (seller_club, f"{slot}_kit", owned_id, TS) for slot, owned_id, _card_id, _resource_id in STAGING_KITS ], ) @@ -928,6 +987,10 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None: if real_club is not None else 0 ) + # Squad rows were just written behind Core's back (bench fill, and + # the manager designation above), so the opaque FIFA17 extension is + # now stale and the host would drop it — projecting an EMPTY squad. + restamped = restamp_squad_extension(conn, squad_row[0]) finally: conn.close() if real_club is None: @@ -950,6 +1013,12 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None: f"players: FIFA refuses to kick off below {CLIENT_MIN_SQUAD} " "(11 starters + 7 subs), and the imported squad carries only its XI" ) + if restamped: + ok( + "re-stamped the squad's FIFA17 extension fingerprint so Core still " + "considers it fresh after the squad changed (a stale extension " + "projects as an empty squad with no manager)" + ) def db_summary(lay: Layout) -> str: @@ -1148,7 +1217,7 @@ def host_banner_line(lay: Layout) -> str: return hits[-1] -def verify(lay: Layout, variant: str) -> None: +def verify(lay: Layout, variant: str, real_club_expected: bool) -> None: line = host_banner_line(lay) ok(f"host banner: {line}") if variant == "off": @@ -1167,6 +1236,26 @@ def verify(lay: Layout, variant: str) -> None: status, body = http_get(CORE_PORT, "/health") ok(f"GET 127.0.0.1:{CORE_PORT}/health -> HTTP {status} {body}") + # A stale opaque extension does not fail anything loudly — the host just + # drops it and the client sees an empty squad with no manager. Prove the + # real club actually projects before handing the rig over. + if real_club_expected: + status, body = http_get(HOST_PORT, f"/ut/game/{GAME}/squad/0") + squad = json.loads(body) if status == 200 else {} + occupied = sum(1 for p in squad.get("players", []) if p.get("itemData", {}).get("id")) + manager = squad.get("manager") or [] + ok( + f"GET /ut/game/{GAME}/squad/0 -> HTTP {status} " + f"{occupied} occupied slot(s), {len(manager)} manager" + ) + if occupied < CLIENT_MIN_SQUAD or not manager: + raise Fatal( + f"the squad projected {occupied} player(s) and {len(manager)} " + f"manager(s); expected at least {CLIENT_MIN_SQUAD} and one " + "manager. A 'stale_integrity' line in the host log means the " + "squad changed without re-stamping its extension fingerprint" + ) + # Nothing this process ever opened may live under the production state dir. leaked = [] for fd in os.listdir(f"/proc/{os.getpid()}/fd"): @@ -1395,7 +1484,7 @@ def main() -> int: ok(f"manifest written: {lay.manifest}") banner("VERIFY ISOLATION") - verify(lay, args.variant) + verify(lay, args.variant, real_club is not None) print_summary(lay, args.variant, args.coins_processed, args.count_mode, args.roster_host, records, real_club)