fifa17-recon: club-item counts, and packs that contain more than footballers
Correcting an overstatement first: I said every card family works. Balls, stadia,
badges and kits do not, and this is the start of that, not the finish.
CLUB ITEMS (FUT_CLUBITEMS=1, default off). tools/fut_clubitems.py builds shelves from
the game's own tables: fcc_balls 42, fcc_stadium 78, fcc_badgecards 656,
fcc_kitcards 1482, fcc_leaguelogos 44, each with its real carddbid AND its real
cardassetid. Counts are wired into the club stat set, replacing the honest zeros
rather than appending a second row per id (the deserializer does
store[ctx][statId] = value, so two rows for one id is a coin toss).
Counts FIRST and on purpose. The consumables round proved the count gates the fetch:
the client does not ask for an item list until club/stats reports a non-zero count,
and the CLUB tab reads 0x1e balls, 0x28 kits, 0x14 stadia. Arming the counters is what
makes the client name the item route, which is the one thing static reading has never
produced for this family -- cardtype 9 has NO arm in the merge, so nothing about it is
discoverable from the card DB.
What is deliberately NOT guessed: which cardsubtypeid means ball versus stadium. The
eight values that reach cardtype 9 are {30,31,145..150} and the assignment appears in
none of the 149 dumped tables. probe_shelf() serves one item per candidate so the
screen can say which is which, rather than shipping a guess into someone's club.
PACKS (FUT_PACK_MIX=1, default on). A pack is no longer eleven footballers: roughly a
quarter of each pack is now consumables and occasionally staff, as a ratio so it
scales from a 5-card bronze to an 11-card premium. Club items are excluded until their
subtypes are verified -- a pack is the worst place to discover a wrong subtype,
because the card lands in the save and has to be cleaned out by hand.
Also fixes the art id AT THE SOURCE. fut_consumables now sets cardassetid from the
fcc_ tables, so a pack-granted consumable renders correctly too. The earlier fix only
corrected the copy served by the club route, which meant packs would have dealt cards
with the green NOT FOUND box again.
Pack tests run against a COPY of the profile, after an earlier test persisted 15 cards
into the live save and had to be undone by hand.
439 + 414 checks green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Club items: balls, stadia, badges, kits and league logos.
|
||||
|
||||
Built from the game's OWN tables, dumped read-only into data/tables/ by db_dump.py:
|
||||
|
||||
fcc_balls 42 rows carddbid 8120194+ cardassetid 37
|
||||
fcc_stadium 78 rows carddbid 6200000+ cardassetid 36
|
||||
fcc_badgecards 656 rows carddbid 6000000+ cardassetid 39
|
||||
fcc_kitcards 1482 rows carddbid 6300000+ cardassetid 35
|
||||
fcc_leaguelogos 44 rows carddbid 8010000+ cardassetid 40
|
||||
|
||||
TWO ID COLUMNS, AND THEY ARE NOT INTERCHANGEABLE. Every fcc_ row carries BOTH
|
||||
carddbid and cardassetid. carddbid is the database key the merge would use;
|
||||
cardassetid is the ART id the card draws from. fut_store._item copies resourceId
|
||||
into cardassetid, which is right for players and wrong for every other family --
|
||||
that is exactly what produced the green "NOT FOUND" placeholder on consumables
|
||||
(external/ion_fut/artAssets/.../notfound.swf) until it was fixed on 2026-08-05.
|
||||
So this module sets both explicitly and never lets one default to the other.
|
||||
|
||||
WHAT IS NOT KNOWN YET, AND IS NOT GUESSED HERE
|
||||
----------------------------------------------
|
||||
cardtype 9 (the club-item family) has NO arm in the merge FUN_180141660: no table
|
||||
query and no miss-fill. So unlike a player or a coach, a club item's identity does
|
||||
NOT come from the local card DB, and a wrong id cannot announce itself. The
|
||||
cardsubtypeid values that reach cardtype 9 are the eight-value set
|
||||
{30, 31, 145, 146, 147, 148, 149, 150}, and WHICH of those means ball versus
|
||||
stadium versus badge is assigned nowhere in the 149 dumped tables.
|
||||
|
||||
Rather than guess, SUBTYPE is a per-family constant below with an explicit
|
||||
"unverified" marker, and probe_shelf() serves one item per candidate subtype so the
|
||||
screen itself can say which is which. The counts do not need any of this: a count is
|
||||
just a number, which is why counts come first.
|
||||
|
||||
THE COUNT IS THE GATE. Proven on consumables the same day: the client does not ask
|
||||
for an item list until club/stats reports a non-zero count for that family. The
|
||||
CLUB tab reads global stat ids 1 (players), 0x1e (balls), 0x28 (kits), 0x14
|
||||
(stadia), 0x0a (staff) and 0x32 (trophies), so making those non-zero is what makes
|
||||
the client reveal the item route it uses. Nothing here should be believed to work
|
||||
until that route is observed in the log.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
_DATA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "tables")
|
||||
|
||||
CLUBITEM_ID_BASE = 960000000 # distinct from save 1e8, sweep 9e8, consumables 9.4e8
|
||||
|
||||
# (table, art id, stat id, stat name, UNVERIFIED cardsubtypeid)
|
||||
FAMILIES = [
|
||||
("balls", "fcc_balls.json", 37, 0x1E, "balls", 149),
|
||||
("stadia", "fcc_stadium.json", 36, 0x14, "stadia", 148),
|
||||
("badges", "fcc_badgecards.json", 39, 0x2E, "badgeDBid", 145),
|
||||
("kits", "fcc_kitcards.json", 35, 0x28, "kits", 146),
|
||||
("leaguelogos", "fcc_leaguelogos.json", 40, 0x2F, "leagueLogos", 150),
|
||||
]
|
||||
|
||||
# Every cardsubtypeid known to reach cardtype 9. Used by probe_shelf().
|
||||
CARDTYPE9_SUBTYPES = (30, 31, 145, 146, 147, 148, 149, 150)
|
||||
|
||||
# How many of each family the starter club owns. Small on purpose: the point is to
|
||||
# make the counter non-zero so the client asks, not to hand anyone a collection.
|
||||
STARTER_N = {"balls": 6, "stadia": 4, "badges": 8, "kits": 8, "leaguelogos": 4}
|
||||
|
||||
|
||||
def _rows(fname):
|
||||
try:
|
||||
with open(os.path.join(_DATA, fname)) as f:
|
||||
return json.load(f).get("rows") or []
|
||||
except (IOError, ValueError):
|
||||
return []
|
||||
|
||||
|
||||
def _item(item_id, carddbid, cardassetid, subtype, extra=None):
|
||||
"""One club item. Deliberately narrow: no rating, no position, no attributes,
|
||||
no nation, no league, no team. A club item has none of those, and sending a
|
||||
field the family does not have is how a wrong shape gets accepted and does
|
||||
nothing."""
|
||||
it = {
|
||||
"id": item_id,
|
||||
"resourceId": carddbid,
|
||||
"assetId": carddbid,
|
||||
"cardassetid": cardassetid, # THE ART ID, never a copy of resourceId
|
||||
"cardsubtypeid": subtype,
|
||||
"itemType": "club", # UNOBSERVED on the wire; see module docstring
|
||||
"itemState": "free",
|
||||
"owners": 1,
|
||||
"untradeable": False,
|
||||
}
|
||||
if extra:
|
||||
it.update(extra)
|
||||
return it
|
||||
|
||||
|
||||
def shelf(next_id=CLUBITEM_ID_BASE):
|
||||
"""The starter club-item shelf, {family: [item]}."""
|
||||
out, nid = {}, next_id
|
||||
for name, table, art, _sid, _sname, subtype in FAMILIES:
|
||||
rows = _rows(table)
|
||||
picked = []
|
||||
for r in rows[:STARTER_N.get(name, 4)]:
|
||||
cid = r.get("carddbid")
|
||||
if not cid:
|
||||
continue
|
||||
extra = {}
|
||||
for k in ("teamid", "leagueid", "value"):
|
||||
if r.get(k):
|
||||
extra[k] = r[k]
|
||||
picked.append(_item(nid, cid, r.get("cardassetid", art), subtype, extra))
|
||||
nid += 1
|
||||
out[name] = picked
|
||||
return out
|
||||
|
||||
|
||||
def counts(next_id=CLUBITEM_ID_BASE):
|
||||
"""[(stat name, count)] for the club panel."""
|
||||
s = shelf(next_id)
|
||||
return [(sname, len(s.get(name, [])))
|
||||
for name, _t, _a, _sid, sname, _st in FAMILIES]
|
||||
|
||||
|
||||
def probe_shelf(family, next_id=CLUBITEM_ID_BASE):
|
||||
"""One item per CANDIDATE cardsubtypeid, same carddbid, for the live oracle.
|
||||
|
||||
Which of {30,31,145..150} means which family is unknown and unguessable from the
|
||||
dumped tables. Serving all eight and looking at the screen is the cheapest way to
|
||||
find out, and unlike a sweep it is READABLE: the family that draws real artwork
|
||||
names its own subtype.
|
||||
"""
|
||||
entry = next((f for f in FAMILIES if f[0] == family), None)
|
||||
if entry is None:
|
||||
return []
|
||||
_n, table, art, _sid, _sname, _st = entry
|
||||
rows = _rows(table)
|
||||
if not rows:
|
||||
return []
|
||||
r = rows[0]
|
||||
return [_item(next_id + i, r.get("carddbid"), r.get("cardassetid", art), st)
|
||||
for i, st in enumerate(CARDTYPE9_SUBTYPES)]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
s = shelf()
|
||||
for name, items in s.items():
|
||||
print("%-12s %2d item(s)" % (name, len(items)))
|
||||
if items:
|
||||
i = items[0]
|
||||
print(" resourceId=%-9s cardassetid=%-4s subtype=%s"
|
||||
% (i["resourceId"], i["cardassetid"], i["cardsubtypeid"]))
|
||||
print("\ncounts:", counts())
|
||||
@@ -86,6 +86,34 @@ CORE_KINDS = ("player_contract", "manager_contract", "healing",
|
||||
"player_fitness", "squad_fitness", "gk_training", "player_training",
|
||||
"position_mod", "player_playstyle", "gk_playstyle")
|
||||
|
||||
# carddbid -> cardassetid, the ART id, read from the game's own fcc_ tables.
|
||||
#
|
||||
# THE TWO IDS ARE NOT INTERCHANGEABLE and this cost a live debugging round. A card
|
||||
# draws its artwork from cardassetid, which is a SMALL id (3 training, 7 contract,
|
||||
# 10 healing, 45 misc), not the carddbid. Copying resourceId into cardassetid is
|
||||
# right for players and wrong here: the client looked up art 5003001, found nothing,
|
||||
# and drew external/ion_fut/artAssets/.../notfound.swf -- a green NOT FOUND box on
|
||||
# every consumable card until 2026-08-05.
|
||||
_ART_BY_CARDDBID = None
|
||||
|
||||
|
||||
def art_id(carddbid, default=None):
|
||||
global _ART_BY_CARDDBID
|
||||
if _ART_BY_CARDDBID is None:
|
||||
import glob
|
||||
d = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "tables")
|
||||
m = {}
|
||||
for f in glob.glob(os.path.join(d, "fcc_*.json")):
|
||||
try:
|
||||
rows = json.load(open(f)).get("rows") or []
|
||||
except Exception:
|
||||
continue
|
||||
for r in rows:
|
||||
if "carddbid" in r and "cardassetid" in r:
|
||||
m[r["carddbid"]] = r["cardassetid"]
|
||||
_ART_BY_CARDDBID = m
|
||||
return _ART_BY_CARDDBID.get(carddbid, default)
|
||||
|
||||
CONSUMABLE_ID_BASE = 940000000 # distinct from the save (1e8), the sweep (9e8)
|
||||
# and the staff overlay (9.5e8)
|
||||
|
||||
@@ -164,7 +192,8 @@ def consumable_item(item_id, subtype, amount=None, contract=None, rating=None,
|
||||
"id": item_id,
|
||||
"resourceId": resource_id,
|
||||
"assetId": resource_id,
|
||||
"cardassetid": resource_id,
|
||||
# THE ART ID, not a copy of resourceId -- see art_id() above.
|
||||
"cardassetid": art_id(resource_id, resource_id),
|
||||
"cardsubtypeid": subtype,
|
||||
"itemType": "player",
|
||||
"rareflag": rareflag,
|
||||
|
||||
@@ -367,14 +367,27 @@ class Store:
|
||||
import random
|
||||
if not self.spend(price):
|
||||
return None
|
||||
# A real FUT pack is not eleven footballers. It is mostly players with a
|
||||
# couple of consumables and the occasional staff card, which is what
|
||||
# FUT_PACK_MIX reproduces. Kept as a RATIO of the pack size rather than a
|
||||
# fixed number so it scales from a 5-card bronze to an 11-card premium.
|
||||
n_extra = 0
|
||||
extras = []
|
||||
if PACK_MIX and count >= 5:
|
||||
n_extra = max(1, count // 4)
|
||||
extras = _pack_extras(n_extra, self)
|
||||
n_extra = len(extras)
|
||||
n_players = max(1, count - n_extra)
|
||||
if tiers:
|
||||
picks = [random.choice(fut_cards.pool_for(random.choice(tiers)))
|
||||
for _ in range(count)]
|
||||
for _ in range(n_players)]
|
||||
else:
|
||||
pool = [p for p in PACK_POOL if (p[1] >= 75) == gold] or PACK_POOL
|
||||
picks = [random.choice(pool) for _ in range(count)]
|
||||
picks = [random.choice(pool) for _ in range(n_players)]
|
||||
items = [_item(self.new_item_id(), a, r, p, n, lg, tm, at)
|
||||
for (a, r, p, n, lg, tm, at) in picks]
|
||||
items += extras
|
||||
random.shuffle(items)
|
||||
with _LOCK:
|
||||
p = self.load()
|
||||
p.setdefault("purchased", []).extend(items)
|
||||
@@ -386,6 +399,53 @@ class Store:
|
||||
return self.load().get("purchased", [])
|
||||
|
||||
|
||||
|
||||
# FUT_PACK_MIX: put non-player cards in packs.
|
||||
#
|
||||
# Consumables and staff are included because both are LIVE-PROVEN to render (staff on
|
||||
# 2026-08-05 with zero DB Error, consumables the same day with real artwork once
|
||||
# cardassetid was fixed). Club items are NOT included: cardtype 9 has no arm in the
|
||||
# merge, so which cardsubtypeid means "ball" versus "stadium" is still unverified, and
|
||||
# a pack is the worst place to discover that a subtype was wrong -- the card lands in
|
||||
# the save and has to be cleaned out by hand.
|
||||
PACK_MIX = os.environ.get("FUT_PACK_MIX", "1") == "1"
|
||||
|
||||
|
||||
def _pack_extras(n, store):
|
||||
"""n non-player cards for a pack: mostly consumables, occasionally staff."""
|
||||
import random
|
||||
out = []
|
||||
for _ in range(n):
|
||||
want_staff = random.random() < 0.25
|
||||
it = None
|
||||
if want_staff:
|
||||
try:
|
||||
import fut_staff
|
||||
pool = list(fut_staff.STARTER_MANAGERS)
|
||||
try:
|
||||
import fut_coaches
|
||||
pool += fut_coaches.starter_coaches(fut_coaches.COACH_ID_BASE)
|
||||
except Exception:
|
||||
pass
|
||||
if pool:
|
||||
it = dict(random.choice(pool))
|
||||
except Exception:
|
||||
it = None
|
||||
if it is None:
|
||||
try:
|
||||
import fut_consumables as fc
|
||||
shelf = fc.starter_consumables(fc.CONSUMABLE_ID_BASE)
|
||||
if shelf:
|
||||
it = dict(random.choice(shelf))
|
||||
except Exception:
|
||||
it = None
|
||||
if it is None:
|
||||
continue
|
||||
it["id"] = store.new_item_id() # a pack card needs its OWN item id
|
||||
out.append(it)
|
||||
return out
|
||||
|
||||
|
||||
# Card pool for packs. Now lives in fut_cards.py (79 players across three rating
|
||||
# tiers, 7 leagues, 20 nations, 18 teams, every outfield position plus GK). The old
|
||||
# 18-entry list below is kept ONLY as the starter-squad source and as the fallback
|
||||
|
||||
@@ -1193,6 +1193,19 @@ CONSUM_STATS = os.environ.get("FUT_CONSUM_STATS", "1") == "1"
|
||||
# Serve consumables as TRADEABLE by default; see _consumable_stacks for why.
|
||||
CONSUM_UNTRADEABLE = os.environ.get("FUT_CONSUM_UNTRADEABLE", "0") == "1"
|
||||
|
||||
# FUT_CLUBITEMS: balls, stadia, badges, kits and league logos.
|
||||
#
|
||||
# Counts FIRST, deliberately. The consumables round proved that the client does not
|
||||
# request an item list until club/stats reports a non-zero count for that family, and
|
||||
# the CLUB tab reads exactly these ids: 0x1e balls, 0x28 kits, 0x14 stadia. So arming
|
||||
# the counters is what makes the client name the item route it uses -- which is the
|
||||
# one thing no amount of static reading has produced for this family, because
|
||||
# cardtype 9 has no arm in the merge at all.
|
||||
#
|
||||
# Default OFF: nothing here has ever been requested by the client, so unlike the
|
||||
# consumables stat fix this is not correcting a demonstrably wrong answer.
|
||||
CLUBITEMS = os.environ.get("FUT_CLUBITEMS", "0") == "1"
|
||||
|
||||
|
||||
def _consumable_stat_rows():
|
||||
"""[(stat name, count)] for the consumables panel, counted from the shelf."""
|
||||
@@ -1261,6 +1274,18 @@ def _club_stat_set():
|
||||
("trophiesSeasonOffline", 0), # 0x37
|
||||
]
|
||||
counts += _consumable_stat_rows()
|
||||
if CLUBITEMS:
|
||||
import fut_clubitems
|
||||
ci = fut_clubitems.counts()
|
||||
# REPLACE the honest zeros above rather than appending a second row per name:
|
||||
# the deserializer writes store[contextValue][statId] = typeValue, so a later
|
||||
# row silently wins and two rows for one id is a coin toss.
|
||||
have = dict(ci)
|
||||
counts = [(t, have.get(t, v)) for t, v in counts]
|
||||
for t, v in ci:
|
||||
if t not in [c[0] for c in counts]:
|
||||
counts.append((t, v))
|
||||
log(" CLUBITEMS: %s" % ", ".join("%s=%d" % kv for kv in ci))
|
||||
# All four keys in every element -- see note 3 above.
|
||||
return [{"contextId": 1, "contextValue": 0, "type": t, "typeValue": int(v)}
|
||||
for t, v in counts]
|
||||
|
||||
Reference in New Issue
Block a user