#!/usr/bin/env python3 """Generate a MediaWiki Loot/Drop-tables page from extracted/loot_tables.json. Source = the two Odin-binary LootTablesConfig assets (Storm + Voyage), decoded by odin_read.py. Both regions share the same 193 table ids and the same item set per table; only the counts differ (Voyage is leaner). So each (table,item) is one row with separate Storm and Voyage count columns. Item names are the in-game I2 names. """ import json, os, re EX = '/home/downloadpizza/sand_tools/extracted' WIKI = '/home/downloadpizza/sand_tools/wiki' SRC = os.path.join(EX, 'loot_tables.json') NAMES = os.path.join(EX, 'item_names.json') OUT = os.path.join(WIKI, 'Loot.mediawiki') _NAMES = json.load(open(NAMES))['items'] if os.path.exists(NAMES) else {} def camel_split(s): return re.findall(r'[A-Za-z][a-z]*\d*', s) def words(tok): return ' '.join(w.upper() if re.match(r'^[A-Za-z]\d+$', w) else w[:1].upper() + w[1:] for w in camel_split(tok)) def humanize(item_id): s = item_id is_item = s.startswith('item_') if is_item: s = s[5:] toks = s.split('_') if toks and toks[0].startswith('resource'): toks[0] = toks[0][len('resource'):] if is_item and len(toks) > 1: return words(toks[0]) + ' (' + ', '.join(words(v) for v in toks[1:]) + ')' return ' '.join(words(t) for t in toks) def display(item_id): n = _NAMES.get(item_id, {}).get('name') return n if n else humanize(item_id) CAT_RULES = [ ('weapons_container_resupply', 'Weapon resupply'), ('weapons_container', 'Weapons container'), ('shells_container', 'Shells container'), ('valuables_container', 'Valuables container'), ('resource_container', 'Resource container'), ('food_container', 'Food container'), ('med_container', 'Medical container'), ('buriedTreasure', 'Buried treasure'), ('mobLoot_ghoulRange', 'Mob drop — Ghoul (ranged)'), ('mobLoot_ghoulMeleeShovel', 'Mob drop — Ghoul (shovel)'), ('mobLoot_ghoulMelee', 'Mob drop — Ghoul (melee)'), ('ironcladLoot_packedTurret', 'Ironclad — packed turret'), ('ironcladLoot_lootBoxEntity', 'Ironclad — loot box (entity)'), ('ironcladLoot_lootBox_mandat','Ironclad — mandatory alloy'), ('ironcladLoot_lootBox', 'Ironclad — loot box'), ('ironcladLoot_repairKit', 'Ironclad — repair kit'), ('ironcladLoot', 'Ironclad'), ('navalMine', 'Naval mine'), ('aurogenCrystal', 'Aurogen crystal'), ] def category(tid): for pre, lab in CAT_RULES: if tid.startswith(pre): return lab return 'Other' def cnt(rows_by_id, item): r = rows_by_id.get(item) if r is None: return '—' lo, hi = r['countMin'], r['countMax'] return str(lo) if lo == hi else f'{lo}–{hi}' def esc(s): return (s or '').replace('|', '|') def main(): d = json.load(open(SRC)) storm, voyage = d['Storm'], d['Voyage'] tids = sorted(storm.keys(), key=lambda t: (category(t), t)) # build per-table id->row maps for fast count lookup smap = {t: {r['itemBlueprint']: r for r in storm[t]} for t in storm} vmap = {t: {r['itemBlueprint']: r for r in voyage.get(t, [])} for t in storm} nrows = sum(len(storm[t]) for t in tids) out = [] out.append("'''Loot tables''' for '''SAND''' — what each lootable source (containers, buried " "treasure, naval mines, ironclad cargo, and mob kills) can drop, and in what " "quantity. Extracted directly from the game's drop-table configuration.") out.append('') out.append("The game ships two regional drop-table sets, '''Storm''' and '''Voyage'''. They " "contain the same loot tables with the same items, but the drop ''amounts'' differ " "— Voyage is the leaner region (152 of 193 tables give smaller stacks). Both " "amounts are shown side by side below; a count like ''20–25'' is a random " "range, a single number is fixed.") out.append('') out.append(f"{len(tids)} loot tables, {nrows} item drops. The table is sortable — click a " "header to group by category, table, or item.") out.append('') out.append('__TOC__') out.append('') out.append('== Drop tables ==') out.append('{| class="wikitable sortable"') out.append('! Category !! Loot table !! Item !! data-sort-type="number" | Storm ' '!! data-sort-type="number" | Voyage') for t in tids: cat = category(t) for r in storm[t]: iid = r['itemBlueprint'] out.append('|-') out.append(f"| {esc(cat)} || {t} || [[{esc(display(iid))}]] " f"|| {cnt(smap[t], iid)} || {cnt(vmap[t], iid)}") out.append('|}') out.append('') # reverse index: item -> which tables drop it out.append('== Where each item drops ==') out.append('For each lootable item, the loot tables that can drop it.') out.append('') item_to_tables = {} for t in tids: for r in storm[t]: item_to_tables.setdefault(r['itemBlueprint'], []).append(t) out.append('{| class="wikitable sortable"') out.append('! Item !! Item ID !! data-sort-type="number" | # tables !! Dropped by') for iid in sorted(item_to_tables, key=lambda i: display(i).lower()): ts = item_to_tables[iid] shown = ', '.join(f'{x}' for x in ts[:12]) if len(ts) > 12: shown += f' … (+{len(ts)-12} more)' out.append('|-') out.append(f"| [[{esc(display(iid))}]] || {iid} || {len(ts)} || {shown}") out.append('|}') out.append('') out.append('== Notes ==') out.append('* Source: the game\'s LootTablesConfig assets ' '(conf_worldLootTablesStormConfig / …VoyageConfig), ' 'Odin-serialized; decoded from the binary payload.') out.append('* Each drop entry is {item, countMin, countMax}; the count shown is ' 'min–max (a single value means min == max).') out.append('* A few drop entries are drop-only blueprint variants (e.g. mob/mine-drop resource ' 'variants and world treasure piles) that are not separate carriable items; they are ' 'shown under their in-game display name where one exists.') out.append("* This lists ''what'' a table can drop. The ''chance'' of a given table rolling, and " "how many POI containers use each table, are decided elsewhere in game logic.") out.append('') out.append('[[Category:Loot]]') text = '\n'.join(out) os.makedirs(WIKI, exist_ok=True) open(OUT, 'w').write(text) print(f"wrote {OUT} ({len(text)} chars, {len(tids)} tables, {nrows} rows, " f"{len(item_to_tables)} distinct items)") if __name__ == '__main__': main()