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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user