fix(staging): re-stamp the squad extension after seeding, and prove it

Filling the bench writes squad rows behind Core's back, which invalidates the
stored FIFA17 opaque extension: Core saw a canonical squad that no longer
matched the extension's fingerprint, the host refused to apply it
(`stale_integrity`), and the client got a squad with zero players and no
manager. Nothing failed loudly — the rig just came up empty.

The seeder now recomputes the fingerprint exactly as
`services::squad::squad_fingerprint` does, and bring-up asserts the squad really
projects (>= 18 occupied, a manager present) instead of trusting that it did.

Seeded owned rows also state their content_kind, so Core does not record a kit
or a manager as a player.
This commit is contained in:
funman300
2026-08-21 19:50:21 +00:00
parent 770029f207
commit 1a6355cad5
+98 -9
View File
@@ -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|<squad>|<formation>|` plus SORTED `slot:owned:captain:bench` items joined
by `;`, hashed with FNV-1a-64 and printed as 16 lowercase hex digits.
Core stamps this whenever the canonical squad changes, and the FIFA17 opaque
extension is only applied when its stored fingerprint still matches. Writing
squad rows directly therefore INVALIDATES the extension, and the host then
projects `stale_integrity` — no players and no manager at all. Re-stamping is
what a squad replace through Core would have done.
"""
items = sorted(
f"{slot}:{owned}:{int(captain)}:{int(bench)}" for slot, owned, captain, bench in slots
)
canon = f"v1|{squad_id}|{formation}|" + ";".join(items)
h = 0xCBF29CE484222325
for b in canon.encode():
h = ((h ^ b) * 0x00000100000001B3) & 0xFFFFFFFFFFFFFFFF
return f"{h:016x}"
def restamp_squad_extension(conn, squad_id: str) -> bool:
"""Bring the stored FIFA17 extension back in step with the canonical squad.
Returns False when the squad has no extension row (nothing to re-stamp),
which is the fixture club's normal state.
"""
row = conn.execute(
"SELECT formation FROM squads WHERE id = ?", (squad_id,)
).fetchone()
if row is None:
return False
slots = conn.execute(
"SELECT position_index, owned_card_id, is_captain, is_on_bench "
"FROM squad_players WHERE squad_id = ?", (squad_id,)
).fetchall()
fingerprint = _squad_fingerprint(squad_id, row[0], slots)
changed = conn.execute(
"UPDATE game_entity_ext SET canonical_fingerprint = ? "
"WHERE entity_kind = 'squad' AND entity_id = ?",
(fingerprint, squad_id),
).rowcount
return changed > 0
def fill_bench_to_minimum(conn, club_id: str, squad_id: str, lay: Layout) -> int:
"""Top the squad up to the client's minimum with the club's best spare
players, filling empty bench slots from index 11 upward.
@@ -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)