Files
yuegong/资料/异星工厂/生成K2配方表.py
T
klarkxy bccba914d6 ♻️ refactor(项目结构): 完成001—020章正文升格与资产同步
- 升格001—009章为正式正文,重写010—020章并迁移至正文目录
- 删除草稿目录中010—012章候选稿,统一以正式正文为准
- 新增黄氏莲、梁文和、武元甲人物卡,扩展朱文晋、农文禄、赫默边界
- 同步世界书、工业设定、章纲、伏笔承诺、资产索引与风格约束
- 新增 tasks/plan.md 与 todo.md 记录重写计划与验收
2026-07-24 23:33:18 +08:00

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