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
|
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:
|
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
|
"""Top the squad up to the client's minimum with the club's best spare
|
||||||
players, filling empty bench slots from index 11 upward.
|
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"]
|
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 = [
|
||||||
(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
|
for _slot, owned_id, card_id, _resource_id in STAGING_KITS
|
||||||
]
|
]
|
||||||
owned.append(
|
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:
|
if real_club is None:
|
||||||
owned = (
|
owned = (
|
||||||
[(item, SELLER_CLUB, card, TS) for item, card in SELLER_SQUAD_CARDS]
|
[(item, SELLER_CLUB, card, "player", TS) for item, card in SELLER_SQUAD_CARDS]
|
||||||
+ [(DISPOSABLE_ITEM, SELLER_CLUB, DISPOSABLE_CARD, TS)]
|
+ [(DISPOSABLE_ITEM, SELLER_CLUB, DISPOSABLE_CARD, "player", TS)]
|
||||||
+ owned
|
+ owned
|
||||||
)
|
)
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, "
|
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, "
|
||||||
"acquired_at) VALUES (?, ?, ?, 0, ?)",
|
"content_kind, acquired_at) VALUES (?, ?, ?, 0, ?, ?)",
|
||||||
owned,
|
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(
|
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 (?, ?, ?, ?)",
|
"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
|
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
|
if real_club is not None
|
||||||
else 0
|
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:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
if real_club is None:
|
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} "
|
f"players: FIFA refuses to kick off below {CLIENT_MIN_SQUAD} "
|
||||||
"(11 starters + 7 subs), and the imported squad carries only its XI"
|
"(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:
|
def db_summary(lay: Layout) -> str:
|
||||||
@@ -1148,7 +1217,7 @@ def host_banner_line(lay: Layout) -> str:
|
|||||||
return hits[-1]
|
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)
|
line = host_banner_line(lay)
|
||||||
ok(f"host banner: {line}")
|
ok(f"host banner: {line}")
|
||||||
if variant == "off":
|
if variant == "off":
|
||||||
@@ -1167,6 +1236,26 @@ def verify(lay: Layout, variant: str) -> None:
|
|||||||
status, body = http_get(CORE_PORT, "/health")
|
status, body = http_get(CORE_PORT, "/health")
|
||||||
ok(f"GET 127.0.0.1:{CORE_PORT}/health -> HTTP {status} {body}")
|
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.
|
# Nothing this process ever opened may live under the production state dir.
|
||||||
leaked = []
|
leaked = []
|
||||||
for fd in os.listdir(f"/proc/{os.getpid()}/fd"):
|
for fd in os.listdir(f"/proc/{os.getpid()}/fd"):
|
||||||
@@ -1395,7 +1484,7 @@ def main() -> int:
|
|||||||
ok(f"manifest written: {lay.manifest}")
|
ok(f"manifest written: {lay.manifest}")
|
||||||
|
|
||||||
banner("VERIFY ISOLATION")
|
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,
|
print_summary(lay, args.variant, args.coins_processed, args.count_mode,
|
||||||
args.roster_host, records, real_club)
|
args.roster_host, records, real_club)
|
||||||
|
|||||||
Reference in New Issue
Block a user