#!/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()