Files
yuegong/资料/异星工厂/生成K2配方表.py
T
klarkxy 6078f261d7 ♻️ refactor(项目结构): 升格007—009章正文并清理旧草稿
- 升格草稿目录第007—009章为正式正文,迁移至正文对应卷篇
- 删除已被替代的第007—020章旧草稿,简化草稿索引
- 精简章纲文件,聚焦当前工业自举路线
- 补充工业与制造设定:建筑部署、组装机1型、设备信息边界
- 在AGENTS.md与风格约束中加入禁用"先否定后肯定"空架的规则
- 新增smooth-zh-prose-flow中文衔接审校技能及参考资料
- 同步更新资产索引与世界书系统边界
2026-07-22 23:10:20 +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/>",
"- 一发7.65×17毫米手枪弹约重7.9克,其中弹头约4.8克、黄铜弹壳约2.6克;7发约重55.3克。按空弹匣不超过50克估算,192只满装弹匣不超过20.22千克,连同约3.5千克箱体与托盘后不超过23.72千克:<https://dlntech.co.in/media/attachments/2025/07/06/datasheet-caliber-765mm.pdf>",
"- Factorio 2.0.7 已删除手枪制造配方,因此这里采用的是K2对应旧版基础游戏的历史逻辑,而不是当前2.x配方。",
"- 铁板、铜板和钢板按K2物料类型分别记账。任何配方中的钢板都保留为钢板,不向下折算成铁板;本书采用的基础弹匣配方不含钢板。",
"- 为保持物质守恒,小说中的手枪弹必须实际消耗弹壳、弹头、发射药和底火材料。设备可以记录现有余料并报告缺料,但不会自行规划补充来源。",
"- K2/基础游戏存在“炸药”科技与 `explosives` 中间品,但基础枪械弹匣不消耗它。小说中该科技用于后续独立补充军械化工耗材、爆破采矿和爆炸武器,不能倒填成第007章手枪弹的直接原料。",
"- 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())