refactor: group scripts into walker/ wikigen/ bundle/
Organize the 16 loose scripts by concern:
walker/ -- .wbt save tooling (sand, build_wbt, walker_hashes,
harvest_hashes, recover_key)
wikigen/ -- MediaWiki page generators (make_*_wiki, render_wiki)
bundle/ -- Unity/Odin asset extraction (unitybundle, odin_read,
extract_*, loot_probe, dump_loot_bytes)
The only cross-script imports (build_wbt->walker_hashes,
extract_loot->odin_read) live within the same folder, so each
script's dir on sys.path[0] keeps them resolving with no code
changes. All data paths are absolute, so the moves don't affect
I/O. Named the code dir wikigen/ to avoid colliding with the
generated wiki/ output dir; ignore the regenerable wiki_site/ render.
This commit is contained in:
157
wikigen/make_loot_wiki.py
Normal file
157
wikigen/make_loot_wiki.py
Normal file
@@ -0,0 +1,157 @@
|
||||
#!/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)} || <code>{t}</code> || [[{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'<code>{x}</code>' for x in ts[:12])
|
||||
if len(ts) > 12:
|
||||
shown += f' … (+{len(ts)-12} more)'
|
||||
out.append('|-')
|
||||
out.append(f"| [[{esc(display(iid))}]] || <code>{iid}</code> || {len(ts)} || {shown}")
|
||||
out.append('|}')
|
||||
out.append('')
|
||||
out.append('== Notes ==')
|
||||
out.append('* Source: the game\'s <code>LootTablesConfig</code> assets '
|
||||
'(<code>conf_worldLootTablesStormConfig</code> / <code>…VoyageConfig</code>), '
|
||||
'Odin-serialized; decoded from the binary payload.')
|
||||
out.append('* Each drop entry is <code>{item, countMin, countMax}</code>; the count shown is '
|
||||
'<code>min–max</code> (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()
|
||||
Reference in New Issue
Block a user