#!/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发:",
"- Factorio 2.0.7 已删除手枪制造配方,因此这里采用的是K2对应旧版基础游戏的历史逻辑,而不是当前2.x配方。",
"- 铁板、铜板和钢板按K2物料类型分别记账。任何配方中的钢板都保留为钢板,不向下折算成铁板;本书采用的基础弹匣配方不含钢板。",
"- 为保持物质守恒,小说中的手枪弹必须实际消耗弹壳、铜包软铁芯弹头与发射药。公司新弹完全不使用铅;击发所需的无铅组分封装在“发射药”受控配方内部,不拆成第二张读者或普通终端可见的配方。正文008的47发旧弹只用于建立模板,试射与配发均使用枪械完成后另行生产的新弹。",
"- 舰载应急化工机以空气、水、植物纤维和电力为输入,输出统一称为“发射药”。其催化核心会缓慢老化,现阶段既不能复制整机,也不能复制核心;接通该设备不代表本地已经完成完整化工自举。",
"- K2/基础游戏中的 `explosives` 是炸药中间品,不是小武器发射药,基础枪械弹匣也不消耗它。本书只把该科技用于爆破采矿和爆炸武器参考,不能用它替代或倒填第007—008章的手枪弹发射药。",
"- 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())