diff --git a/scripts/fifa17-definition-coverage.py b/scripts/fifa17-definition-coverage.py new file mode 100755 index 0000000..b98176e --- /dev/null +++ b/scripts/fifa17-definition-coverage.py @@ -0,0 +1,830 @@ +#!/usr/bin/env python3 +"""FIFA 17 definition-coverage guard. + +Classifies EVERY table in the FIFA 17 resident-database dump +(`fifa17-recon/data/tables/*.json`, produced by `fifa17-recon/tools/db_dump.py`) +into exactly one coverage bucket, and every definition id those tables ship into +exactly one of them too. A table with no rule lands in UNKNOWN and the run +FAILS: that is the whole point. Adding a newly dumped table, or renaming one, +must force a human to classify it rather than letting it silently disappear into +"presentation". + +This is OFFLINE ANALYSIS TOOLING (an RE/coverage oracle). It is not authoritative +for anything at runtime: the authoritative reader/writer/owner of FUT items is +Rust Core plus the FIFA 17 adapter. + +Authority for the taxonomy below, in precedence order: + 1. `fifa17-recon/docs/plan-2026-08-06-card-subsystem.md` (SETTLED; outranks + `fifa17-recon/docs/CARD_SYSTEM.md` wherever the two disagree) + 2. the shipped tables themselves (`--tables`) and + `fifa17-recon/data/consumables.json` (`--consumables`) + 3. the Rust taxonomy that consumes them: + `openfut-adapter-fifa17/src/fut/content_taxonomy.rs` and + `openfut-import-fifa17/src/lib.rs::club_family` + +Buckets +------- +KNOWN_OWNABLE A FUT club can hold an inventory instance of these + definitions, and the class is inside OpenFUT's item + vocabulary (player / staff / consumable / kit / badge / + ball / stadium / league logo). +KNOWN_PRESENTATION_ONLY Shipped and rendered, but never owned as an inventory + instance: match/career content, asset registries, pure + lookup and localisation tables, empty scratch mirrors. + Trophies belong to this class conceptually but ship NO + definition table at all (see TROPHY_NOTE). +KNOWN_UNSUPPORTED A real card-definition class, ownable in the retail + game, that OpenFUT deliberately does not support yet. + Every entry carries a one-line stated reason. +UNKNOWN Cannot be placed from evidence. MUST be zero. + +Usage +----- + python3 scripts/fifa17-definition-coverage.py [--tables DIR] [--json] [-v] + +Exits 0 only when UNKNOWN == 0 and every invariant check passes. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import NamedTuple + +OWNABLE = "KNOWN_OWNABLE" +PRESENTATION = "KNOWN_PRESENTATION_ONLY" +UNSUPPORTED = "KNOWN_UNSUPPORTED" +UNKNOWN = "UNKNOWN" + +BUCKET_ORDER = (OWNABLE, UNSUPPORTED, PRESENTATION, UNKNOWN) + + +class Rule(NamedTuple): + """One table's classification. + + bucket -- which coverage bucket the table belongs to. + id_col -- the column holding the CARD DEFINITION id, or None when the + table ships no definition id of its own (a table may still hold + foreign keys into somebody else's id space; those are not + definition ids and are never counted here). + id_space -- the shared id-space key. Two tables with the same key are + counted as a UNION, never summed (see ID_SPACES). + reason -- why this table is in this bucket. Evidence, not opinion. + """ + + bucket: str + id_col: str | None + id_space: str | None + reason: str + + +def ownable(id_col: str, id_space: str, reason: str) -> Rule: + return Rule(OWNABLE, id_col, id_space, reason) + + +def presentation(reason: str) -> Rule: + # Presentation tables ship no definition id of their own by construction: + # if one did, it would be a card class and would not be in this bucket. + return Rule(PRESENTATION, None, None, reason) + + +def unsupported(id_col: str, id_space: str, reason: str) -> Rule: + return Rule(UNSUPPORTED, id_col, id_space, reason) + + +# --------------------------------------------------------------------------- +# THE ONE CLASSIFICATION TABLE. Audit this dict and you have audited the tool. +# One entry per table in the dump; a table absent from here becomes UNKNOWN. +# --------------------------------------------------------------------------- +RULES: dict[str, Rule] = { + # -- KNOWN_OWNABLE: the 16 real card-definition tables --------------------- + # (15 here + fcc_misccards, which is the 16th definition table but sits in + # KNOWN_UNSUPPORTED; the 15 below + misccards are the settled 16.) + "players": ownable( + "playerid", "player.playerid", + "cardtype 1 player definitions; playerid IS the FUT assetId. The " + "reference ownable class — read/write/ownership already in Core.", + ), + "managercards": ownable( + "carddbid", "staff.manager.carddbid", + "staff cardsubtypeid 4 (manager); cardtype 2 merge FUN_1801356c0. " + "Ownable staff card (carries value/rare/nation/formationid).", + ), + "headcoachcards": ownable( + "carddbid", "staff.headcoach.carddbid", + "staff cardsubtypeid 5 (head coach) in the FUN_1800d8330 family " + "selector; ownable staff card.", + ), + "gkcoachcards": ownable( + "carddbid", "staff.gkcoach.carddbid", + "staff cardsubtypeid 6 (GK coach) in the FUN_1800d8330 family " + "selector; ownable staff card.", + ), + "physiocards": ownable( + "carddbid", "staff.physio.carddbid", + "staff cardsubtypeid 7 (physio) in the FUN_1800d8330 family selector; " + "ownable staff card.", + ), + "fitnesscoachcards": ownable( + "carddbid", "staff.fitnesscoach.carddbid", + "staff cardsubtypeid 8 (fitness coach) in the FUN_1800d8330 family " + "selector; ownable staff card.", + ), + "fcc_contractcards": ownable( + "carddbid", "consumable.contract.carddbid", + "consumable definitions, cardsubtype 201 (player contract) / 202 " + "(manager contract); both in data/consumables.json.", + ), + "fcc_healingcards": ownable( + "carddbid", "consumable.healing.carddbid", + "consumable definitions, cardsubtype 211..218 (healing) + 219/220 " + "(player/squad fitness); all in data/consumables.json.", + ), + "fcc_trainingcards": ownable( + "carddbid", "consumable.training.carddbid", + "consumable definitions: GK/player training 51..67, position 91..110, " + "formation 121..136, chemistry styles 250..273, manager league " + "300..341; all in data/consumables.json.", + ), + "fcc_kitcards": ownable( + "carddbid", "club.kit.carddbid", + "cardtype 7 club item, cardsubtypeid 9 (kit) via resolver FUN_180119bd0; " + "constant cardassetid 35 on all rows. Ownable club customisation.", + ), + "fcc_stadium": ownable( + "carddbid", "club.stadium.carddbid", + "cardtype 7 club item, cardsubtypeid 10 (stadium); constant cardassetid " + "36. Ownable club customisation (stadiumid is a foreign key into the " + "match-venue registry, not this table's identity).", + ), + "fcc_badgecards": ownable( + "carddbid", "club.badge.carddbid", + "cardtype 7 club item, cardsubtypeid 11 (club badge); constant " + "cardassetid 39. Ownable club customisation.", + ), + "fcc_balls": ownable( + "carddbid", "club.ball.carddbid", + "cardtype 9 club item, cardsubtypeid 30 (ball, FUT_UC_BALL caption); " + "constant cardassetid 37. Ownable club customisation.", + ), + "fcc_leaguelogos": ownable( + "carddbid", "club.leaguelogo.carddbid", + "cardtype 9 club item, cardsubtypeid 31 (league logo, by elimination " + "over FUN_1800d8330's cardtype-9 set); constant cardassetid 40. " + "SHARES its carddbid space with fcc_leaguelogostickers — see " + "ID_SPACES/collision report.", + ), + "fcc_leaguelogostickers": ownable( + "carddbid", "club.leaguelogo.carddbid", + "same cardsubtypeid 31 / cardassetid 40 league-logo class; its 39 " + "carddbids ALL collide with fcc_leaguelogos' ids (shipped data bug), so " + "it contributes 0 new definition ids and is counted as a union.", + ), + + # -- KNOWN_UNSUPPORTED: real card classes OpenFUT deliberately defers ------ + "fcc_misccards": unsupported( + "carddbid", "misc.carddbid", + "cardtype 9 cardsubtype {231,232,233,236} = FUN_1800d8330's " + "0xe7..0xe9/0xec block, matched exactly by this table's cardsubtype " + "column. These ARE owned items (the client's TO_TRADE_PILE gate " + "excludes exactly {0xe7,0xe8,0xe9,0xec}, which only means anything for " + "owned inventory), but the per-subtype semantics (cardassetid 43..46, " + "meaning of amount/rating) are NOT reverse-engineered, the subtypes are " + "absent from data/consumables.json's 172, and neither " + "content_taxonomy::consumable_family nor import::club_family maps them. " + "UNSUPPORTED rather than guessed.", + ), + "fut_storymodehero": unsupported( + "carddbid", "storymodehero.carddbid", + "a distinct carddbid space (22800..22879) for The Journey story-mode " + "hero cards, but the shipped row is only {carddbid, teamid}: no name, " + "rating, value, cardassetid or cardsubtypeid, so no FUT item can be " + "shaped from client data without fabricating it. OpenFUT does not " + "support story-mode hero items.", + ), + + # -- KNOWN_PRESENTATION_ONLY ---------------------------------------------- + # Crowd / atmosphere / match-engine lookups. + "BigAttendance": presentation( + "crowd-emotion band lookup (min/max/emotion) for a full stadium; " + "match presentation, not a card definition."), + "NoAttendance": presentation( + "crowd-emotion band lookup for an empty stadium; match presentation."), + "MatchIntensity": presentation( + "score-difference x match-minute AI intensity curve; match engine " + "lookup, no ids."), + "fcc_GrandStandPlayers": presentation( + "single playerid column choosing which player heads populate the FUT " + "hub grandstand. Foreign keys into the player id space (several do not " + "even resolve in the shipped players table); rendered, never owned."), + "celebrations": presentation( + "celebration animation ids; match presentation."), + "videos": presentation( + "per-locale video asset ids; front-end presentation."), + "eatrax": presentation( + "licensed soundtrack rows (song/artist/album); audio presentation."), + "songplaylistlinks": presentation( + "song-to-playlist links; audio presentation."), + "audionation": presentation( + "per-nation commentary/crowd audio bank indices; audio lookup."), + "audiostadium": presentation( + "per-stadium commentary language index; audio lookup."), + "dynamicimages": presentation( + "ad/sponsor dynamic image descriptors; stadium dressing presentation."), + "sponsors": presentation( + "ad sponsor registry (adsponserid); stadium dressing presentation."), + "teamsponsorlinks": presentation("team-to-sponsor links; presentation."), + "competitionsponsorlinks": presentation( + "competition-to-sponsor links; presentation."), + "modeadboardlinks": presentation("mode-to-adboard links; presentation."), + "presentationcompsettings": presentation( + "per-competition presentation feature flags; presentation."), + "presentationmodesettings": presentation( + "per-mode presentation feature flags; presentation."), + "assetcryptokeys": presentation( + "asset decryption keys (empty in this dump); build plumbing, not a " + "card definition."), + + # Asset registries the FUT card tables POINT AT. The ownable definition is + # the fcc_* card; these registries are the rendered asset behind it. + "teams": presentation( + "club/crest asset registry (assetid = team asset). fcc_badgecards' " + "teamid points here; the ownable badge definition is the fcc_ row."), + "teamkits": presentation( + "in-match kit asset registry. fcc_kitcards is the ownable FUT kit " + "definition; these rows are the geometry/colour asset it renders."), + "stadiums": presentation( + "match-venue registry (stadiumid). fcc_stadium is the ownable FUT " + "stadium definition; these rows are the venue it renders."), + "teamballs": presentation( + "in-match ball asset registry (ballid, isavailableinstore). fcc_balls " + "is the ownable FUT ball definition."), + "teamballremapping": presentation( + "ballid remap table (empty in this dump); asset plumbing."), + "dlcballs": presentation( + "DLC ball asset names (empty in this dump); asset registry, and FIFA 17 " + "FUT owns balls through fcc_balls, not through DLC asset rows."), + "dlcboots": presentation( + "DLC boot asset names (empty in this dump). FIFA 17 FUT has no boot " + "item class at all — boots are rendered on players, never owned."), + "playerboots": presentation( + "boot asset registry (shoetype/colour/licensing). Rendered on players; " + "FIFA 17 FUT ships no boot card class."), + "playerbootremapping": presentation( + "shoetype remap table (empty in this dump); asset plumbing."), + "shoecolors": presentation("boot colour palette; asset lookup."), + "manager": presentation( + "career-mode manager PERSONS (managerid, head/suit appearance). The " + "ownable FUT manager card is managercards."), + "physio": presentation( + "career-mode physio PERSONS (physioid, appearance). The ownable FUT " + "physio card is physiocards."), + "referee": presentation( + "referee persons/appearance (refereeid); match presentation, never " + "owned."), + "leaguerefereelinks": presentation("league-to-referee links; lookup."), + + # FUT front-end lookup / calculator tables (fcc_* that are NOT card tables). + "fcc_bonusvalues": presentation( + "chemistry/boost bonus value lookup (bonusid, bonuslevel); pure " + "calculator input, not a card definition."), + "fcc_managerbonusvalues": presentation( + "manager bonus value lookup; pure calculator input."), + "fcc_chemlinkcalc": presentation( + "formation x position chemistry-link matrix; pure calculator input."), + "fcc_nationcalc": presentation( + "nation grouping for chemistry (nationid -> groupid); lookup."), + "fcc_leagues": presentation( + "FUT league name/country lookup (leagueid -> futcountryid). " + "fcc_leaguelogos' leagueid points here; the ownable definition is the " + "fcc_leaguelogos row."), + "fcc_coinrewards": presentation( + "leaderboard objective coin reward lookup; economy table, not a card."), + "fcc_discardcoins": presentation( + "quick-sell price lookup keyed by (cardtype, level, rare). Prices for " + "cards, not card definitions."), + "fcc_myclubs": presentation("MY CLUB tab id/name lookup; UI structure."), + "fcc_myclubscategories": presentation( + "MY CLUB category rows (categoryid, isteamcategory); UI structure."), + "fcc_preferredpositioncalc": presentation( + "preferred-position calculator matrix; calculator input."), + "fcc_preferredformationcalcgk": presentation( + "preferred-formation calculator matrix (GK); calculator input."), + "fcc_preferredformationcalcback": presentation( + "preferred-formation calculator matrix (defence); calculator input."), + "fcc_preferredformationcalcmid": presentation( + "preferred-formation calculator matrix (midfield); calculator input."), + "fcc_preferredformationcalcst": presentation( + "preferred-formation calculator matrix (attack); calculator input."), + "fcc_formationcardspositions_hd": presentation( + "on-screen card X/Y coordinates per formation (HD layout); UI layout."), + "fcc_formationcardspositions_kc": presentation( + "on-screen card X/Y coordinates per formation (KC layout); UI layout."), + "fcc_textposvalues_hd": presentation( + "on-screen text offsets per formation (HD layout); UI layout."), + "fcc_textposvalues_kc": presentation( + "on-screen text offsets per formation (KC layout); UI layout."), + "fcc_navcoords_hd": presentation( + "D-pad navigation graph between squad slots (HD). Its `playerid` column " + "is a 0..11 SLOT INDEX, not a player id; UI navigation."), + "fcc_navcoords_kc": presentation( + "D-pad navigation graph between squad slots (KC); UI navigation."), + + # Squad / formation / tactics data. + "formations": presentation( + "formation definitions (positions, offsets, instructions); tactics " + "data, never owned."), + "formationlayout": presentation("formation-to-formation slot remap; lookup."), + "defformation": presentation("default formation rows; tactics data."), + "customformations": presentation( + "user custom formations (empty in this dump); tactics data."), + "customteamstyles": presentation( + "user custom team styles (empty in this dump); tactics data."), + "teamformationteamstylelinks": presentation( + "team-to-formation/style links (empty in this dump); tactics lookup."), + "teamsheets": presentation("team sheet rows; tactics data."), + "default_teamsheets": presentation("default team sheet rows; tactics data."), + "defaultteamdata": presentation("default per-team tactics; tactics data."), + "teamsheetanalysis": presentation( + "playerid x recorded position analysis rows; tactics lookup."), + "fieldpositionboundingboxes": presentation( + "pitch bounding boxes per position id; match engine geometry."), + "playerpositionzones": presentation( + "position zone polygons; match engine geometry."), + "attributeprefpositionformula": presentation( + "attribute weighting per position; rating calculator input."), + "playerattributesmapping": presentation( + "raw-attribute to displayed-attribute mapping; calculator input."), + "playerattributesmapping_g4": presentation( + "gen-4 variant of the attribute mapping; calculator input."), + "starratingboundaries": presentation( + "overall-rating to star-rating bands; UI lookup."), + + # Reference / geography / competition structure. + "nations": presentation("nation registry (nationid, iso, confederation); lookup."), + "leagues": presentation("league registry (leagueid, level); lookup."), + "leagueteamlinks": presentation("league table state per team; lookup/state."), + "teamnationlinks": presentation("international team-to-nation links; lookup."), + "rowteamnationlinks": presentation("rest-of-world team-to-nation links; lookup."), + "teamstadiumlinks": presentation("team-to-stadium links; lookup."), + "teamstadiumlinkscache": presentation( + "cached team-to-stadium links (empty in this dump); lookup."), + "stadiumassignments": presentation( + "custom stadium name assignments (empty in this dump); lookup."), + "competition": presentation( + "competition definitions and presentation flags (competitionid); " + "match/competition structure, never owned."), + "rivals": presentation("team rivalry pairs; match presentation."), + "fixtures": presentation("fixture rows (empty in this dump); schedule state."), + "factory_teams": presentation( + "single factory/template team row; default data, not a card."), + "fifaGameDefaults": presentation( + "default player/team/ball/referee ids per settings context; defaults " + "lookup."), + "fifaGameSettings": presentation("game settings rows; user settings."), + "version": presentation("database schema/version metadata."), + "server_db_version": presentation("server database version metadata."), + + # Player-adjacent state and career tables (state about players, not new + # definitions; every playerid here is a foreign key into players.playerid). + "teamplayerlinks": presentation( + "player-to-team squad links plus season form/stats; state over " + "players.playerid, not a new definition id."), + "previousteam": presentation( + "player's previous team ids; state over players.playerid."), + "playerloans": presentation( + "loan rows (playerid, teamidloanedfrom); career state."), + "playersuspensions": presentation( + "suspension rows (empty in this dump); career state."), + "restrictedplayers": presentation( + "restricted/replacement player rows (empty in this dump); career state."), + "playerformdiff": presentation( + "player rating deltas (empty in this dump); career state."), + "teamformdiff": presentation( + "team rating deltas (empty in this dump); career state."), + "transfers": presentation("transfer rows (empty in this dump); career state."), + "transactionhistory": presentation( + "transfer history rows (empty in this dump); career state."), + "player_grudgelove": presentation( + "player-to-team emotional affinity; career/story lookup."), + "dna": presentation("player DNA rows (empty in this dump); career data."), + "stories": presentation("career story engine rows (empty in this dump)."), + "career_calendar": presentation("career calendar dates; career data."), + "career_clinchedobjectives": presentation( + "career objective flags (empty in this dump); career state."), + "career_squadranking": presentation( + "career squad ranking (empty in this dump); career state."), + "career_playerlastmatchhistory": presentation( + "career last-match history (empty in this dump); career state."), + "career_playermatchratinghistory": presentation( + "career match rating history (empty in this dump); career state."), + + # Localisation / name string tables. + "playernames": presentation( + "nameid -> player name strings; localisation, not a card definition."), + "dcplayernames": presentation( + "DLC player name strings (empty in this dump); localisation."), + "editedplayernames": presentation( + "user-edited player names (empty in this dump); localisation."), + "commentarynames": presentation("commentary name strings; localisation."), + "clubcommentarynames": presentation("club commentary name strings; localisation."), + "createclubnames": presentation("create-a-club name affixes; localisation."), + "career_commonnames": presentation( + "career common-name strings (empty in this dump); localisation."), + "career_firstnames": presentation( + "career first-name strings (empty in this dump); localisation."), + "career_lastnames": presentation( + "career last-name strings (empty in this dump); localisation."), + "trainingteamplayernames": presentation( + "training/arena team player name strings; localisation."), + + # Create-a-player / customisation input tables. + "createplayer": presentation( + "create-a-player morph sliders (empty in this dump); customisation " + "input, not a card definition."), + "temp_createplayer": presentation( + "scratch mirror of createplayer (empty in this dump)."), + "createplayerpositiontemplates": presentation( + "create-a-player attribute templates per position; customisation input."), + "createplayerviews": presentation( + "create-a-player UI view/attribute mapping; UI structure."), + + # Skill-games / arena / training rosters (fixed non-FUT rosters). + "smplayers": presentation( + "skill-games roster player rows; a fixed non-FUT roster, never owned."), + "smrivals": presentation("skill-games rivalry pairs; presentation."), + "trainingteamplayers": presentation( + "training/arena roster player rows; a fixed non-FUT roster."), + "trainingteamplayerlinks": presentation( + "training/arena roster squad links; lookup."), + + # Empty scratch/staging mirrors (0 rows in this dump; the game copies live + # tables into them). No definition ids of their own by construction. + "temp_players": presentation("empty scratch mirror of players."), + "temp_teams": presentation("empty scratch mirror of teams."), + "temp_teamplayerlinks": presentation("empty scratch mirror of teamplayerlinks."), + "temp_formations": presentation("empty scratch mirror of formations."), + "temp_arenaplayer": presentation("empty scratch arena player table."), + "temp_arenaplayername": presentation("empty scratch arena player name table."), + "temp_arenateam": presentation("empty scratch arena team table."), + "temp_arenateamplayerlinks": presentation("empty scratch arena squad links."), + + # Companion-app / cloud ("cz_") staging tables, all empty in this dump. + "cz_players": presentation( + "companion/cloud staging copy of players (empty in this dump)."), + "cz_teams": presentation( + "companion/cloud staging copy of teams (empty in this dump)."), + "cz_teamkits": presentation( + "companion/cloud staging copy of teamkits (empty in this dump)."), + "cz_leagues": presentation( + "companion/cloud staging copy of leagues (empty in this dump)."), + "cz_assets": presentation( + "companion/cloud asset descriptors (empty in this dump)."), +} + +# Tables that share ONE carddbid space and must therefore be counted as a +# UNION. Each entry is (id_space, expected_union, note) — the note is printed so +# the bug cannot regress silently. +ID_SPACES: dict[str, tuple[int, str]] = { + "club.leaguelogo.carddbid": ( + 44, + "SHIPPED DATA BUG: fcc_leaguelogos (44 rows) and " + "fcc_leaguelogostickers (39 rows) both start their carddbid at " + "8010000, so all 39 sticker ids collide with logo ids. The league-logo " + "definition count is the UNION = 44, never the naive sum 83.", + ), +} + +# The SETTLED inventory: 16 real definition tables holding 20918 distinct ids +# (league logos counted as their union of 44). Checked as an invariant so a +# re-dump that gains or loses definitions is caught here. +SETTLED_DEFINITION_TABLES = ( + "players", "managercards", "headcoachcards", "gkcoachcards", + "fitnesscoachcards", "physiocards", "fcc_contractcards", + "fcc_healingcards", "fcc_trainingcards", "fcc_misccards", "fcc_kitcards", + "fcc_badgecards", "fcc_balls", "fcc_stadium", "fcc_leaguelogos", + "fcc_leaguelogostickers", +) +SETTLED_DEFINITION_ID_TOTAL = 20918 + +# Each club family ships a CONSTANT cardassetid, verified across all shipped +# rows; it is the deserializer's own per-subtype default. +CLUB_CARD_ASSET_IDS = { + "fcc_kitcards": 35, + "fcc_stadium": 36, + "fcc_balls": 37, + "fcc_badgecards": 39, + "fcc_leaguelogos": 40, + "fcc_leaguelogostickers": 40, +} + +# Consumable definition tables whose every cardsubtype must appear in +# data/consumables.json. fcc_misccards is deliberately absent: its subtypes +# {231,232,233,236} are NOT consumables and NOT in that file — that is exactly +# why it is KNOWN_UNSUPPORTED. +CONSUMABLE_DEFINITION_TABLES = ( + "fcc_contractcards", "fcc_healingcards", "fcc_trainingcards", +) + +TROPHY_NOTE = ( + "TROPHIES (cardsubtypeid 0x91..0x96): FUN_180108c00 computes " + "subtype = tournamentType + 0x91 and FUN_1800fed90 is the only function " + "whose case set is exactly {0x91..0x96}. They are rendered from wire " + "FUT::TournamentInfo data and ship NO definition table in this dump, so " + "they contribute 0 definition ids. Presentation-only by evidence, not by " + "convenience." +) + + +def load_table(path: Path) -> dict: + with path.open(encoding="utf-8") as fh: + return json.load(fh) + + +def classify(tables_dir: Path, consumables_path: Path) -> dict: + files = sorted(tables_dir.glob("*.json")) + if not files: + raise SystemExit(f"no *.json tables under {tables_dir}") + + per_table: list[dict] = [] + ids_by_space: dict[str, set[int]] = {} + space_bucket: dict[str, str] = {} + problems: list[str] = [] + + for path in files: + name = path.stem + data = load_table(path) + rows = data.get("rows") or [] + columns = {c["name"] for c in data.get("schema", [])} + rule = RULES.get(name) + if rule is None: + per_table.append({ + "table": name, + "bucket": UNKNOWN, + "rows": len(rows), + "definition_ids": 0, + "id_space": None, + "reason": "NO CLASSIFICATION RULE — classify it in RULES before " + "this dump can be trusted.", + }) + continue + + ids: set[int] = set() + if rule.id_col is not None: + if rule.id_col not in columns: + problems.append( + f"{name}: declared id column {rule.id_col!r} is absent from " + f"the shipped schema" + ) + ids = {r[rule.id_col] for r in rows if rule.id_col in r} + space = rule.id_space + assert space is not None, f"{name}: id column without id space" + prior = space_bucket.setdefault(space, rule.bucket) + if prior != rule.bucket: + problems.append( + f"id space {space!r} spans two buckets ({prior} and " + f"{rule.bucket}); a shared id space must be one class" + ) + ids_by_space.setdefault(space, set()).update(ids) + + per_table.append({ + "table": name, + "bucket": rule.bucket, + "rows": len(rows), + "definition_ids": len(ids), + "id_space": rule.id_space, + "reason": rule.reason, + }) + + # Definition ids per bucket, deduplicated across shared id spaces. + bucket_ids: dict[str, set[int]] = {b: set() for b in BUCKET_ORDER} + for space, ids in ids_by_space.items(): + bucket_ids[space_bucket[space]].update(ids) + + totals = {} + for bucket in BUCKET_ORDER: + entries = [e for e in per_table if e["bucket"] == bucket] + totals[bucket] = { + "tables": len(entries), + "rows": sum(e["rows"] for e in entries), + "definition_ids": len(bucket_ids[bucket]), + } + + collisions = [] + for space, (expected, note) in ID_SPACES.items(): + members = [e["table"] for e in per_table if e["id_space"] == space] + counts = { + e["table"]: e["definition_ids"] + for e in per_table if e["id_space"] == space + } + union = len(ids_by_space.get(space, set())) + collisions.append({ + "id_space": space, + "tables": members, + "per_table_ids": counts, + "naive_sum": sum(counts.values()), + "union": union, + "expected_union": expected, + "ok": union == expected, + "note": note, + }) + + checks = [{ + "check": "leaguelogo carddbid union", + "ok": all(c["ok"] for c in collisions), + "detail": "; ".join( + f"{c['id_space']}: union={c['union']} expected={c['expected_union']} " + f"naive_sum={c['naive_sum']}" for c in collisions + ), + }] + + settled_spaces = { + RULES[t].id_space for t in SETTLED_DEFINITION_TABLES if t in RULES + } + settled_total = sum(len(ids_by_space.get(s, set())) for s in settled_spaces) + checks.append({ + "check": "settled 16 definition tables total", + "ok": settled_total == SETTLED_DEFINITION_ID_TOTAL, + "detail": f"{settled_total} distinct ids across " + f"{len(SETTLED_DEFINITION_TABLES)} tables " + f"({len(settled_spaces)} id spaces); " + f"expected {SETTLED_DEFINITION_ID_TOTAL}", + }) + + asset_detail = [] + asset_ok = True + for table, expected in CLUB_CARD_ASSET_IDS.items(): + path = tables_dir / f"{table}.json" + if not path.exists(): + asset_ok = False + asset_detail.append(f"{table}=MISSING") + continue + seen = {r.get("cardassetid") for r in load_table(path).get("rows") or []} + ok = seen == {expected} + asset_ok = asset_ok and ok + asset_detail.append( + f"{table}={expected}" if ok else f"{table}=UNEXPECTED{sorted(seen)}" + ) + checks.append({ + "check": "constant cardassetid per club family", + "ok": asset_ok, + "detail": ", ".join(asset_detail), + }) + + cons_ok = True + if not consumables_path.exists(): + cons_ok = False + cons_detail = f"{consumables_path} missing — cannot verify" + else: + with consumables_path.open(encoding="utf-8") as fh: + known = {e["cardsubtypeid"] for e in json.load(fh)["subtypes"]} + unlisted: set[int] = set() + for table in CONSUMABLE_DEFINITION_TABLES: + path = tables_dir / f"{table}.json" + if not path.exists(): + cons_ok = False + continue + unlisted |= { + r["cardsubtype"] for r in load_table(path).get("rows") or [] + } - known + cons_ok = cons_ok and not unlisted + cons_detail = ( + f"{len(known)} documented subtypes; unlisted in consumable " + f"definition tables: {sorted(unlisted) if unlisted else 'none'}" + ) + checks.append({ + "check": "consumable subtypes all documented", + "ok": cons_ok, + "detail": cons_detail, + }) + + for problem in problems: + checks.append({"check": "schema/id-space integrity", "ok": False, + "detail": problem}) + + rules_without_table = sorted( + set(RULES) - {e["table"] for e in per_table} + ) + all_ids = set() + for ids in ids_by_space.values(): + all_ids |= ids + + return { + "tables_dir": str(tables_dir), + "table_count": len(per_table), + "per_table": per_table, + "totals": totals, + "definition_ids_total": len(all_ids), + "id_space_collisions": collisions, + "checks": checks, + "rules_without_shipped_table": rules_without_table, + "trophy_note": TROPHY_NOTE, + "unknown": totals[UNKNOWN]["tables"], + "ok": totals[UNKNOWN]["tables"] == 0 and all(c["ok"] for c in checks), + } + + +def render(report: dict, verbose: bool) -> None: + print(f"FIFA 17 definition coverage — {report['tables_dir']}") + print(f"{report['table_count']} shipped tables\n") + + for bucket in BUCKET_ORDER: + entries = [e for e in report["per_table"] if e["bucket"] == bucket] + if not entries: + print(f"{bucket}: (none)\n") + continue + t = report["totals"][bucket] + print(f"{bucket} — {t['tables']} tables, {t['rows']} rows, " + f"{t['definition_ids']} distinct definition ids") + detailed = bucket != PRESENTATION or verbose + for e in sorted(entries, key=lambda e: (-e["definition_ids"], e["table"])): + print(f" {e['table']:<32} rows={e['rows']:>6} " + f"ids={e['definition_ids']:>6}" + + (f" space={e['id_space']}" if e["id_space"] else "")) + if detailed: + for line in wrap(e["reason"], 72): + print(f" {line}") + print() + + print("ID-SPACE COLLISIONS") + for c in report["id_space_collisions"]: + per = ", ".join(f"{k}={v}" for k, v in sorted(c["per_table_ids"].items())) + print(f" {c['id_space']}: {per}") + print(f" naive sum={c['naive_sum']} UNION={c['union']} " + f"expected={c['expected_union']} " + f"{'OK' if c['ok'] else 'MISMATCH'}") + for line in wrap(c["note"], 72): + print(f" {line}") + print() + + print("TROPHIES") + for line in wrap(report["trophy_note"], 74): + print(f" {line}") + print() + + print("INVARIANT CHECKS") + for c in report["checks"]: + print(f" [{'PASS' if c['ok'] else 'FAIL'}] {c['check']}: {c['detail']}") + print() + + if report["rules_without_shipped_table"]: + print("RULES WITH NO SHIPPED TABLE (informational — stale or " + "guard entries)") + for name in report["rules_without_shipped_table"]: + print(f" {name}") + print() + + print("SUMMARY") + for bucket in BUCKET_ORDER: + t = report["totals"][bucket] + print(f" {bucket:<24} {t['tables']:>4} tables " + f"{t['definition_ids']:>6} definition ids") + print(f" {'TOTAL':<24} {report['table_count']:>4} tables " + f"{report['definition_ids_total']:>6} definition ids") + print(f" UNKNOWN = {report['unknown']}") + print(" RESULT: " + ("OK" if report["ok"] else "FAIL")) + + +def wrap(text: str, width: int) -> list[str]: + words = text.split() + lines: list[str] = [] + cur = "" + for w in words: + if cur and len(cur) + 1 + len(w) > width: + lines.append(cur) + cur = w + else: + cur = f"{cur} {w}".strip() + if cur: + lines.append(cur) + return lines + + +def main(argv: list[str] | None = None) -> int: + repo = Path(__file__).resolve().parent.parent + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--tables", type=Path, + default=repo / "fifa17-recon/data/tables", + help="directory of dumped *.json definition tables") + ap.add_argument("--consumables", type=Path, + default=repo / "fifa17-recon/data/consumables.json", + help="consumable subtype evidence file") + ap.add_argument("--json", action="store_true", + help="emit the machine-readable report on stdout") + ap.add_argument("-v", "--verbose", action="store_true", + help="also print the reason for every presentation table") + args = ap.parse_args(argv) + + report = classify(args.tables, args.consumables) + if args.json: + json.dump(report, sys.stdout, indent=2, sort_keys=False) + sys.stdout.write("\n") + else: + render(report, args.verbose) + return 0 if report["ok"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sold-staging-up.py b/scripts/sold-staging-up.py index d5d3f71..3a9b2ba 100755 --- a/scripts/sold-staging-up.py +++ b/scripts/sold-staging-up.py @@ -187,6 +187,24 @@ STAGING_KITS = [ ("away", "owned-a-kit-away", "fifa17_6400003", 6_400_003), ] +# The remaining club-item families, so the rig exercises EVERY ownable class +# rather than only the two the real club happens to hold. Every value is read +# out of the client's own tables (fifa17-recon/data/tables), never invented: +# fcc_badgecards 6000005 teamid 21 -> the same team as the kits above +# fcc_balls 8120194 cardassetid 37 +# fcc_stadium 6200000 cardassetid 36 (capacity 76200) +# fcc_leaguelogos 8010015 leagueid 53 -> the same league as the manager below +# Each family ships a CONSTANT cardassetid (kit 35, stadium 36, ball 37, +# badge 39, logo 40), which is what the importer gates on. A league logo has no +# equipped slot, so it is owned as generic `misc` content. +STAGING_CLUB_ITEMS = [ + # (slot, owned_id, card_id, resource_id, kind, subtype, card_asset_id, team_id) + ("badge", "owned-a-badge", "fifa17_6000005", 6_000_005, "badge", 11, 39, 21), + ("ball", "owned-a-ball", "fifa17_8120194", 8_120_194, "ball", 30, 37, None), + ("stadium", "owned-a-stadium", "fifa17_6200000", 6_200_000, "stadium", 10, 36, None), + (None, "owned-a-leaguelogo", "fifa17_8010015", 8_010_015, "misc", 31, 40, None), +] + # The club manager. FIFA refuses to start a match without one ("your player or # managers contracts have expired"), and NEITHER club owns a manager: the real # import has 1992 players and exactly 3 staff items, all coaches (2 fitness, 1 GK), @@ -527,6 +545,37 @@ def materialise(lay: Layout) -> None: "team_id": KIT_TEAM_ID, } + for _slot, _owned, card_id, resource_id, kind, subtype, art, team in STAGING_CLUB_ITEMS: + if card_id not in existing: + definitions.append({ + "id": card_id, + "name": kind.title(), + "overall": 0, + "position": "", + "nation": "", + "league": "", + "club": "", + "pace": 0, + "shooting": 0, + "passing": 0, + "dribbling": 0, + "defending": 0, + "physical": 0, + "rarity": "bronze", + "image_path": None, + }) + entry = { + "asset_id": resource_id, + "version": 0, + "rareflag": 0, + "kind": kind, + "subtype": subtype, + "card_asset_id": art, + } + if team is not None: + entry["team_id"] = team + catalog["cards"][card_id] = entry + mgr = STAGING_MANAGER if mgr["card_id"] not in existing: definitions.append({ @@ -924,6 +973,11 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None: TS, ) ) + owned.extend( + (owned_id, seller_club, card_id, kind, TS) + for _slot, owned_id, card_id, _rid, kind, _st, _art, _team + in STAGING_CLUB_ITEMS + ) if real_club is None: owned = ( [(item, SELLER_CLUB, card, "player", TS) for item, card in SELLER_SQUAD_CARDS] @@ -960,6 +1014,14 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None: [ (seller_club, f"{slot}_kit", owned_id, TS) for slot, owned_id, _card_id, _resource_id in STAGING_KITS + ] + # badge / ball / stadium are slot-keyed exactly like the kits. + # A league logo has no slot, so it stays owned-but-unequipped — + # which is itself worth exercising. + + [ + (seller_club, slot, owned_id, TS) + for slot, owned_id, _c, _r, _k, _s, _a, _t in STAGING_CLUB_ITEMS + if slot is not None ], )