#!/usr/bin/env python3 """从本地 K2 Lua 源快照生成 Markdown 与 CSV 配方表。""" from __future__ import annotations import argparse import csv import hashlib import io import re from collections import Counter from dataclasses import dataclass from pathlib import Path HERE = Path(__file__).resolve().parent ROOT = HERE.parents[1] SOURCE_DIR = ROOT / "old" / "中间文件" / "recipe_sources" / "k2" ZH_LOCALE = ROOT / "old" / "中间文件" / "locale_sources" / "k2so" / "Krastorio2-zh-CN.cfg" EN_LOCALE = ROOT / "old" / "中间文件" / "locale_sources" / "k2" / "Krastorio2-en.cfg" MD_PATH = HERE / "K2配方表.md" CSV_PATH = HERE / "K2配方表.csv" CATEGORY_ZH = { "advanced-chemistry": "高级化学", "air-purification": "空气净化", "atmosphere-condensation": "大气冷凝", "bioprocessing": "生物处理", "centrifuging": "离心处理", "chemistry": "化学", "crafting": "制造", "crafting-with-fluid": "带流体制造", "crushing": "粉碎", "electrolysis": "电解", "fluid-filtration": "流体过滤", "fuel-refinery": "燃料精炼", "growing": "培育", "matter-deconversion": "物质逆转换", "nuclear-fusion": "核聚变", "oil-processing": "石油处理", "smelting": "冶炼", "smelting-crafting": "冶炼制造", "stabilizer-charging": "稳定器充能", "t3-tech-cards": "三级科技卡", } @dataclass(frozen=True) class Stack: kind: str name: str amount: str probability: str | None = None temperature: str | None = None @dataclass(frozen=True) class Recipe: source_file: str source_line: int category: str recipe_id: str energy_required: str enabled: bool ingredients: tuple[Stack, ...] results: tuple[Stack, ...] def mask_strings_and_comments(text: str) -> str: """屏蔽字符串与注释内容,同时保留字符位置和换行。""" chars = list(text) i = 0 state = "code" quote = "" while i < len(chars): c = chars[i] n = chars[i + 1] if i + 1 < len(chars) else "" if state == "code": if c == "-" and n == "-": chars[i] = chars[i + 1] = " " i += 2 state = "comment" continue if c in {'"', "'"}: quote = c chars[i] = " " i += 1 state = "string" continue elif state == "comment": if c == "\n": state = "code" else: chars[i] = " " i += 1 continue elif state == "string": if c == "\\": chars[i] = " " if i + 1 < len(chars): chars[i + 1] = " " i += 2 continue if c == quote: chars[i] = " " state = "code" elif c != "\n": chars[i] = " " i += 1 continue i += 1 return "".join(chars) def brace_pairs(text: str) -> dict[int, int]: masked = mask_strings_and_comments(text) stack: list[int] = [] pairs: dict[int, int] = {} for i, c in enumerate(masked): if c == "{": stack.append(i) elif c == "}" and stack: pairs[stack.pop()] = i return pairs def containing_block(text: str, position: int) -> tuple[int, int]: pairs = brace_pairs(text) candidates = [(start, end) for start, end in pairs.items() if start < position < end] if not candidates: raise ValueError(f"找不到位置 {position} 所属的 Lua 表") return min(candidates, key=lambda pair: pair[1] - pair[0]) def extract_table(block: str, field: str) -> str: match = re.search(rf"\b{re.escape(field)}\s*=\s*\{{", block) if not match: raise ValueError(f"配方缺少 {field} 表") opening = block.find("{", match.start()) closing = brace_pairs(block).get(opening) if closing is None: raise ValueError(f"{field} 表的大括号不配对") return block[opening : closing + 1] def scalar(block: str, field: str, default: str | None = None) -> str: match = re.search(rf"\b{re.escape(field)}\s*=\s*([^,\n}}]+)", block) if not match: if default is None: raise ValueError(f"配方缺少字段 {field}") return default return match.group(1).strip().strip('"\'') def parse_stacks(table: str) -> tuple[Stack, ...]: stacks: list[Stack] = [] for match in re.finditer(r"\{([^{}]*)\}", table, flags=re.S): entry = match.group(1) name_match = re.search( r'\bname\s*=\s*(?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))', entry, ) if not name_match: continue kind_match = re.search(r'\btype\s*=\s*"([^"]+)"', entry) amount_match = re.search(r"\bamount\s*=\s*([0-9.]+)", entry) min_match = re.search(r"\bamount_min\s*=\s*([0-9.]+)", entry) max_match = re.search(r"\bamount_max\s*=\s*([0-9.]+)", entry) probability_match = re.search(r"\bprobability\s*=\s*([0-9.]+)", entry) temperature_match = re.search(r"\btemperature\s*=\s*([0-9.]+)", entry) if amount_match: amount = amount_match.group(1) elif min_match and max_match: amount = f"{min_match.group(1)}–{max_match.group(1)}" else: amount = "1" stacks.append( Stack( kind=kind_match.group(1) if kind_match else "item", name=name_match.group(1) or name_match.group(2), amount=amount, probability=probability_match.group(1) if probability_match else None, temperature=temperature_match.group(1) if temperature_match else None, ) ) return tuple(stacks) def parse_recipe_file(path: Path) -> list[Recipe]: text = path.read_text(encoding="utf-8-sig") recipes: list[Recipe] = [] seen_blocks: set[tuple[int, int]] = set() for match in re.finditer(r'\btype\s*=\s*"recipe"', text): start, end = containing_block(text, match.start()) if (start, end) in seen_blocks: continue seen_blocks.add((start, end)) block = text[start : end + 1] recipe_id = scalar(block, "name") recipes.append( Recipe( source_file=path.name, source_line=text.count("\n", 0, start) + 1, category=scalar(block, "category", "crafting"), recipe_id=recipe_id, energy_required=scalar(block, "energy_required", "0.5"), enabled=scalar(block, "enabled", "true").lower() == "true", ingredients=parse_stacks(extract_table(block, "ingredients")), results=parse_stacks(extract_table(block, "results")), ) ) return recipes def parse_locale(path: Path) -> dict[str, dict[str, str]]: sections: dict[str, dict[str, str]] = {} current = "" for raw_line in path.read_text(encoding="utf-8-sig").splitlines(): line = raw_line.strip() if not line or line.startswith(";") or line.startswith("#"): continue if line.startswith("[") and line.endswith("]"): current = line[1:-1] sections.setdefault(current, {}) continue if "=" in line and current: key, value = line.split("=", 1) sections[current][key] = value return sections def expand_macros(value: str, locale: dict[str, dict[str, str]]) -> str: macro_sections = {"ITEM": "item-name", "FLUID": "fluid-name", "ENTITY": "entity-name"} pattern = re.compile(r"__(ITEM|FLUID|ENTITY)__([^_][^_]*)__") for _ in range(5): replaced = pattern.sub( lambda m: locale.get(macro_sections[m.group(1)], {}).get(m.group(2), m.group(2)), value, ) if replaced == value: break value = replaced return value def localized_name( name: str, kind: str, locale: dict[str, dict[str, str]], *, recipe: bool = False, ) -> str | None: if recipe: candidates = ("recipe-name", "item-name", "fluid-name", "entity-name") elif kind == "fluid": candidates = ("fluid-name", "item-name", "recipe-name") else: candidates = ("item-name", "entity-name", "recipe-name", "fluid-name") for section in candidates: value = locale.get(section, {}).get(name) if value: return expand_macros(value, locale) return None def source_digest(paths: list[Path]) -> str: digest = hashlib.sha256() for path in paths: digest.update(path.name.encode("utf-8")) digest.update(b"\0") digest.update(path.read_bytes()) digest.update(b"\0") return digest.hexdigest() def escape_md(value: str) -> str: return value.replace("|", "\\|").replace("\n", " ") def story_duration_hours(game_seconds: str) -> str: """按全书统一比例把游戏配方秒数换算为正文小时数。""" try: return f"{float(game_seconds):g}小时" except ValueError: return f"{game_seconds}小时(待运行时解析)" def story_duration_value(game_seconds: str) -> str: """供 CSV 使用的正文小时数;数值与游戏秒数保持一比一。""" try: return f"{float(game_seconds):g}" except ValueError: return game_seconds def display_name( name: str, kind: str, zh: dict[str, dict[str, str]], en: dict[str, dict[str, str]], *, recipe: bool = False, ) -> str: zh_name = localized_name(name, kind, zh, recipe=recipe) en_name = localized_name(name, kind, en, recipe=recipe) label = zh_name or en_name if label and label != name: return f"{escape_md(label)}
`{name}`" return f"`{name}`" def stack_amount(stack: Stack) -> str: value = stack.amount if stack.probability: value += f"({float(stack.probability) * 100:g}% 概率)" if stack.temperature: value += f"({stack.temperature}℃)" return value def display_stacks( stacks: tuple[Stack, ...], zh: dict[str, dict[str, str]], en: dict[str, dict[str, str]], ) -> str: if not stacks: return "无" rendered = [] for stack in stacks: type_note = "(流体)" if stack.kind == "fluid" else "" rendered.append( f"{display_name(stack.name, stack.kind, zh, en)} × {stack_amount(stack)}{type_note}" ) return "
".join(rendered) def plain_name( name: str, kind: str, locale: dict[str, dict[str, str]], *, recipe: bool = False, ) -> str: return localized_name(name, kind, locale, recipe=recipe) or "" def plain_stacks( stacks: tuple[Stack, ...], zh: dict[str, dict[str, str]], en: dict[str, dict[str, str]], ) -> str: values = [] for stack in stacks: zh_name = plain_name(stack.name, stack.kind, zh) en_name = plain_name(stack.name, stack.kind, en) names = "/".join(part for part in (zh_name, en_name, stack.name) if part) type_note = "[fluid]" if stack.kind == "fluid" else "[item]" values.append(f"{names} x {stack_amount(stack)} {type_note}") return "; ".join(values) def build_markdown( recipes: list[Recipe], zh: dict[str, dict[str, str]], en: dict[str, dict[str, str]], digest: str, ) -> str: counts = Counter(recipe.category for recipe in recipes) lines = [ "# K2配方表(本地源快照)", "", "## 文件状态", "", "本文件是写作参考资料,不是《越共》正文 canon,也不是一局实际安装模组后导出的最终配方数据库。", "", f"- 数据范围:`old/中间文件/recipe_sources/k2/` 内 {len(counts)} 个 Lua 文件,共 {len(recipes)} 条显式 `recipe` 定义。", "- 中文名称:优先采用本地 `k2so/Krastorio2-zh-CN.cfg`;缺失时退回 K2 英文 locale 或内部 ID。中文只用于检索和阅读,不参与数值判定。", "- 未包含:基础游戏配方、其他 K2 脚本在 `data-updates` 等阶段对基础配方的修改、建筑兼容列表、模组设置与实际存档中的最终覆盖结果。", "- 源文件中无法仅靠本快照求值的 Lua 变量名会原样保留。例如 `kr_optimization_tech_card_name` 是运行时解析的配方名,不擅自猜成某个固定物品 ID。", "- 本地没有 K2 `info.json` 或完整模组包,无法仅凭这批文件确认确切版本。", f"- 源数据 SHA-256 汇总:`{digest}`。", "- 本表由同目录 `生成K2配方表.py` 生成;源文件变化后可重新生成,勿手工修改表格主体。", "", "## 对小说写作的直接用法", "", "- 实验室负责研究和解锁;制造机载入已经取得的配方,检查原料并执行制造。实验室本身不直接制造手枪。", "- 在没有传送带、机械臂和物流网络时,可以人工把金属、零件或标准料箱送入制造机。这与游戏中手动向机器上料的逻辑相容。", "- 本地显式 K2 配方快照中没有 `pistol` 配方;小说采用用户确认截图中的手枪配方:制造时间5秒、铁板5、铜板5、产出1。K2归档源码中的铁板2、铜板1属于另一版本,不用于当前正文。", "- 全书配方时间统一按1游戏秒=正文1小时换算。本表保留游戏原始秒数,并另列小说基准制造时长;排队、人工上料、质检、冷却、故障与停机时间另行累计。", "- 配方中的物料单位是游戏单位,不能直接等同于现实千克或升。小说把游戏界面的“产出1”解释为机器完成一次固定生产;原料关系不变,实际用料按这次产出的全部成品核算。", "- M1910式手枪在小说中每次固定生产32把,并封装为一只成品箱;32把约18.88千克,连同空箱与四层托盘仍低于24千克上限。制造完成后需由能源/解包节点开箱,才能取得和分配单把手枪。", "", "## 手枪与弹药补充参考", "", "| 对象 | K2/基础游戏原配方 | 小说适配层 |", "|---|---|---|", "| 当前采用的游戏手枪 | 制造时间5秒、铁板5、铜板5、产出1 | 自动实验室从M1910样枪得到本机可用的制造方法;5秒换算为一箱32把手枪制造5小时,实际取料按整箱质量与加工损耗缩放。枪械本身不消耗底火或发射药 |", "| 基础枪械弹匣 | 制造时间1秒、铁板4、成品弹匣1;每只弹匣10发 | M1910的7.65毫米弹匣容量为7发。小说适配为制造时间1秒、铁板3、铜板1、产出1箱;正文基准制造1小时。每箱192只满装弹匣,共1344发;铅料、发射药和底火作为受控耗材另行检查 |", "", "- K2归档手枪配方(仅作版本差异参考,当前正文不采用):", "- Factorio基础枪械弹匣:", "- M1910式手枪的.32 ACP弹匣容量为7发:", "- 一发7.65×17毫米手枪弹约重7.9克,其中弹头约4.8克、黄铜弹壳约2.6克;7发约重55.3克。按空弹匣不超过50克估算,192只满装弹匣不超过20.22千克,连同约3.5千克箱体与托盘后不超过23.72千克:", "- Factorio 2.0.7 已删除手枪制造配方,因此这里采用的是K2对应旧版基础游戏的历史逻辑,而不是当前2.x配方。", "- 铁板、铜板和钢板按K2物料类型分别记账。任何配方中的钢板都保留为钢板,不向下折算成铁板;本书采用的基础弹匣配方不含钢板。", "- 为保持物质守恒,小说中的手枪弹必须实际消耗弹壳、弹头、发射药和底火材料。设备可以记录现有余料并报告缺料,但不会自行规划补充来源。", "- K2/基础游戏存在“炸药”科技与 `explosives` 中间品,但基础枪械弹匣不消耗它。小说中该科技用于后续独立补充军械化工耗材、爆破采矿和爆炸武器,不能倒填成第007章手枪弹的直接原料。", "- Factorio炸药科技:", "", "## 研究设施边界", "", "本地科技文件表明:`kr-laboratory` 科技解锁基础 `lab`;`kr-advanced-lab` 是更高效的实验室且不兼容基础科技卡;`kr-research-server` 用于生产复杂科技卡;`kr-singularity-lab` 属于更后期的研究设施。它们支持“研究—解锁—制造”的分工,不支持“实验室直接吐出成品”。", "", "## 分类统计", "", "| 内部类别 | 中文说明 | 配方数 |", "|---|---:|---:|", ] for category in sorted(counts): lines.append(f"| `{category}` | {CATEGORY_ZH.get(category, '未译')} | {counts[category]} |") lines.extend(["", "## 配方明细", ""]) for category in sorted(counts): category_recipes = sorted( (recipe for recipe in recipes if recipe.category == category), key=lambda recipe: recipe.recipe_id, ) lines.extend( [ f"### {CATEGORY_ZH.get(category, category)}(`{category}`,{len(category_recipes)}条)", "", "| 配方 | 游戏耗时(秒) | 小说基准制造时长 | 初始可用 | 输入 | 输出 | 源位置 |", "|---|---:|---:|:---:|---|---|---|", ] ) for recipe in category_recipes: source = ( f"[{recipe.source_file}:{recipe.source_line}]" f"(../../old/中间文件/recipe_sources/k2/{recipe.source_file}#L{recipe.source_line})" ) lines.append( "| " + " | ".join( [ display_name(recipe.recipe_id, "item", zh, en, recipe=True), recipe.energy_required, story_duration_hours(recipe.energy_required), "是" if recipe.enabled else "否", display_stacks(recipe.ingredients, zh, en), display_stacks(recipe.results, zh, en), source, ] ) + " |" ) lines.append("") return "\n".join(lines).rstrip() + "\n" def build_csv( recipes: list[Recipe], zh: dict[str, dict[str, str]], en: dict[str, dict[str, str]], ) -> bytes: output = io.StringIO(newline="") writer = csv.writer(output) writer.writerow( [ "source_file", "source_line", "category", "category_zh", "recipe_id", "recipe_name_zh", "recipe_name_en", "energy_required", "story_duration_hours", "enabled", "ingredients", "results", ] ) for recipe in sorted(recipes, key=lambda item: (item.category, item.recipe_id)): writer.writerow( [ recipe.source_file, recipe.source_line, recipe.category, CATEGORY_ZH.get(recipe.category, ""), recipe.recipe_id, plain_name(recipe.recipe_id, "item", zh, recipe=True), plain_name(recipe.recipe_id, "item", en, recipe=True), recipe.energy_required, story_duration_value(recipe.energy_required), "true" if recipe.enabled else "false", plain_stacks(recipe.ingredients, zh, en), plain_stacks(recipe.results, zh, en), ] ) return output.getvalue().encode("utf-8-sig") def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--check", action="store_true", help="只校验生成结果是否为最新") args = parser.parse_args() source_paths = sorted(SOURCE_DIR.glob("*.lua"), key=lambda path: path.name) if not source_paths: raise SystemExit(f"未找到 K2 配方源:{SOURCE_DIR}") recipes = [recipe for path in source_paths for recipe in parse_recipe_file(path)] if len(recipes) != 119: raise SystemExit(f"配方数量异常:预期 119,实际 {len(recipes)}") ids = [recipe.recipe_id for recipe in recipes] duplicates = sorted(name for name, count in Counter(ids).items() if count > 1) if duplicates: raise SystemExit(f"发现重复配方 ID:{', '.join(duplicates)}") zh = parse_locale(ZH_LOCALE) en = parse_locale(EN_LOCALE) markdown = build_markdown(recipes, zh, en, source_digest(source_paths)).encode("utf-8") csv_bytes = build_csv(recipes, zh, en) if args.check: problems = [] if not MD_PATH.exists() or MD_PATH.read_bytes() != markdown: problems.append(MD_PATH.name) if not CSV_PATH.exists() or CSV_PATH.read_bytes() != csv_bytes: problems.append(CSV_PATH.name) if problems: raise SystemExit("生成文件不是最新版本:" + "、".join(problems)) print(f"校验通过:{len(source_paths)} 个源文件,{len(recipes)} 条配方。") return 0 MD_PATH.write_bytes(markdown) CSV_PATH.write_bytes(csv_bytes) print(f"已生成 {MD_PATH.name} 与 {CSV_PATH.name}:{len(recipes)} 条配方。") return 0 if __name__ == "__main__": raise SystemExit(main())