Files
Nanami-UI/agent-tools/generate_consumable_db.py
2026-04-09 09:46:47 +08:00

427 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import argparse
import re
import textwrap
import xml.etree.ElementTree as ET
import zipfile
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path, PurePosixPath
MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
DOC_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
NS = {
"main": MAIN_NS,
}
ROLE_META = {
"坦克 (物理坦克)": {
"key": "tank_physical",
"detail": "坦克 · 物理坦克",
"color": (0.40, 0.70, 1.00),
},
"坦克 (法系坦克)": {
"key": "tank_caster",
"detail": "坦克 · 法系坦克",
"color": (1.00, 0.80, 0.20),
},
"法系输出": {
"key": "caster_dps",
"detail": "输出 · 法系输出",
"color": (0.65, 0.45, 1.00),
},
"物理近战": {
"key": "melee_dps",
"detail": "输出 · 物理近战",
"color": (1.00, 0.55, 0.25),
},
"物理远程": {
"key": "ranged_dps",
"detail": "输出 · 物理远程",
"color": (0.55, 0.88, 0.42),
},
"治疗": {
"key": "healer",
"detail": "治疗",
"color": (0.42, 1.00, 0.72),
},
}
CATEGORY_ORDER = [
"合剂",
"药剂",
"攻强",
"诅咒之地buff",
"赞达拉",
"武器",
"食物",
"",
"药水",
]
# The exported sheet does not include item IDs. We only preserve a very small
# set of IDs that already existed in the addon and can be matched confidently.
ITEM_ID_OVERRIDES = {
"巨人药剂": 9206,
"猫鼬药剂": 13452,
"精炼智慧合剂": 13511,
"夜鳞鱼汤": 13931,
"烤鱿鱼": 13928,
"炎夏火水": 12820,
"自由行动药水": 5634,
}
HEADER_MAP = {
"A": "role",
"B": "category",
"C": "name",
"D": "effect",
"E": "duration",
}
@dataclass
class Row:
role: str
category: str
name: str
effect: str
duration: str
row_number: int
def read_xlsx_rows(path: Path) -> list[Row]:
with zipfile.ZipFile(path) as archive:
shared_strings = load_shared_strings(archive)
workbook = ET.fromstring(archive.read("xl/workbook.xml"))
relationships = ET.fromstring(archive.read("xl/_rels/workbook.xml.rels"))
rel_map = {
rel.attrib["Id"]: rel.attrib["Target"]
for rel in relationships.findall(f"{{{PKG_REL_NS}}}Relationship")
}
sheet = workbook.find("main:sheets", NS)[0]
target = rel_map[sheet.attrib[f"{{{DOC_REL_NS}}}id"]]
sheet_path = normalize_sheet_path(target)
root = ET.fromstring(archive.read(sheet_path))
sheet_rows = root.find("main:sheetData", NS).findall("main:row", NS)
rows: list[Row] = []
for row in sheet_rows[1:]:
values = {}
for cell in row.findall("main:c", NS):
ref = cell.attrib.get("r", "")
col_match = re.match(r"([A-Z]+)", ref)
if not col_match:
continue
col = col_match.group(1)
values[col] = read_cell_value(cell, shared_strings).strip()
if not any(values.values()):
continue
normalized = {
field: normalize_text(values.get(col, ""))
for col, field in HEADER_MAP.items()
}
rows.append(
Row(
role=normalized["role"],
category=normalized["category"],
name=normalized["name"],
effect=normalized["effect"],
duration=normalized["duration"],
row_number=int(row.attrib.get("r", "0")),
)
)
return rows
def load_shared_strings(archive: zipfile.ZipFile) -> list[str]:
if "xl/sharedStrings.xml" not in archive.namelist():
return []
root = ET.fromstring(archive.read("xl/sharedStrings.xml"))
values: list[str] = []
for string_item in root.findall("main:si", NS):
text_parts = [
text_node.text or ""
for text_node in string_item.iter(f"{{{MAIN_NS}}}t")
]
values.append("".join(text_parts))
return values
def normalize_sheet_path(target: str) -> str:
if target.startswith("/"):
normalized = PurePosixPath("xl") / PurePosixPath(target).relative_to("/")
else:
normalized = PurePosixPath("xl") / PurePosixPath(target)
return str(normalized).replace("xl/xl/", "xl/")
def read_cell_value(cell: ET.Element, shared_strings: list[str]) -> str:
cell_type = cell.attrib.get("t")
value_node = cell.find("main:v", NS)
if cell_type == "s" and value_node is not None:
return shared_strings[int(value_node.text)]
if cell_type == "inlineStr":
inline_node = cell.find("main:is", NS)
if inline_node is None:
return ""
return "".join(
text_node.text or ""
for text_node in inline_node.iter(f"{{{MAIN_NS}}}t")
)
return value_node.text if value_node is not None else ""
def normalize_text(value: str) -> str:
value = (value or "").strip()
value = value.replace("\u3000", " ")
value = re.sub(r"\s+", " ", value)
value = value.replace("", "(").replace("", ")")
return value
def normalize_rows(rows: list[Row]) -> list[Row]:
normalized: list[Row] = []
for row in rows:
role = row.role
category = row.category
name = row.name
effect = normalize_effect(row.effect)
duration = normalize_duration(row.duration)
if not role or not category or not name:
raise ValueError(
f"存在不完整数据行Excel 行号 {row.row_number}: "
f"{role!r}, {category!r}, {name!r}"
)
normalized.append(
Row(
role=role,
category=category,
name=name,
effect=effect,
duration=duration,
row_number=row.row_number,
)
)
return normalized
def normalize_effect(value: str) -> str:
value = normalize_text(value)
replacements = {
"": " / ",
"": " / ",
}
for old, new in replacements.items():
value = value.replace(old, new)
value = value.replace("提升治疗", "提升治疗效果")
return value
def normalize_duration(value: str) -> str:
value = normalize_text(value)
replacements = {
"2小时1": "2小时",
"瞬发 ": "瞬发",
}
return replacements.get(value, value)
def build_groups(rows: list[Row]) -> list[dict]:
buckets: dict[str, list[Row]] = defaultdict(list)
for row in rows:
buckets[row.role].append(row)
role_order = [
role
for role in ROLE_META
if role in buckets
]
unknown_roles = sorted(role for role in buckets if role not in ROLE_META)
role_order.extend(unknown_roles)
category_index = {name: index for index, name in enumerate(CATEGORY_ORDER)}
groups = []
for role in role_order:
meta = ROLE_META.get(role, {})
role_rows = sorted(
buckets[role],
key=lambda item: (
category_index.get(item.category, len(CATEGORY_ORDER)),
item.row_number,
item.name,
),
)
items = []
for row in role_rows:
items.append(
{
"cat": row.category,
"name": row.name,
"effect": row.effect,
"duration": row.duration,
"id": ITEM_ID_OVERRIDES.get(row.name, 0),
}
)
groups.append(
{
"key": meta.get("key", slugify(role)),
"role": role,
"detail": meta.get("detail", role),
"color": meta.get("color", (0.85, 0.75, 0.90)),
"items": items,
}
)
return groups
def slugify(value: str) -> str:
value = re.sub(r"[^0-9A-Za-z\u4e00-\u9fff]+", "_", value)
value = value.strip("_")
return value.lower() or "role"
def render_lua(groups: list[dict], source_path: Path, item_count: int) -> str:
role_order = [group["role"] for group in groups]
category_order = sorted(
{item["cat"] for group in groups for item in group["items"]},
key=lambda name: CATEGORY_ORDER.index(name)
if name in CATEGORY_ORDER
else len(CATEGORY_ORDER),
)
generated_at = datetime.fromtimestamp(source_path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
lines = [
"-" * 80,
"-- Nanami-UI: ConsumableDB.lua",
"-- 食物药剂百科数据库(由导出表生成,请优先更新 Excel 后再重新生成)",
f"-- Source: {source_path}",
f"-- Stats : {len(groups)} roles / {len(category_order)} categories / {item_count} entries",
"-" * 80,
"SFrames = SFrames or {}",
"",
"SFrames.ConsumableDB = {",
f' generatedAt = "{generated_at}",',
" summary = {",
f" roleCount = {len(groups)},",
f" categoryCount = {len(category_order)},",
f" itemCount = {item_count},",
" },",
" roleOrder = {",
]
for role in role_order:
lines.append(f' "{lua_escape(role)}",')
lines.extend(
[
" },",
" categoryOrder = {",
]
)
for category in category_order:
lines.append(f' "{lua_escape(category)}",')
lines.extend(
[
" },",
" groups = {",
]
)
for index, group in enumerate(groups, start=1):
color = ", ".join(f"{component:.2f}" for component in group["color"])
lines.extend(
[
"",
f" -- {index}. {group['detail']}",
" {",
f' key = "{lua_escape(group["key"])}",',
f' role = "{lua_escape(group["role"])}",',
f' detail = "{lua_escape(group["detail"])}",',
f" color = {{ {color} }},",
" items = {",
]
)
for item in group["items"]:
lines.append(
' {{ cat="{cat}", name="{name}", effect="{effect}", duration="{duration}", id={item_id} }},'.format(
cat=lua_escape(item["cat"]),
name=lua_escape(item["name"]),
effect=lua_escape(item["effect"]),
duration=lua_escape(item["duration"]),
item_id=item["id"],
)
)
lines.extend(
[
" },",
" },",
]
)
lines.extend(
[
" },",
"}",
"",
]
)
return "\n".join(lines)
def lua_escape(value: str) -> str:
value = value.replace("\\", "\\\\").replace('"', '\\"')
return value
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate ConsumableDB.lua from an exported WoW consumables XLSX."
)
parser.add_argument("xlsx", type=Path, help="Path to the exported XLSX file")
parser.add_argument(
"-o",
"--output",
type=Path,
default=Path("ConsumableDB.lua"),
help="Output Lua file path",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
rows = normalize_rows(read_xlsx_rows(args.xlsx))
groups = build_groups(rows)
lua = render_lua(groups, args.xlsx, len(rows))
args.output.write_text(lua, encoding="utf-8", newline="\n")
print(
textwrap.dedent(
f"""\
Generated {args.output}
source : {args.xlsx}
roles : {len(groups)}
items : {len(rows)}
categories: {len({item['cat'] for group in groups for item in group['items']})}
"""
).strip()
)
if __name__ == "__main__":
main()