113 lines
5.5 KiB
Python
113 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Build a bounded, versioned wiki catalogue from the current server's public tables.
|
|
No account/character/configuration data is read. Output is UTF-8 TSV, never executable Python.
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
FILES = ('item_proto.txt', 'item_names.txt', 'mob_proto.txt', 'mob_names.txt', 'refine.tsv')
|
|
|
|
|
|
def rows(path):
|
|
for line in path.read_bytes().splitlines():
|
|
cols = line.split(b'\t')
|
|
if cols and cols[0].split(b'~')[0].isdigit():
|
|
yield cols
|
|
|
|
|
|
def names(path, overlay):
|
|
result = {}
|
|
for cols in rows(path):
|
|
if len(cols) > 1:
|
|
result[int(cols[0].split(b'~')[0])] = cols[1].decode('gbk', 'replace')
|
|
for path in overlay:
|
|
for line in path.read_text(encoding='utf-8').splitlines():
|
|
c = line.split('\t', 1)
|
|
if len(c) == 2 and c[0].isdigit():
|
|
result[int(c[0])] = c[1]
|
|
return result
|
|
|
|
|
|
def clean(value):
|
|
value = str(value)
|
|
if any(c in value for c in '\t\r\n\x00') or len(value) > 256:
|
|
raise ValueError('invalid catalogue cell')
|
|
# Native text renderer recognizes pipe escape sequences; source names are plain text.
|
|
return value.replace('|', '/')
|
|
|
|
|
|
def build(source, output, names_dir):
|
|
hashes = {name: hashlib.sha256((source / name).read_bytes()).hexdigest() for name in FILES}
|
|
item_names = names(source / 'item_names.txt', [names_dir / 'item_names_zh.txt', names_dir / 'item_names_zh_overlay.txt'])
|
|
mob_names = names(source / 'mob_names.txt', [names_dir / 'mob_names_zh.txt', names_dir / 'mob_names_zh_overlay.txt'])
|
|
records, items, mobs, refines = [], {}, set(), {}
|
|
for c in rows(source / 'item_proto.txt'):
|
|
if len(c) < 33:
|
|
raise ValueError('truncated item row')
|
|
extent = list(map(int, c[0].split(b'~')))
|
|
start, end = extent[0], extent[-1]
|
|
if not 0 < start <= end < 2**32 or end - start > 10000 or start in items:
|
|
raise ValueError('invalid/duplicate item range')
|
|
kind, subtype = c[2].decode('ascii'), c[3].decode('ascii')
|
|
if kind == 'ITEM_ELK':
|
|
continue
|
|
level = max([int(c[i+1]) for i in (14, 16) if c[i] == b'LEVEL'] + [0])
|
|
name = item_names.get(start, '物品 #%d' % start)
|
|
# I id range_end name type subtype minimum_level size refined_vnum refine_set
|
|
row = ['I', start, end, name, kind, subtype, level, int(c[4]), int(c[11]), int(c[12])]
|
|
items[start] = row
|
|
records.append(row)
|
|
extents = sorted((v[1], v[2]) for v in items.values())
|
|
if any(a[1] >= b[0] for a, b in zip(extents, extents[1:])):
|
|
raise ValueError('overlapping item ranges')
|
|
for c in rows(source / 'mob_proto.txt'):
|
|
if len(c) < 27:
|
|
raise ValueError('truncated mob row')
|
|
if c[3] not in (b'MONSTER', b'STONE'):
|
|
continue
|
|
vnum = int(c[0])
|
|
if vnum in mobs:
|
|
raise ValueError('duplicate monster')
|
|
mobs.add(vnum)
|
|
# M id name type rank level hp damage_min damage_max experience defense
|
|
records.append(['M', vnum, mob_names.get(vnum, '怪物 #%d' % vnum), c[3].decode('ascii'),
|
|
c[2].decode('ascii')] + [int(c[i]) for i in (5, 20, 18, 19, 25, 26)])
|
|
for c in rows(source / 'refine.tsv'):
|
|
if len(c) != 13:
|
|
raise ValueError('refine table must contain 13 columns')
|
|
values = list(map(int, c))
|
|
rid, cost, prob = values[:3]
|
|
if rid in refines or rid <= 0 or cost < 0 or not 0 <= prob <= 100:
|
|
raise ValueError('invalid refine record')
|
|
mats = [(values[i], values[i+1]) for i in range(3, 13, 2) if values[i] or values[i+1]]
|
|
if any(v <= 0 or n <= 0 for v, n in mats):
|
|
raise ValueError('invalid refine ingredient')
|
|
refines[rid] = True
|
|
records.append(['R', rid, cost, prob, ','.join('%d:%d' % pair for pair in mats)])
|
|
if not items or not mobs or not refines or len(records) > 30000:
|
|
raise ValueError('empty or oversized catalogue')
|
|
payload = ''.join('\t'.join(clean(c) for c in row) + '\n' for row in records).encode('utf-8')
|
|
digest = hashlib.sha256(payload).hexdigest()
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
(output / 'catalogue.txt').write_bytes(('MTWIKI\t1\t' + digest + '\n').encode('ascii') + payload)
|
|
report = dict(schema=1, catalogue_sha256=digest, source_sha256=hashes,
|
|
items=len(items), monsters=len(mobs), refine_recipes=len(refines),
|
|
source='203 public server tables; no player data',
|
|
generator_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
|
localization_sha256={name:hashlib.sha256((names_dir/name).read_bytes()).hexdigest() for name in ('item_names_zh.txt','item_names_zh_overlay.txt','mob_names_zh.txt','mob_names_zh_overlay.txt')},
|
|
scope='template catalogue; acquisition and runtime rates not yet queried')
|
|
(output / 'manifest.json').write_text(json.dumps(report, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
|
|
return report
|
|
|
|
|
|
if __name__ == '__main__':
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--source', type=Path, required=True)
|
|
parser.add_argument('--output', type=Path, default=REPO / 'data/wiki')
|
|
parser.add_argument('--names-dir', type=Path, default=REPO / 'data/zh_40250')
|
|
args = parser.parse_args()
|
|
print(json.dumps(build(args.source, args.output, args.names_dir), ensure_ascii=False, indent=2))
|