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:
132
wikigen/make_crafting_wiki.py
Normal file
132
wikigen/make_crafting_wiki.py
Normal file
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a MediaWiki crafting page from extracted/crafting_recipes.json."""
|
||||
import json, re, os
|
||||
|
||||
EX = '/home/downloadpizza/sand_tools/extracted'
|
||||
WIKI = '/home/downloadpizza/sand_tools/wiki'
|
||||
SRC = os.path.join(EX, 'crafting_recipes.json')
|
||||
NAMES = os.path.join(EX, 'item_names.json')
|
||||
OUT = os.path.join(WIKI, 'Crafting.mediawiki')
|
||||
|
||||
STATION_SHORT = {
|
||||
'Recipes_Utility_Workbench_T1': 'Sewing (Utility)',
|
||||
'Recipes_Armament_Workbench_T1': 'Armaments T1',
|
||||
'Recipes_Armament_Workbench_T2': 'Armaments T2',
|
||||
}
|
||||
|
||||
# real in-game display names (I2 localization); fall back to humanize() if absent
|
||||
_NAMES = json.load(open(NAMES))['items'] if os.path.exists(NAMES) else {}
|
||||
def display(item_id):
|
||||
n = _NAMES.get(item_id, {}).get('name')
|
||||
return n if n else humanize(item_id)
|
||||
|
||||
STATIONS = {
|
||||
'Recipes_Utility_Workbench_T1': ('Utility / Sewing recipes (fabric, armour, food, valuables)', 1),
|
||||
'Recipes_Armament_Workbench_T1': ('Armaments recipes — Tier 1 (basic ammo & weapons)', 2),
|
||||
'Recipes_Armament_Workbench_T2': ('Armaments recipes — Tier 2 (advanced ammo & weapons)', 3),
|
||||
'TestRecipesBundle': ('Test recipes (debug)', 99),
|
||||
}
|
||||
|
||||
ALIAS = {'item_coinCrown': 'Crown'}
|
||||
|
||||
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) # T2, C4
|
||||
else w[:1].upper() + w[1:] for w in camel_split(tok))
|
||||
|
||||
def humanize(item_id):
|
||||
if item_id in ALIAS: return ALIAS[item_id]
|
||||
s = item_id
|
||||
is_item = s.startswith('item_')
|
||||
if is_item: s = s[5:]
|
||||
toks = s.split('_')
|
||||
if toks[0].startswith('resource'): toks[0] = toks[0][len('resource'):]
|
||||
# item_* IDs use camelCase base + underscore variants; bare IDs use underscore as space
|
||||
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 link(item_id):
|
||||
return f'[[{display(item_id)}]]'
|
||||
|
||||
def ingredients(lst):
|
||||
return ' + '.join(f"{i['amount']} × {link(i['itemId'])}" for i in lst)
|
||||
|
||||
def outputs(lst):
|
||||
return ' + '.join(f"{o['amount']} × {link(o['itemId'])}" for o in lst)
|
||||
|
||||
def fmt_time(t):
|
||||
return f"{int(t)}s" if t == int(t) else f"{t}s"
|
||||
|
||||
def table(recs):
|
||||
rows = ['{| class="wikitable sortable"',
|
||||
'! Output !! Ingredients !! data-sort-type="number" | Time']
|
||||
for r in recs:
|
||||
rows.append('|-')
|
||||
rows.append(f"| {outputs(r['outputs'])} || {ingredients(r['inputs'])} "
|
||||
f"|| {fmt_time(r['craftTimeSeconds'])}")
|
||||
rows.append('|}')
|
||||
return '\n'.join(rows)
|
||||
|
||||
def main():
|
||||
data = json.load(open(SRC))['recipes']
|
||||
order = sorted(data, key=lambda k: STATIONS.get(k, (k, 50))[1])
|
||||
real = [k for k in order if STATIONS.get(k, ('', 50))[1] < 50]
|
||||
total = sum(len(data[k]) for k in real)
|
||||
|
||||
out = []
|
||||
out.append("Crafting in '''SAND''' is performed at workbenches placed on your walker. Each "
|
||||
"workbench tier unlocks a set of recipes that turn salvaged resources into ammo, "
|
||||
"weapons, armour and utility items.")
|
||||
out.append('')
|
||||
out.append(f"This page lists all {total} craftable recipes (plus developer test recipes), "
|
||||
"extracted directly from the game files. Item names are the in-game (English) names.")
|
||||
out.append('')
|
||||
out.append('__TOC__')
|
||||
out.append('')
|
||||
out.append('== Workbenches ==')
|
||||
out.append("Crafting compartments that can be placed on a walker (from the buildable "
|
||||
"compartment database):")
|
||||
out.append("* <code>walker_compCrafting_Open_1x1</code> — '''S&H Armaments Workbench''' (1×1)")
|
||||
out.append("* <code>walker_compCrafting_Small_Wood_1x1</code> — '''S&H Compact Armaments Workshop''' (1×1)")
|
||||
out.append("* <code>walker_compCrafting_Wood_2x1</code> — '''S&H Armaments Workshop''' (1×2)")
|
||||
out.append("There is also a '''KF Sewing Workshop''' (<code>walker_compCraftingUtility_Wood_2x1</code>) "
|
||||
"associated with the utility/fabric recipes, but it is '''not''' in the buildable "
|
||||
"compartment list in this build.")
|
||||
out.append('')
|
||||
out.append("'''Note:''' The recipe sets below come from the game's three recipe bundles "
|
||||
"(<code>Recipes_Utility_Workbench_T1</code>, <code>Recipes_Armament_Workbench_T1/T2</code>). "
|
||||
"Which workbench compartment loads which bundle is decided in game code at runtime and "
|
||||
"is '''not''' stored in the asset data, so the exact compartment→recipe pairing "
|
||||
"below is inferred from the bundle names and item types, not confirmed.")
|
||||
out.append('')
|
||||
out.append('== Recipes ==')
|
||||
out.append(f'All {total} production recipes in a single sortable table. '
|
||||
'Click a column header to sort (e.g. by station or craft time).')
|
||||
out.append('')
|
||||
out.append('{| class="wikitable sortable"')
|
||||
out.append('! Station !! Output !! Ingredients !! data-sort-type="number" | Time')
|
||||
for k in real:
|
||||
station = STATION_SHORT.get(k, k)
|
||||
for r in data[k]:
|
||||
out.append('|-')
|
||||
out.append(f"| {station} || {outputs(r['outputs'])} || {ingredients(r['inputs'])} "
|
||||
f"|| {fmt_time(r['craftTimeSeconds'])}")
|
||||
out.append('|}')
|
||||
out.append('')
|
||||
out.append('== Notes ==')
|
||||
out.append('* Recipes are extracted from the game\'s <code>CraftingRecipeBundle</code> assets; '
|
||||
'item names are the in-game English names.')
|
||||
out.append('* "Time" is the base crafting duration in seconds.')
|
||||
out.append('* Developer/debug recipes are omitted.')
|
||||
out.append('')
|
||||
out.append('[[Category:Crafting]]')
|
||||
text = '\n'.join(out)
|
||||
os.makedirs(WIKI, exist_ok=True)
|
||||
open(OUT, 'w').write(text)
|
||||
print(f"wrote {OUT} ({len(text)} chars, {total} recipes in one table)")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
75
wikigen/make_items_wiki.py
Normal file
75
wikigen/make_items_wiki.py
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a MediaWiki Items page from the authoritative item registry.
|
||||
|
||||
Source = items_registry.json, built from CheatItemDefinitionsData.Items
|
||||
(List<ItemDefinition>). An entry exists iff the game defines it as a carriable
|
||||
item (it has an ItemDefinition / StorageStack). Damage-type name variants
|
||||
(_Ranged/_Melee) and world objects are NOT in this list, by design.
|
||||
"""
|
||||
import json, os
|
||||
|
||||
EX = '/home/downloadpizza/sand_tools/extracted'
|
||||
WIKI = '/home/downloadpizza/sand_tools/wiki'
|
||||
OUT = os.path.join(WIKI, 'Items.mediawiki')
|
||||
|
||||
reg = json.load(open(os.path.join(EX, 'items_registry.json')))['items']
|
||||
|
||||
CAT_LABEL = {
|
||||
'WEAPON': 'Weapon', 'AMMO': 'Ammo', 'TURRET_AMMO': 'Turret Ammo',
|
||||
'RESOURCE_T1': 'Resource (T1)', 'RESOURCE_T2': 'Resource (T2)', 'RESOURCE_T3': 'Resource (T3)',
|
||||
'ARMOR': 'Armor', 'BACKPACK': 'Backpack', 'ENERGY': 'Energy', 'FOOD': 'Food', 'MONEY': 'Money',
|
||||
'KEY': 'Key', 'SMALL_VALUABLE': 'Small Valuable', 'LARGE_VALUABLE': 'Large Valuable',
|
||||
'RAID_EXPLOSIVES': 'Raid Explosive', 'WEAPON_BELT': 'Weapon Belt',
|
||||
'UTILITY_CONSUMABLE': 'Utility Consumable', 'ATTACK_CONSUMABLE': 'Attack Consumable',
|
||||
}
|
||||
|
||||
def esc(s):
|
||||
return (s or '').replace('\n', ' ').replace('|', '|').strip()
|
||||
|
||||
def stack(n):
|
||||
return '—' if n >= 100000 else str(n) # 100000/1000000 = effectively unlimited
|
||||
|
||||
def main():
|
||||
out = []
|
||||
out.append("Complete list of carriable '''items''' in '''SAND''' — everything the game defines as an "
|
||||
"actual inventory item (i.e. it has an item definition). Names and descriptions are the "
|
||||
"in-game English text.")
|
||||
out.append('')
|
||||
out.append(f"{len(reg)} items. The table is sortable — click a header to group by category. "
|
||||
"(Damage-type name variants and world objects are intentionally excluded; this is the "
|
||||
"real pickup-able item set.)")
|
||||
out.append('')
|
||||
out.append('__TOC__')
|
||||
out.append('')
|
||||
out.append('== Items ==')
|
||||
out.append('{| class="wikitable sortable"')
|
||||
out.append('! Category !! Name !! Item ID !! data-sort-type="number" | Stack !! Short description')
|
||||
rows = sorted(reg.items(), key=lambda kv: (kv[1]['type'], kv[1]['name'].lower()))
|
||||
for iid, info in rows:
|
||||
cat = CAT_LABEL.get(info['type'], info['type'].title())
|
||||
out.append('|-')
|
||||
out.append(f"| {cat} || [[{info['name']}]] || <code>{iid}</code> "
|
||||
f"|| {stack(info['storageStack'])} || {esc(info['shortDescription'])}")
|
||||
out.append('|}')
|
||||
out.append('')
|
||||
out.append('== Notes ==')
|
||||
out.append('* The item set comes from the game\'s item-definition registry '
|
||||
'(<code>CheatItemDefinitionsData.Items</code>): an entry is included only if the game '
|
||||
'defines it as a carriable item. This deliberately excludes damage-type name variants '
|
||||
'(<code>_Ranged</code>/<code>_Melee</code>) and world objects, which merely have '
|
||||
'display names but are not inventory items.')
|
||||
out.append('* "Stack" is the item\'s storage stack limit; "—" means effectively unlimited.')
|
||||
out.append('* Full long-form descriptions exist for most items and can be added per page.')
|
||||
out.append('')
|
||||
out.append('[[Category:Items]]')
|
||||
text = '\n'.join(out)
|
||||
os.makedirs(WIKI, exist_ok=True)
|
||||
open(OUT, 'w').write(text)
|
||||
from collections import Counter
|
||||
c = Counter(CAT_LABEL.get(v['type'], v['type']) for v in reg.values())
|
||||
print(f"wrote {OUT} ({len(text)} chars, {len(reg)} items)")
|
||||
for k, v in c.most_common():
|
||||
print(f" {v:3d} {k}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
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()
|
||||
126
wikigen/render_wiki.py
Normal file
126
wikigen/render_wiki.py
Normal file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render the generated MediaWiki crafting page to a standalone HTML file with
|
||||
MediaWiki (Vector skin) styling, so it can be eyeballed locally. Handles exactly
|
||||
the subset of wikitext our generator emits: headings, bold/italic, [[links]],
|
||||
{| wikitable sortable |}, * lists, <code>, __TOC__, [[Category:]].
|
||||
"""
|
||||
import re, sys, html, pathlib
|
||||
|
||||
SRC = pathlib.Path('/home/downloadpizza/sand_tools/wiki/Crafting.mediawiki')
|
||||
OUT = pathlib.Path.home() / 'sand_tools' / 'wiki_site' / 'index.html'
|
||||
|
||||
def inline(t):
|
||||
# links: [[Target]] or [[Target|Label]]
|
||||
def lk(m):
|
||||
tgt = m.group(1); lab = m.group(2) or tgt
|
||||
return f'<a class="wlink" href="#">{html.escape(lab)}</a>'
|
||||
t = re.sub(r'\[\[([^\]|]+)(?:\|([^\]]+))?\]\]', lk, t)
|
||||
t = re.sub(r"'''(.+?)'''", r'<strong>\1</strong>', t)
|
||||
t = re.sub(r"''(.+?)''", r'<em>\1</em>', t)
|
||||
return t # note: × / <code> already valid HTML, pass through
|
||||
|
||||
def render(wt):
|
||||
out = []
|
||||
cats = []
|
||||
lines = wt.splitlines()
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
ln = lines[i]
|
||||
if ln.strip() == '__TOC__':
|
||||
i += 1; continue
|
||||
m = re.match(r'\[\[Category:([^\]]+)\]\]', ln.strip())
|
||||
if m:
|
||||
cats.append(m.group(1)); i += 1; continue
|
||||
h = re.match(r'^(=+)\s*(.*?)\s*=+\s*$', ln)
|
||||
if h:
|
||||
lvl = len(h.group(1))
|
||||
out.append(f'<h{lvl}>{inline(h.group(2))}</h{lvl}>')
|
||||
i += 1; continue
|
||||
if ln.startswith('{|'): # table
|
||||
cls = 'wikitable'
|
||||
if 'sortable' in ln: cls += ' sortable'
|
||||
out.append(f'<table class="{cls}">')
|
||||
i += 1
|
||||
while i < len(lines) and not lines[i].startswith('|}'):
|
||||
row = lines[i]
|
||||
if row.startswith('!'):
|
||||
cells = row[1:].split('!!')
|
||||
out.append('<tr>' + ''.join(
|
||||
f'<th>{inline(_celltext(c))}</th>' for c in cells) + '</tr>')
|
||||
elif row.startswith('|-'):
|
||||
pass
|
||||
elif row.startswith('|'):
|
||||
cells = row[1:].split('||')
|
||||
out.append('<tr>' + ''.join(
|
||||
f'<td>{inline(_celltext(c))}</td>' for c in cells) + '</tr>')
|
||||
i += 1
|
||||
out.append('</table>')
|
||||
i += 1; continue
|
||||
if ln.startswith('* '):
|
||||
items = []
|
||||
while i < len(lines) and lines[i].startswith('* '):
|
||||
items.append(f'<li>{inline(lines[i][2:])}</li>'); i += 1
|
||||
out.append('<ul>' + ''.join(items) + '</ul>'); continue
|
||||
if ln.strip() == '':
|
||||
i += 1; continue
|
||||
out.append(f'<p>{inline(ln)}</p>')
|
||||
i += 1
|
||||
if cats:
|
||||
out.append('<div class="catlinks"><strong>Categories</strong>: ' +
|
||||
' | '.join(f'<a class="wlink" href="#">{html.escape(c)}</a>' for c in cats) +
|
||||
'</div>')
|
||||
return '\n'.join(out)
|
||||
|
||||
def _celltext(c):
|
||||
# strip optional "attr=... | text" cell prefix
|
||||
if '|' in c and not c.strip().startswith('['):
|
||||
# only split on a | that's an attribute separator (before any [[ )
|
||||
pre, _, post = c.partition('|')
|
||||
if '[[' not in pre:
|
||||
return post.strip()
|
||||
return c.strip()
|
||||
|
||||
PAGE = """<!doctype html><html><head><meta charset="utf-8">
|
||||
<title>{title} - SAND Wiki (local test)</title>
|
||||
<style>
|
||||
body{{font-family:sans-serif;background:#f6f6f6;margin:0;color:#202122}}
|
||||
.mw-body{{max-width:60em;margin:1em auto;background:#fff;border:1px solid #a2a9b1;padding:1.5em 2em}}
|
||||
h1{{font-family:'Linux Libertine',Georgia,serif;font-weight:normal;border-bottom:1px solid #a2a9b1;padding-bottom:.2em;margin-top:0}}
|
||||
h2{{font-family:'Linux Libertine',Georgia,serif;font-weight:normal;border-bottom:1px solid #a2a9b1;padding-bottom:.2em;margin-top:1.2em}}
|
||||
table.wikitable{{background:#f8f9fa;border:1px solid #a2a9b1;border-collapse:collapse;margin:1em 0}}
|
||||
table.wikitable>tr>th,table.wikitable>tr>td,table.wikitable th,table.wikitable td{{border:1px solid #a2a9b1;padding:.3em .6em}}
|
||||
table.wikitable th{{background:#eaecf0;text-align:center}}
|
||||
table.sortable th{{cursor:pointer}}
|
||||
table.sortable th::after{{content:" \\2195";color:#888;font-size:.8em}}
|
||||
a.wlink{{color:#0645ad;text-decoration:none}}
|
||||
a.wlink:hover{{text-decoration:underline}}
|
||||
.catlinks{{border:1px solid #a2a9b1;background:#f8f9fa;padding:.4em .8em;margin-top:1.5em;font-size:.9em}}
|
||||
.note{{background:#fef6e7;border:1px solid #fc3;padding:.4em .8em;font-size:.85em;margin-bottom:1em}}
|
||||
</style></head><body><div class="mw-body">
|
||||
<div class="note">Local render for testing — not a live MediaWiki. Links are inert; sorting is basic JS.</div>
|
||||
<h1>{title}</h1>
|
||||
{body}
|
||||
</div>
|
||||
<script>
|
||||
document.querySelectorAll('table.sortable').forEach(function(t){{
|
||||
t.querySelectorAll('th').forEach(function(th,ci){{
|
||||
th.addEventListener('click',function(){{
|
||||
var rows=Array.from(t.querySelectorAll('tr')).slice(1);
|
||||
var num=th.textContent.match(/Time/);
|
||||
var dir=th._d=!th._d;
|
||||
rows.sort(function(a,b){{
|
||||
var x=a.cells[ci].textContent.trim(),y=b.cells[ci].textContent.trim();
|
||||
if(num){{x=parseFloat(x)||0;y=parseFloat(y)||0;return dir?x-y:y-x;}}
|
||||
return dir?x.localeCompare(y):y.localeCompare(x);
|
||||
}});
|
||||
rows.forEach(function(r){{t.appendChild(r);}});
|
||||
}});
|
||||
}});
|
||||
}});
|
||||
</script></body></html>"""
|
||||
|
||||
if __name__ == '__main__':
|
||||
wt = SRC.read_text()
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text(PAGE.format(title='Crafting', body=render(wt)))
|
||||
print(f"wrote {OUT}")
|
||||
Reference in New Issue
Block a user