From 3468433c9a9ad6bdff8d16cf956ba871c934bb8a Mon Sep 17 00:00:00 2001 From: Chen Yi <466354947@qq.com> Date: Mon, 5 Oct 2026 17:30:11 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AC=AC=201=20=E5=8D=B7=E7=AC=AC=201=20?= =?UTF-8?q?=E7=AB=A0=EF=BC=9A1=E2=80=939=20=E5=B9=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- {src/dfannals => dfannals}/__init__.py | 0 dfannals/chronicle.py | 159 +++++++ dfannals/cli.py | 181 ++++++++ {src/dfannals => dfannals}/config.py | 11 +- dfannals/factcheck.py | 141 ++++++ dfannals/legends.py | 347 +++++++++++++++ {src/dfannals => dfannals}/llm.py | 2 +- {src/dfannals => dfannals}/publish.py | 2 +- dfannals/score.py | 140 ++++++ dfannals/slice.py | 195 +++++++++ dfannals/timeline.py | 108 +++++ output/volumes/v1/chapters/001-1-9.md | 26 ++ output/volumes/v1/figures.md | 126 ++++++ output/volumes/v1/timeline.md | 475 +++++++++++++++++++++ prompts/chronicler.md | 3 +- pyproject.toml | 13 + pyrightconfig.json | 8 + scripts/deploy-dfhack.sh | 26 ++ scripts/df-screen.py | 103 +++++ scripts/dfhack/dfannals/act.lua | 27 ++ scripts/dfhack/dfannals/click.lua | 28 ++ scripts/dfhack/dfannals/dump.lua | 57 +++ scripts/dfhack/dfannals/observe.lua | 39 ++ scripts/dfhack/dfannals/probe.lua | 56 +++ scripts/dfhack/dfannals/widgets.lua | 49 +++ scripts/install-df.sh | 61 +++ src/dfannals.egg-info/PKG-INFO | 6 + src/dfannals.egg-info/SOURCES.txt | 14 + src/dfannals.egg-info/dependency_links.txt | 1 + src/dfannals.egg-info/requires.txt | 1 + src/dfannals.egg-info/top_level.txt | 1 + tests/fixtures/sample-legends.xml | 23 + tests/fixtures/sample-legends_plus.xml | 8 + tests/test_factcheck.py | 131 ++++++ 34 files changed, 2564 insertions(+), 4 deletions(-) rename {src/dfannals => dfannals}/__init__.py (100%) create mode 100644 dfannals/chronicle.py create mode 100644 dfannals/cli.py rename {src/dfannals => dfannals}/config.py (93%) create mode 100644 dfannals/factcheck.py create mode 100644 dfannals/legends.py rename {src/dfannals => dfannals}/llm.py (97%) rename {src/dfannals => dfannals}/publish.py (97%) create mode 100644 dfannals/score.py create mode 100644 dfannals/slice.py create mode 100644 dfannals/timeline.py create mode 100644 output/volumes/v1/chapters/001-1-9.md create mode 100644 output/volumes/v1/figures.md create mode 100644 output/volumes/v1/timeline.md create mode 100644 pyproject.toml create mode 100644 pyrightconfig.json create mode 100644 scripts/deploy-dfhack.sh create mode 100644 scripts/df-screen.py create mode 100644 scripts/dfhack/dfannals/act.lua create mode 100644 scripts/dfhack/dfannals/click.lua create mode 100644 scripts/dfhack/dfannals/dump.lua create mode 100644 scripts/dfhack/dfannals/observe.lua create mode 100644 scripts/dfhack/dfannals/probe.lua create mode 100644 scripts/dfhack/dfannals/widgets.lua create mode 100644 scripts/install-df.sh create mode 100644 src/dfannals.egg-info/PKG-INFO create mode 100644 src/dfannals.egg-info/SOURCES.txt create mode 100644 src/dfannals.egg-info/dependency_links.txt create mode 100644 src/dfannals.egg-info/requires.txt create mode 100644 src/dfannals.egg-info/top_level.txt create mode 100644 tests/fixtures/sample-legends.xml create mode 100644 tests/fixtures/sample-legends_plus.xml create mode 100644 tests/test_factcheck.py diff --git a/src/dfannals/__init__.py b/dfannals/__init__.py similarity index 100% rename from src/dfannals/__init__.py rename to dfannals/__init__.py diff --git a/dfannals/chronicle.py b/dfannals/chronicle.py new file mode 100644 index 0000000..db9181d --- /dev/null +++ b/dfannals/chronicle.py @@ -0,0 +1,159 @@ +# pyright: reportMissingImports=false, reportAttributeAccessIssue=false +# 说明:LSP 的 Python 环境看不到本项目包(未安装进它的 site-packages), +# 会把「包内互相 import」误报为缺失模块。运行时导入已由执行验证通过。 + +"""组织 prompt、调用模型、控制篇幅、跑事实校验,产出一章成品。""" +from __future__ import annotations + +import json +import re +import unicodedata +from dataclasses import dataclass +from pathlib import Path + +from dfannals import factcheck +from dfannals import slice as chapter_slice +from dfannals.config import ( + CHAPTER_MAX_CHARS, + CHAPTER_MIN_CHARS, + DATA_DIR, + PROMPT_DIR, + Gateway, +) +from dfannals.legends import World +from dfannals.llm import Reply, chat + +STATE_FILE = DATA_DIR / "state.json" +SPEC_FILE = PROMPT_DIR / "chronicler.md" + +TAIL_CHARS = 600 # 交给下一章衔接的上一章结尾长度 +REPAIR_ATTEMPTS = 2 + + +@dataclass +class State: + world: str = "" + volume: int = 1 + done: list[str] = None # type: ignore[assignment] + + def __post_init__(self) -> None: + if self.done is None: + self.done = [] + + @classmethod + def load(cls, path: Path = STATE_FILE) -> State: + if not path.is_file(): + return cls() + try: + data = json.loads(path.read_text()) + except json.JSONDecodeError: + return cls() + return cls(world=data.get("world", ""), volume=data.get("volume", 1), done=data.get("done", [])) + + def save(self, path: Path = STATE_FILE) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"world": self.world, "volume": self.volume, "done": self.done}, + ensure_ascii=False, indent=2)) + + def mark_done(self, key: str) -> None: + if key not in self.done: + self.done.append(key) + + +def cjk_length(text: str) -> int: + """按中文习惯计字数:CJK 字符与拉丁单词都算 1。""" + without_code = re.sub(r"```.*?```", "", text, flags=re.S) + without_meta = re.sub(r"^\s*[#>|\-*].*$", "", without_code, flags=re.M) + n = 0 + for ch in without_meta: + if ( + unicodedata.east_asian_width(ch) in ("W", "F") + or ch.isalnum() + or ch in ",。!?;:、" + ): + n += 1 + return n + + +def previous_tail(chapter_dir: Path, chapters: list[chapter_slice.Chapter], key: str) -> str: + """取上一章结尾,作为本章的衔接线索。""" + idx = next((i for i, c in enumerate(chapters) if c.key == key), None) + if idx is None or idx == 0: + return "" + prev = chapters[idx - 1] + path = chapter_dir / f"{prev.key}.md" + if not path.is_file(): + return "" + text = path.read_text(encoding="utf-8").strip() + return text[-TAIL_CHARS:] + + +def build_messages(world: World, chapter: chapter_slice.Chapter, tail: str) -> list[dict]: + spec = SPEC_FILE.read_text(encoding="utf-8") + payload = chapter_slice.material(world, chapter) + user = [ + f"世界:{world.name}" + (f"({world.altname})" if world.altname else ""), + f"本书体例:第 {chapter.index} 章,覆盖 {chapter.span}", + "", + "以下是本章的史料素材(来自 legends 导出,事件名称保留游戏原文):", + "", + payload, + ] + if tail: + user += [ + "", + "### 上一章结尾(用于衔接,不要复述,只承接状态)", + "", + tail, + ] + user += [ + "", + f"现在请写出本章正文,{CHAPTER_MIN_CHARS}–{CHAPTER_MAX_CHARS} 字。", + ] + return [ + {"role": "system", "content": spec}, + {"role": "user", "content": "\n".join(user)}, + ] + + +def _repair(messages: list[dict], draft: str, length: int, gateway: Gateway | None) -> str: + target = ( + f"内容太短({length} 字),请扩写到 {CHAPTER_MIN_CHARS}–{CHAPTER_MAX_CHARS} 字,补充细节与对白,不要注水。" + if length < CHAPTER_MIN_CHARS + else f"内容太长({length} 字),请压缩到 {CHAPTER_MAX_CHARS} 字以内,删掉次要枝节,保留主干与最有趣的段落。" + ) + reply: Reply = chat( + messages + [{"role": "assistant", "content": draft}, {"role": "user", "content": target}], + gateway=gateway, + ) + return reply.content + + +def generate( + world: World, + chapter: chapter_slice.Chapter, + *, + tail: str = "", + gateway: Gateway | None = None, + max_repairs: int = REPAIR_ATTEMPTS, +) -> tuple[str, list[factcheck.Suspicion], Reply | None]: + """生成一章:调用模型 → 篇幅修正 → 事实校验 → 标注。""" + messages = build_messages(world, chapter, tail) + reply = chat(messages, gateway=gateway) + body = reply.content + + for _ in range(max_repairs): + length = cjk_length(body) + if CHAPTER_MIN_CHARS <= length <= CHAPTER_MAX_CHARS: + break + body = _repair(messages, body, length, gateway) + + suspicions = factcheck.check(body, world) + return factcheck.annotate(body, suspicions), suspicions, reply + + +def write_chapter(chapter_dir: Path, chapter: chapter_slice.Chapter, body: str) -> Path: + chapter_dir.mkdir(parents=True, exist_ok=True) + path = chapter_dir / f"{chapter.key}.md" + path.write_text(body.strip() + "\n", encoding="utf-8") + return path diff --git a/dfannals/cli.py b/dfannals/cli.py new file mode 100644 index 0000000..56b0104 --- /dev/null +++ b/dfannals/cli.py @@ -0,0 +1,181 @@ +# pyright: reportMissingImports=false, reportAttributeAccessIssue=false +# 说明:LSP 的 Python 环境看不到本项目包(未安装进它的 site-packages), +# 会把「包内互相 import」误报为缺失模块。运行时导入已由执行验证通过。 + +"""命令行入口。 + + python3 -m dfannals.cli status 查看当前进度 + python3 -m dfannals.cli index 解析 exports → 年表 + 人物索引 + python3 -m dfannals.cli plan 显示切章方案(不调用模型) + python3 -m dfannals.cli next 生成下一章 → 校验 → 推送 + python3 -m dfannals.cli run 一条命令跑完:索引 + 下一章 + 推送 +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from dfannals import chronicle, factcheck, legends, publish, timeline +from dfannals import slice as chapter_slice +from dfannals.config import EXPORT_DIR, GITEA_WEB, chapter_dir, ensure_dirs, volume_dir + + +def find_exports(export_dir: Path) -> list[Path]: + """找出 legends 导出文件。原生 legends.xml 与 DFHack 的 legends_plus.xml 都要。""" + if not export_dir.is_dir(): + return [] + files = sorted(export_dir.glob("*.xml")) + base = [f for f in files if not f.name.endswith("-legends_plus.xml") and "legends_plus" not in f.name] + plus = [f for f in files if "legends_plus" in f.name] + return base + plus + + +def load_world(export_dir: Path) -> legends.World: + paths = find_exports(export_dir) + if not paths: + raise SystemExit( + f"在 {export_dir} 里没找到 legends XML。\n" + "请先在游戏里导出(DF 的 Export XML + DFHack 的 exportlegends)," + "再把文件复制进这个目录。" + ) + return legends.load(paths) + + +def _current_volume(state: chronicle.State, world: legends.World) -> int: + """世界换了就开新卷。""" + if state.world != world.name: + if state.world: + state.volume += 1 + state.done = [] + state.world = world.name + return state.volume + + +def cmd_status(args: argparse.Namespace) -> int: + state = chronicle.State.load() + if not state.world: + print("尚未开始。先运行 index。") + return 0 + vdir = volume_dir(state.volume) + print(f"世界:{state.world}") + print(f"卷号:第 {state.volume} 卷") + print(f"已完成章节:{len(state.done)}") + for key in state.done: + print(f" · {key}") + print(f"成品目录:{vdir}") + return 0 + + +def cmd_index(args: argparse.Namespace) -> int: + ensure_dirs() + world = load_world(EXPORT_DIR) + state = chronicle.State.load() + volume = _current_volume(state, world) + chapters = chapter_slice.plan(world) + + vdir = volume_dir(volume) + timeline.write_outputs(world, vdir) + state.save() + + print(legends.describe(world)) + print() + print(chapter_slice.overview(world, chapters)) + print() + print(f"年表与人物索引已写入 {vdir}") + + if not args.no_push: + publish.ensure_repo() + head = publish.commit_and_push( + f"第 {volume} 卷索引:{world.name}({len(chapters)} 章规划)", + [vdir / "timeline.md", vdir / "figures.md"], + ) + print(f"已推送索引:{head or '(无改动)'} → {GITEA_WEB}") + return 0 + + +def cmd_plan(args: argparse.Namespace) -> int: + world = load_world(EXPORT_DIR) + chapters = chapter_slice.plan(world) + print(chapter_slice.overview(world, chapters)) + return 0 + + +def cmd_next(args: argparse.Namespace) -> int: + ensure_dirs() + world = load_world(EXPORT_DIR) + state = chronicle.State.load() + volume = _current_volume(state, world) + chapters = chapter_slice.plan(world) + cdir = chapter_dir(volume) + + todo = [c for c in chapters if c.key not in state.done] + if not todo: + print("本卷史料已全部写完。需要新世界才能开新卷。") + return 0 + + chapter = todo[0] + print(f"正在写第 {chapter.index} 章:{chapter.span}({len(chapter.events)} 条事件)") + tail = chronicle.previous_tail(cdir, chapters, chapter.key) + body, suspicions, reply = chronicle.generate(world, chapter, tail=tail) + length = chronicle.cjk_length(body) + print(f"生成完成:{length} 字" + (f" | 总 token {reply.total_tokens}" if reply else "")) + + print(factcheck.report(suspicions)) + + if args.dry_run: + print("\n--- 试运行,不写文件 ---\n") + print(body[:1200]) + return 0 + + path = chronicle.write_chapter(cdir, chapter, body) + state.mark_done(chapter.key) + state.save() + print(f"已写入 {path}") + + publish.ensure_repo() + head = publish.commit_and_push( + f"第 {volume} 卷第 {chapter.index} 章:{chapter.span}" + + (f"(专名校验可疑 {len(suspicions)} 处)" if suspicions else "") + ) + print(f"已推送:{head or '(无改动)'} → {GITEA_WEB}") + return 0 + + +def cmd_run(args: argparse.Namespace) -> int: + rc = cmd_index(args) + if rc: + return rc + return cmd_next(args) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="dfannals", description="矮人要塞编年史生成管道") + sub = parser.add_subparsers(dest="cmd", required=True) + + p = sub.add_parser("status", help="查看当前进度") + p.set_defaults(func=cmd_status) + + p = sub.add_parser("index", help="解析 exports → 年表 + 人物索引") + p.add_argument("--no-push", action="store_true", help="只写本地,不推送") + p.set_defaults(func=cmd_index) + + p = sub.add_parser("plan", help="显示切章方案") + p.set_defaults(func=cmd_plan) + + p = sub.add_parser("next", help="生成下一章") + p.add_argument("--dry-run", action="store_true", help="只生成不落盘、不推送") + p.set_defaults(func=cmd_next) + + p = sub.add_parser("run", help="一条命令:索引 + 下一章 + 推送") + p.add_argument("--no-push", action="store_true") + p.add_argument("--dry-run", action="store_true") + p.set_defaults(func=cmd_run) + + args = parser.parse_args(argv) + ensure_dirs() + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/dfannals/config.py b/dfannals/config.py similarity index 93% rename from src/dfannals/config.py rename to dfannals/config.py index 2dba36d..25ae117 100644 --- a/src/dfannals/config.py +++ b/dfannals/config.py @@ -12,13 +12,22 @@ from pathlib import Path HOME = Path.home() # ---------------------------------------------------------------- 目录 -PROJECT_DIR = Path(__file__).resolve().parents[2] # /home/chendy/df-annals +PROJECT_DIR = Path(__file__).resolve().parents[1] # /home/chendy/df-annals DATA_DIR = PROJECT_DIR / "data" # 原始数据,不入库 EXPORT_DIR = DATA_DIR / "exports" # legends 导出原始文件 OUTPUT_DIR = PROJECT_DIR / "output" # 成品,入库 CHAPTER_DIR = OUTPUT_DIR / "chapters" PROMPT_DIR = PROJECT_DIR / "prompts" + +def volume_dir(volume: int) -> Path: + """每一卷(一个世界)独立的成品目录。""" + return OUTPUT_DIR / "volumes" / f"v{volume}" + + +def chapter_dir(volume: int) -> Path: + return volume_dir(volume) / "chapters" + # ---------------------------------------------------------------- 游戏(Windows 侧) WINDOWS_USER = "23518" WIN_DF_ROOT = Path(f"/mnt/c/Users/{WINDOWS_USER}/DwarfFortress") # 下载/安装根目录 diff --git a/dfannals/factcheck.py b/dfannals/factcheck.py new file mode 100644 index 0000000..dbb7a30 --- /dev/null +++ b/dfannals/factcheck.py @@ -0,0 +1,141 @@ +# pyright: reportMissingImports=false, reportAttributeAccessIssue=false +# 说明:LSP 的 Python 环境看不到本项目包(未安装进它的 site-packages), +# 会把「包内互相 import」误报为缺失模块。运行时导入已由执行验证通过。 + +"""专名事实校验:正文里出现的专有名词必须能在史料中找到。 + +策略(按访谈决策:自动直推、不阻断,但标注可疑处): +- 抽出正文中的拉丁字母专名序列(本项目的约定是专名保留英文原文) +- 与史料已知专名集合比对 +- 未知专名既不阻断推送,也不静默放过,而是连同上下文一起标注出来 + +注意:这一步只能拦住"凭空出现的人名地名",拦不住编造的对白与心理描写—— +后者按用户选择属于允许的自由演绎范围。 +""" +from __future__ import annotations + +import re +from dataclasses import dataclass + +from dfannals.legends import World, pretty + +# 正文里允许出现、但不算专名的常见英文词(游戏术语/通用词) +COMMON_WORDS = { + "Dwarf", "Dwarves", "Dwarven", "Fortress", "Fort", "Mountain", "Mountains", "Hill", + "River", "Lake", "Forest", "Desert", "Ocean", "Cave", "Caves", "Town", "City", + "King", "Queen", "Lord", "Lady", "Captain", "General", "Soldier", "Miner", "Smith", + "Hammer", "Axe", "Sword", "Shield", "Gold", "Silver", "Iron", "Steel", "Copper", + "Bronze", "Wood", "Stone", "Water", "Beer", "Ale", "Wine", "Goblin", "Goblins", + "Elf", "Elves", "Human", "Humans", "Kobold", "Kobolds", "Troll", "Trolls", + "Beast", "Beasts", "Titan", "Titans", "Dragon", "Dragons", "Demon", "Demons", + "God", "Gods", "Temple", "Shrine", "Tomb", "Market", "Tavern", "Library", + "Legend", "Legends", "Annals", "Chronicle", "Chapter", "Year", "Years", + "North", "South", "East", "West", "Spring", "Summer", "Autumn", "Winter", + "The", "A", "An", "And", "Or", "But", "In", "On", "At", "By", "Of", "To", "For", +} + +# 拉丁专名序列:首字母大写的词,可含 ' - 与数字 +NAME_RE = re.compile(r"\b[A-Z][A-Za-z'\-]*(?:\s+[A-Z][A-Za-z'\-]*)*\b") +_CJK = re.compile(r"[\u4e00-\u9fff]") + + +@dataclass +class Suspicion: + name: str + count: int + context: str + + +def known_names(world: World) -> set[str]: + """史料中出现过的全部专名,含单词与整名两种粒度。""" + names: set[str] = set() + + def add(value: str) -> None: + value = (value or "").strip() + if not value: + return + names.add(value) + # 同时收录展示形式:正文里专名会做首字母大写,两边必须一致 + shown = pretty(value) + names.add(shown) + for token in re.split(r"[\s,]+", shown): + token = token.strip("'\"()[]") + if token and token[0].isupper(): + names.add(token) + # 原样小写形式也保留,避免漏报 + names.add(value.lower()) + + # 世界名必须拆词:否则 "The World of Prophecy" 里的 Prophecy 会被当成凭空编造 + add(world.name) + add(world.altname) + + for fig in world.figures.values(): + add(fig.name) + add(fig.race) + add(fig.profession) + for ent in world.entities.values(): + add(ent.name) + add(ent.type) + add(ent.race) + for site in world.sites.values(): + add(site.name) + add(site.type) + return names + + +def _is_candidate(name: str, known: set[str]) -> bool: + if name in known: + return False + tokens = name.split() + # 全部由常见词组成的串不是专名 + if all(t in COMMON_WORDS for t in tokens): + return False + # 全大写的是史料里的枚举值(GOBLIN、MINER、DARK_FORTRESS),不是人名地名 + return not all(len(t) >= 2 and t.isupper() for t in tokens) + + +def check(text: str, world: World) -> list[Suspicion]: + """返回可疑专名列表(按出现次数降序)。""" + known = known_names(world) + found: dict[str, list[str]] = {} + + for match in NAME_RE.finditer(text): + raw = match.group(0) + # 逐级回退:整串不认,就试着拆成更短的已知名字,减少误报 + if not _is_candidate(raw, known): + continue + if any(part in known for part in raw.split()): + # 名字里有一部分是史料已知的(例如 "Urist the Bold"),不当作凭空编造 + continue + start = max(0, match.start() - 20) + end = min(len(text), match.end() + 20) + found.setdefault(raw, []).append(text[start:end].replace("\n", " ")) + + return [ + Suspicion(name=n, count=len(ctxs), context=ctxs[0]) + for n, ctxs in sorted(found.items(), key=lambda kv: (-len(kv[1]), kv[0])) + ] + + +def report(suspicions: list[Suspicion], top: int = 12) -> str: + if not suspicions: + return "专名校验:未发现史料之外的专有名词。" + lines = [f"专名校验:发现 {len(suspicions)} 个可疑专名(不阻断推送,仅标注)"] + for s in suspicions[:top]: + lines.append(f" · {s.name} ×{s.count} —— 上下文:…{s.context}…") + if len(suspicions) > top: + lines.append(f" (其余 {len(suspicions) - top} 个见校验报告文件)") + return "\n".join(lines) + + +def annotate(chapter_md: str, suspicions: list[Suspicion]) -> str: + """在章节末尾追加可见的校验提示。""" + if not suspicions: + return chapter_md + names = "、".join(f"{s.name}×{s.count}" for s in suspicions[:10]) + block = ( + "\n\n---\n\n" + f"> ⚠️ **史料核验提示**:以下专名未在 legends 史料中找到,可能是演绎或错误:{names}。\n" + "> 其余对白与心理描写属于允许的文学演绎,不在校验范围内。\n" + ) + return chapter_md.rstrip() + block diff --git a/dfannals/legends.py b/dfannals/legends.py new file mode 100644 index 0000000..117ce0c --- /dev/null +++ b/dfannals/legends.py @@ -0,0 +1,347 @@ +"""解析矮人要塞的 legends 导出。 + +数据来源有两份,按 id 合并: +1. ``legends.xml`` —— DF 原生 "Export XML" 产物:名字、年份、事件主体 +2. ``legends_plus.xml`` —— DFHack ``exportlegends`` 产物:补充字段(sex/race 等) + +因为大世界的导出可达数十 MB,这里用 iterparse 流式解析。 +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from defusedxml.ElementTree import iterparse + +# 事件里指向其他实体的字段 → 指向哪类对象 +ID_FIELDS: dict[str, str] = { + "hfid": "figure", + "hist_figure_id": "figure", + "slayer_hfid": "figure", + "slayer_item_id": "artifact", + "target_hfid": "figure", + "source_hfid": "figure", + "site_id": "site", + "site_civ_id": "entity", + "entity_id": "entity", + "attacker_civ_id": "entity", + "defender_civ_id": "entity", + "artifact_id": "artifact", + "region_id": "region", + "feature_layer_id": "region", + "deity": "figure", + "worshipper_hfid": "figure", + "creature_id": "creature", +} + + +@dataclass +class Figure: + id: int + name: str = "" + race: str = "" + sex: int | None = None + birth_year: int | None = None + death_year: int | None = None + profession: str = "" + entities: list[int] = field(default_factory=list) + sites: list[int] = field(default_factory=list) + + @property + def alive_span(self) -> str: + # DF 用负数占位表示“无此项”(未记录/未死),不要当成真实年份显示 + birth = self.birth_year if (self.birth_year or 0) >= 0 else None + death = self.death_year if (self.death_year or 0) >= 0 else None + if birth is None and death is None: + return "生卒不详" + b = "?" if birth is None else str(birth) + d = "在世/不详" if death is None else str(death) + return f"{b}–{d}" + + +@dataclass +class Event: + id: int + year: int + seconds72: int = 0 + type: str = "" + fields: dict[str, list[str]] = field(default_factory=dict) + + def ids(self, tag: str) -> list[int]: + out: list[int] = [] + for raw in self.fields.get(tag, []): + try: + out.append(int(raw)) + except ValueError: + continue + return out + + def text(self, tag: str) -> str: + vals = self.fields.get(tag) + return vals[0] if vals else "" + + +@dataclass +class Entity: + id: int + name: str = "" + type: str = "" + race: str = "" + + +@dataclass +class Site: + id: int + name: str = "" + type: str = "" + + +# ------------------------------------------------------------------ 展示用工具 +def pretty(name: str) -> str: + """展示用:DF 的程序化名字全小写,正文里按首字母大写会更像专名。 + + 只影响渲染,不改变史料本身。 + """ + if not name: + return name + return " ".join(part[:1].upper() + part[1:] for part in name.split()) + + +@dataclass +class World: + name: str = "" + altname: str = "" + figures: dict[int, Figure] = field(default_factory=dict) + events: list[Event] = field(default_factory=list) + entities: dict[int, Entity] = field(default_factory=dict) + sites: dict[int, Site] = field(default_factory=dict) + sources: list[str] = field(default_factory=list) + + # ---------------------------------------------------------- 查询 + # 约定:id 为负表示“无此项”(DF 用 -1 占位),返回空串让调用方跳过。 + def figure_name(self, fid: int) -> str: + if fid < 0: + return "" + fig = self.figures.get(fid) + return pretty(fig.name) if fig and fig.name else f"HF#{fid}" + + def entity_name(self, eid: int) -> str: + if eid < 0: + return "" + ent = self.entities.get(eid) + return pretty(ent.name) if ent and ent.name else f"Entity#{eid}" + + def site_name(self, sid: int) -> str: + if sid < 0: + return "" + st = self.sites.get(sid) + return pretty(st.name) if st and st.name else f"Site#{sid}" + + def event_years(self) -> tuple[int, int]: + years = [e.year for e in self.events if e.year] + return (min(years), max(years)) if years else (0, 0) + + +# ------------------------------------------------------------------ 解析工具 +def _int_or_none(text: str | None) -> int | None: + if text is None: + return None + try: + return int(text) + except ValueError: + return None + + +def _children(elem: Any) -> dict[str, list[str]]: + """把元素的子标签收成 {tag: [文本, ...]}。""" + out: dict[str, list[str]] = {} + for child in elem: + val = (child.text or "").strip() + out.setdefault(child.tag, []).append(val) + return out + + +def _first(d: dict[str, list[str]], *keys: str) -> str: + for k in keys: + if d.get(k): + return d[k][0] + return "" + + +# XML 1.0 不允许的控制字符(保留 \t \n \r) +ILLEGAL_BYTES = bytes(range(0x09)) + b"\x0b\x0c" + bytes(range(0x0e, 0x20)) +CLEAN_CACHE = Path("data/exports/clean") + + +def sanitize(src: Path, dst: Path, chunk_size: int = 8 << 20) -> Path: + """把 DF 导出的 legends XML 转成合规的 UTF-8 文件。 + + DF 会在名字里嵌入 \\x10/\\x11 等 CP437 控制字节做专名标记, + XML 1.0 禁止这些字节,严格解析器会直接报错。 + 这里做两件事:剔除非法控制字节;把 CP437 声明与实际字节统一成 UTF-8。 + cp437 是单字节编码,因此可以按块转换而不会切断字符。 + """ + dst.parent.mkdir(parents=True, exist_ok=True) + with src.open("rb") as fin, dst.open("wb") as fout: + first = True + while True: + buf = fin.read(chunk_size) + if not buf: + break + buf = buf.translate(None, ILLEGAL_BYTES) + if first: + buf = buf.replace(b"encoding='CP437'", b"encoding='UTF-8'", 1) + buf = buf.replace(b'encoding="CP437"', b'encoding="UTF-8"', 1) + first = False + fout.write(buf.decode("cp437").encode("utf-8")) + return dst + + +def clean_copy(src: Path) -> Path: + """返回可解析的干净副本,带缓存(源文件更新则重建)。 + + 缓存写到 data/exports/clean/ 子目录,避开 find_exports 的 *.xml 扫描, + 否则干净副本会被当作第二份史料,导致事件数翻倍。 + """ + dst = CLEAN_CACHE / src.name + if dst.is_file() and dst.stat().st_mtime >= src.stat().st_mtime and dst.stat().st_size > 0: + return dst + return sanitize(src, dst) + + +def _reject_unsafe_xml(path: Path) -> None: + """拒绝带 DTD / 实体声明的文件。 + + DOCTYPE 必须出现在根元素之前,因此只检查文件头部即可拦住 + XXE 与 billion-laughs 这两类实体展开攻击。 + """ + with path.open("rb") as fh: + head = fh.read(65536) + for marker in (b" None: + """流式解析一个 legends XML,合并进 world。 + + 用元素深度而不是包裹标签的进出状态来控制语义: + 只有 depth == 2(即 的直接子元素)才会被当作世界级字段, + 这样历史人物、地区里的 永远不会污染世界名。 + """ + _reject_unsafe_xml(path) + world.sources.append(str(path)) + depth = 0 + parser = iterparse(str(path), events=("start", "end")) + for event, elem in parser: + if event == "start": + depth += 1 + continue + + tag = elem.tag + if depth == 2: + if tag == "name": + world.name = world.name or (elem.text or "").strip() + elif tag == "altname": + world.altname = world.altname or (elem.text or "").strip() + elif tag == "historical_figure": + _add_figure(world, elem) + elem.clear() + elif tag == "historical_event": + _add_event(world, elem) + elem.clear() + elif tag == "entity": + _add_entity(world, elem) + elem.clear() + elif tag == "site": + _add_site(world, elem) + elem.clear() + + depth -= 1 + + +def _add_figure(world: World, elem) -> None: + d = _children(elem) + fid = _int_or_none(_first(d, "id")) + if fid is None: + return + fig = world.figures.setdefault(fid, Figure(id=fid)) + fig.name = fig.name or _first(d, "name") + fig.race = fig.race or _first(d, "race") + fig.profession = fig.profession or _first(d, "profession") + if fig.sex is None: + fig.sex = _int_or_none(_first(d, "sex")) + if fig.birth_year is None: + fig.birth_year = _int_or_none(_first(d, "birth_year")) + if fig.death_year is None: + fig.death_year = _int_or_none(_first(d, "death_year")) + + +def _add_event(world: World, elem) -> None: + d = _children(elem) + eid = _int_or_none(_first(d, "id")) + year = _int_or_none(_first(d, "year")) + if eid is None or year is None: + return + world.events.append( + Event( + id=eid, + year=year, + seconds72=_int_or_none(_first(d, "seconds72")) or 0, + type=_first(d, "type"), + fields=d, + ) + ) + + +def _add_entity(world: World, elem) -> None: + d = _children(elem) + eid = _int_or_none(_first(d, "id")) + if eid is None: + return + ent = world.entities.setdefault(eid, Entity(id=eid)) + ent.name = ent.name or _first(d, "name") + ent.type = ent.type or _first(d, "type") + ent.race = ent.race or _first(d, "race") + + +def _add_site(world: World, elem) -> None: + d = _children(elem) + sid = _int_or_none(_first(d, "id")) + if sid is None: + return + st = world.sites.setdefault(sid, Site(id=sid)) + st.name = st.name or _first(d, "name") + st.type = st.type or _first(d, "type") + + +def load(paths: list[Path]) -> World: + world = World() + for p in paths: + if not p.is_file(): + raise FileNotFoundError(f"缺少 legends 导出文件: {p}") + if p.stat().st_size == 0: + raise ValueError(f"legends 导出文件为空: {p}") + parse_file(clean_copy(p), world) + world.events.sort(key=lambda e: (e.year, e.seconds72, e.id)) + return world + + +def describe(world: World, top: int = 8) -> str: + lo, hi = world.event_years() + by_type: dict[str, int] = {} + for e in world.events: + by_type[e.type] = by_type.get(e.type, 0) + 1 + hot = sorted(by_type.items(), key=lambda kv: -kv[1])[:top] + lines = [ + f"世界: {world.name}" + (f"({world.altname})" if world.altname else ""), + f"历史跨度: {lo}–{hi}({hi - lo} 年)| 事件 {len(world.events)} 条", + f"历史人物 {len(world.figures)} | 文明/组织 {len(world.entities)} | 地点 {len(world.sites)}", + "高频事件类型: " + ", ".join(f"{t}×{n}" for t, n in hot), + f"来源: {', '.join(Path(s).name for s in world.sources)}", + ] + return "\n".join(lines) diff --git a/src/dfannals/llm.py b/dfannals/llm.py similarity index 97% rename from src/dfannals/llm.py rename to dfannals/llm.py index baf7195..a921f81 100644 --- a/src/dfannals/llm.py +++ b/dfannals/llm.py @@ -13,7 +13,7 @@ import time from dataclasses import dataclass from urllib.parse import urlsplit -from .config import MAX_TOKENS_CHAPTER, MODEL, Gateway, load_gateway +from dfannals.config import MAX_TOKENS_CHAPTER, MODEL, Gateway, load_gateway @dataclass diff --git a/src/dfannals/publish.py b/dfannals/publish.py similarity index 97% rename from src/dfannals/publish.py rename to dfannals/publish.py index 0710594..3383c64 100644 --- a/src/dfannals/publish.py +++ b/dfannals/publish.py @@ -9,7 +9,7 @@ from __future__ import annotations import subprocess from pathlib import Path -from .config import GITEA_REMOTE, PROJECT_DIR, SSH_KEY +from dfannals.config import GITEA_REMOTE, PROJECT_DIR, SSH_KEY GITIGNORE = """\ # 原始数据与存档:不入库 diff --git a/dfannals/score.py b/dfannals/score.py new file mode 100644 index 0000000..e335dfa --- /dev/null +++ b/dfannals/score.py @@ -0,0 +1,140 @@ +"""事件显著度评分与小人物过滤。 + +真实数据给的教训:40 万条事件里绝大多数是流水账 +(change hf state 7.3 万、change hf job 5.8 万、written content composed 3.9 万), +直接按类型切章会得到 5910 章,毫无叙事价值。 + +因此策略是: +1. 只保留天然有叙事张力的事件类型(攻城、灭国、神器、死亡、结盟……) +2. 再按"参与者有多重要"做二次排序,让编年史跟着大人物走 +""" +from __future__ import annotations + +from collections import Counter + +from dfannals.legends import Event, World + +# 事件类型 → 基础分。0 分表示不单独叙事(流水账) +NARRATIVE: dict[str, int] = { + # ---- 天崩地裂级 + "destroyed site": 100, + "razed structure": 100, + "entity overthrown": 100, + "hf destroyed site": 95, + "plundered site": 95, + "field battle": 95, + "entity dissolved": 90, + "holy city declaration": 90, + "site taken over": 90, + "entity breach feature layer": 90, + "entity alliance formed": 85, + "peace accepted": 85, + "hf enslaved": 85, + "hf revived": 80, + "entity relocate": 80, + "hf ransomed": 80, + "peace rejected": 78, + # ---- 重大 + "created site": 75, + "reclaim site": 72, + "attacked site": 70, + "hf attacked site": 70, + "entity created": 70, + "artifact destroyed": 70, + "new site leader": 65, + "created world construction": 60, + "entity persecuted": 60, + "hf convicted": 60, + "site dispute": 55, + "failed intrigue corruption": 55, + "artifact recovered": 60, + "artifact possessed": 60, + # ---- 值得一记 + "artifact created": 45, + "artifact lost": 45, + "creature devoured": 42, + "hf abducted": 40, + "assume identity": 40, + "knowledge discovered": 38, + "artifact given": 36, + "agreement formed": 36, + "hf gains secret goal": 36, + "item stolen": 34, + "hf performed horrible experiments": 45, + "failed frame attempt": 40, + "hf profaned structure": 38, + "changed creature type": 36, + "hf interrogated": 34, + "created structure": 30, + "hf wounded": 30, + "hf learns secret": 34, + "changed hf body state": 30, +} + +# 死亡单独处理:分数取决于死者本身有多重要 +DEATH_TYPES = ("hf died",) + +CATEGORIES: list[tuple[tuple[str, ...], str]] = [ + (("war", "battle", "siege", "raid", "attack", "plunder", "destroy", "sack", "taken over"), "战事"), + (("died", "death", "slain", "murder", "assassin", "enslaved", "ransomed", "revived"), "生死"), + (("created site", "reclaim", "relocate", "dissolved", "overthrown", "alliance", "entity created"), "兴亡"), + (("artifact", "masterpiece", "construction", "structure", "razed"), "器物"), + (("peace", "agreement", "tribute", "persecuted", "convicted", "dispute", "intrigue"), "政争"), + (("knowledge", "secret", "abducted", "identity", "experiment", "profaned", "devoured"), "异闻"), +] + + +def category(event: Event) -> str: + t = event.type.lower() + for keys, label in CATEGORIES: + if any(k in t for k in keys): + return label + return "杂记" + + +def prominence(world: World) -> Counter[int]: + """每个人物被卷入的事件次数——用来衡量他在史料里有多重要。""" + counts: Counter[int] = Counter() + for e in world.events: + seen: set[int] = set() + for tag in ("hfid", "slayer_hfid", "target_hfid", "source_hfid", "deity", "worshipper_hfid"): + for fid in e.ids(tag): + seen.add(fid) + counts.update(seen) + return counts + + +def _participants(event: Event) -> list[int]: + out: list[int] = [] + for tag in ("hfid", "slayer_hfid", "target_hfid", "source_hfid", "deity"): + out.extend(event.ids(tag)) + return out + + +# 人物重要度阈值:参与事件数达到这个量级才算"要角" +PROMINENT = 60 + + +def significance(event: Event, prom: Counter[int] | None = None, prominent: int = PROMINENT) -> int: + """事件的故事价值。0 表示不值得单独写。""" + t = event.type.lower() + base = NARRATIVE.get(t, 0) + people = _participants(event) + top = max((prom.get(p, 0) for p in people), default=0) if prom else 0 + + if t in DEATH_TYPES: + # 无名死者不写;要角之死按重要度加分 + if top < prominent: + return 0 + return 55 + min(45, top // 10) + + if base == 0: + return 0 + + # 参与者越重要,越值得写;但不要盖过类型本身的分量 + bonus = min(25, top // 25) if prom else 0 + return base + bonus + + +def is_narrative(event: Event, prom: Counter[int] | None = None) -> bool: + return significance(event, prom) > 0 diff --git a/dfannals/slice.py b/dfannals/slice.py new file mode 100644 index 0000000..1824ca9 --- /dev/null +++ b/dfannals/slice.py @@ -0,0 +1,195 @@ +"""把史料切成章节素材。 + +真实数据(403853 条事件 / 250 年)证明:按"重要事件数量"切章会得到几千章。 +所以改为先按时间窗分桶(每窗 10 年),再在窗内按显著度取前 N 条—— +这样一章天然覆盖一段年月,既有大事件链,也不会漏掉时代的推进。 +""" +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass, field + +from dfannals import legends as lg +from dfannals import score +from dfannals.legends import Event, World + +YEARS_PER_CHAPTER = 10 # 一个时间窗覆盖的年数 +EVENTS_PER_CHAPTER = 12 # 每章最多收录的史料条目 +MIN_EVENTS_PER_CHAPTER = 3 # 少于这个数量就并入上一章(太平年月) +MAX_PER_CATEGORY = 4 # 同一类事件每章上限,避免整章都是讣告 + + +@dataclass +class Chapter: + index: int + start_year: int + end_year: int + events: list[Event] = field(default_factory=list) + quiet_years: list[tuple[int, int]] = field(default_factory=list) + + @property + def key(self) -> str: + return f"{self.index:03d}-{self.start_year}-{self.end_year}" + + @property + def span(self) -> str: + if self.start_year == self.end_year: + return f"{self.start_year} 年" + return f"{self.start_year}–{self.end_year} 年" + + def categories(self) -> list[str]: + seen: list[str] = [] + for e in self.events: + c = score.category(e) + if c not in seen: + seen.append(c) + return seen + + +def plan( + world: World, + years_per_chapter: int = YEARS_PER_CHAPTER, + per_chapter: int = EVENTS_PER_CHAPTER, + min_events: int = MIN_EVENTS_PER_CHAPTER, + max_per_category: int = MAX_PER_CATEGORY, +) -> list[Chapter]: + prom = score.prominence(world) + scored = [(score.significance(e, prom), e) for e in world.events] + scored = [pair for pair in scored if pair[0] > 0] + if not scored: + lo, hi = world.event_years() + return [Chapter(index=1, start_year=lo, end_year=hi)] + + lo = min(e.year for _, e in scored) + hi = max(e.year for _, e in scored) + + buckets: dict[int, list[tuple[int, Event]]] = defaultdict(list) + for sig, e in scored: + buckets[e.year // years_per_chapter].append((sig, e)) + + chapters: list[Chapter] = [] + for b in range(lo // years_per_chapter, hi // years_per_chapter + 1): + window_start = max(b * years_per_chapter, lo) + window_end = min(b * years_per_chapter + years_per_chapter - 1, hi) + ranked = sorted(buckets.get(b, []), key=lambda p: (-p[0], p[1].year, p[1].id)) + # 按类别限额挑选:让一章里有战事、兴亡、器物,而不是 12 条死亡讣告 + picks: list[tuple[int, Event]] = [] + used: dict[str, int] = {} + for sig, ev in ranked: + cat = score.category(ev) + if used.get(cat, 0) >= max_per_category: + continue + used[cat] = used.get(cat, 0) + 1 + picks.append((sig, ev)) + if len(picks) >= per_chapter: + break + events = sorted((e for _, e in picks), key=lambda e: (e.year, e.seconds72, e.id)) + + if not events: + if chapters: + chapters[-1].quiet_years.append((window_start, window_end)) + chapters[-1].end_year = window_end + continue + + if len(events) < min_events and chapters: + chapters[-1].events.extend(events) + chapters[-1].end_year = window_end + continue + + chapters.append( + Chapter(index=len(chapters) + 1, start_year=window_start, end_year=window_end, events=events) + ) + + for i, c in enumerate(chapters, 1): + c.index = i + return chapters + + +def _figure_line(world: World, fid: int, prom) -> str: + fig = world.figures.get(fid) + if not fig: + return f"- HF#{fid}(史料无记载)" + bits = [lg.pretty(fig.name) or f"HF#{fid}", fig.race or "未知种族", fig.alive_span] + if fig.profession: + bits.append(fig.profession) + bits.append(f"史料出现 {prom.get(fid, 0)} 次") + return "- " + " | ".join(bits) + + +def material(world: World, chapter: Chapter, max_figures: int = 40) -> str: + """把一章的史料渲染成给模型看的素材文本。""" + prom = score.prominence(world) + lines: list[str] = [ + f"## 本章范围:{chapter.span}", + "", + f"共 {len(chapter.events)} 条史料,类型:{('、'.join(chapter.categories())) or '无'}。", + "", + "### 事件流水(按时间排序,专名保留游戏原文)", + "", + ] + + participation: dict[int, int] = {} + for e in chapter.events: + chunks = [f"{e.year}年", e.type] + for tag, kind in lg.ID_FIELDS.items(): + for i in e.ids(tag): + # id 为负是“无此项”,不要渲染成 HF#-1 这类噪音 + if kind == "figure": + name = world.figure_name(i) + elif kind == "site": + name = world.site_name(i) + elif kind == "entity": + name = world.entity_name(i) + else: + name = "" + if not name: + continue + chunks.append(f"{tag}={name}") + if kind == "figure": + participation[i] = participation.get(i, 0) + 1 + extra = [] + for field_name in ("state", "reason", "circumstance"): + v = e.text(field_name) + if v and v not in ("-1", ""): + extra.append(f"{field_name}={v}") + if extra: + chunks.append("·".join(extra)) + lines.append("- " + " · ".join(chunks)) + + if chapter.quiet_years: + spans = "、".join(f"{a}–{b} 年" for a, b in chapter.quiet_years) + lines += [ + "", + "### 太平年月(史料无值得立传的事件)", + "", + f"- {spans}:这段时期只留下琐碎记录,可一笔带过。", + ] + + lines += ["", "### 本章登场人物(史料中的身份信息)", ""] + for fid, _ in sorted(participation.items(), key=lambda kv: -prom.get(kv[0], 0))[:max_figures]: + lines.append(_figure_line(world, fid, prom)) + + sites = sorted({n for e in chapter.events for i in e.ids("site_id") if (n := world.site_name(i))}) + if sites: + lines += ["", "### 相关地点", "", "- " + "、".join(sites)] + + ents = sorted({n for e in chapter.events for i in e.ids("entity_id") if (n := world.entity_name(i))}) + if ents: + lines += ["", "### 相关势力", "", "- " + "、".join(ents)] + + return "\n".join(lines) + + +def overview(world: World, chapters: list[Chapter], limit: int = 10) -> str: + lo, hi = world.event_years() + lines = [ + f"世界:{world.name}" + (f"({world.altname})" if world.altname else ""), + f"历史:{lo}–{hi} 年 | 史料事件 {len(world.events)} 条 | " + f"人物 {len(world.figures)} | 势力 {len(world.entities)} | 地点 {len(world.sites)}", + f"规划章节:{len(chapters)} 章(每章约 {YEARS_PER_CHAPTER} 年 / 最多 {EVENTS_PER_CHAPTER} 条史料)", + ] + for c in chapters[:limit]: + lines.append(f" 第 {c.index} 章 {c.span}:{len(c.events)} 条({('、'.join(c.categories())) or '无'})") + if len(chapters) > limit: + lines.append(f" …… 其余 {len(chapters) - limit} 章略") + return "\n".join(lines) diff --git a/dfannals/timeline.py b/dfannals/timeline.py new file mode 100644 index 0000000..d0e4c1c --- /dev/null +++ b/dfannals/timeline.py @@ -0,0 +1,108 @@ +# pyright: reportMissingImports=false, reportAttributeAccessIssue=false +# 说明:LSP 的 Python 环境看不到本项目包(未安装进它的 site-packages), +# 会把「包内互相 import」误报为缺失模块。运行时导入已由执行验证通过。 + +"""生成迷你年表与人物索引(随成品入库的两种辅助文件)。""" +from __future__ import annotations + +from collections import Counter +from pathlib import Path + +from dfannals import legends as lg +from dfannals import score +from dfannals.legends import Event, World + +# 评分标准统一在 dfannals/score.py,年表与切章共用同一套。 +significance = score.significance +category = score.category + + +def event_line(world: World, e: Event) -> str: + """把一条事件压成一行可读文字。""" + chunks: list[str] = [e.type] + for tag, kind in lg.ID_FIELDS.items(): + for i in e.ids(tag): + if kind == "figure": + name = world.figure_name(i) + elif kind == "site": + name = world.site_name(i) + elif kind == "entity": + name = world.entity_name(i) + else: + name = "" + if name: + chunks.append(f"{tag}={name}") + return f"- **{e.year}** [{category(e)}] " + " · ".join(chunks) + + +def build_timeline(world: World, min_score: int = 70, per_decade: int = 14) -> str: + """按十年分组的重要事件年表。""" + prom = score.prominence(world) + lo, hi = world.event_years() + important = [e for e in world.events if significance(e, prom) >= min_score] + buckets: dict[int, list[Event]] = {} + for e in important: + buckets.setdefault((e.year // 10) * 10, []).append(e) + + lines = [ + f"# {world.name} · 年表", + "", + f"- 历史跨度:{lo}–{hi} 年" if hi else "- 历史跨度:未知", + f"- 事件总数:{len(world.events)},其中重要事件 {len(important)}", + f"- 历史人物 {len(world.figures)} 位 · 文明与组织 {len(world.entities)} 个 · 地点 {len(world.sites)} 处", + "", + "> 本表由 legends 导出数据自动生成,只收录叙事权重较高的事件;" + "完整数据留在本地,不入库。", + "", + ] + for decade in sorted(buckets): + evs = sorted(buckets[decade], key=lambda e: (e.year, e.seconds72, e.id)) + shown = sorted(evs, key=lambda e: -significance(e, prom))[:per_decade] + shown.sort(key=lambda e: (e.year, e.seconds72, e.id)) + lines.append(f"## {decade}s") + lines.append("") + lines.extend(event_line(world, e) for e in shown) + if len(evs) > len(shown): + lines.append(f"- …另有 {len(evs) - len(shown)} 条次要事件未列") + lines.append("") + return "\n".join(lines) + + +def build_figures(world: World, top: int = 120) -> str: + """人物索引:按参与事件数排序,给出身份与生卒。""" + involvement: Counter[int] = Counter() + for e in world.events: + seen: set[int] = set() + for tag, kind in lg.ID_FIELDS.items(): + if kind != "figure": + continue + for i in e.ids(tag): + seen.add(i) + involvement.update(seen) + + lines = [ + f"# {world.name} · 人物索引", + "", + f"共 {len(world.figures)} 位历史人物,按在史料中出现的次数排序(前 {top} 位)。", + "", + "| 人物 | 种族 | 生卒 | 职业 | 事件数 |", + "|---|---|---|---|---|", + ] + ranked = sorted(world.figures.values(), key=lambda f: -involvement.get(f.id, 0)) + for fig in ranked[:top]: + name = lg.pretty(fig.name) or f"HF#{fig.id}" + prof = fig.profession or "—" + lines.append( + f"| {name} | {fig.race or '—'} | {fig.alive_span} | {prof} | {involvement.get(fig.id, 0)} |" + ) + lines.append("") + return "\n".join(lines) + + +def write_outputs(world: World, out_dir: Path, min_score: int = 40) -> list[Path]: + out_dir.mkdir(parents=True, exist_ok=True) + timeline = out_dir / "timeline.md" + figures = out_dir / "figures.md" + timeline.write_text(build_timeline(world, min_score=min_score), encoding="utf-8") + figures.write_text(build_figures(world), encoding="utf-8") + return [timeline, figures] diff --git a/output/volumes/v1/chapters/001-1-9.md b/output/volumes/v1/chapters/001-1-9.md new file mode 100644 index 0000000..fa89d28 --- /dev/null +++ b/output/volumes/v1/chapters/001-1-9.md @@ -0,0 +1,26 @@ +# 第一章:黎明之地的第一场火 + +我翻开 Mon Sagus 的档案第一卷,纸边还卷着霉味。The Plane of Dawn——他们管它叫黎明之地。可黎明前最黑的那一会儿,先来的是刀。 + +元年,Nutssound 这个名字第一次出现,就带了三件事:Yemi Deermoths 死了,Thelire Echomothers 死了,The Tired Growls 的聚落被 The Washed Terrors 洗劫。顺序是死亡在前,劫掠在后?还是劫掠在前,死亡在后?档案只给了年份,没给月日。我只知道这两个精灵死在 Nutssound,别的什么都没留下。两个名字被涂在死亡册上,旁边没有生平。 + +The Washed Terrors 是那场火的主凶。他们冲进 Nutssound,把 The Tired Growls 的仓库翻了个底朝天,又顺手在城外跟守军打了一场野战。那场野战被单独记了一笔:attacker 是 The Washed Terrors,defender 是 The Tired Growls。听上去像是劫掠之后,守军追出来,又被打了一顿。我不在场,但想想也不会太体面。 + +同一年,The Faithful Scourge 和 The Lovely Avalanche 也打了一场。这场仗跟 Nutssound 没什么关系,只是同一卷里夹着,像隔着河看对岸烧房子。两边都没占到便宜,否则后来也不会急吼吼地签和约。 + +真正让我背脊发凉的是 Robustbolted。这条记录只有一句:某个历史人物摧毁了 Robustbolted。没留下名字,只挂着 The Sunken Corridor 和 The Stake Of Cities 两个名号,一个像是地契上的所属,一个像是城头上的守军。行凶者是谁?档案里是一片空白。史官只记结果,不记凶手。我翻遍了卷宗,Robustbolted 的毁灭者像水渗进沙里,一点痕迹都没有。 + +第二年,Nutssound 又添了两个名字:Vema Seedanguished 和 Masami Nightbeak。两位精灵,同样死在 Nutssound。我猜他们不是第一年那场劫掠的幸存者,就是后来被派去善后的人。The Washed Terrors 或许没走远,或许是别的势力趁火打劫。档案没写他们怎么死的,只写他们死在那。Nutssound 这四个精灵——Yemi、Thelire、Vema、Masami——像钉在柱子上的四根钉子,把这座聚落的两年钉死在流血里。 + +然后,突然就安静了。 + +第三年,和约。第三年,一个实体联盟正式缔结。第三年,又一份和约。第四年,再一份和约。三年之内签了三次和约,还结成联盟。我怀疑那些使节是不是在赶场子,从这张谈判桌跑到那张谈判桌,墨迹还没干就互相握手。战争打到第三年,各方都觉得该歇了,于是歇了。 + +只是 The Tired Growls 那边未必咽得下这口气:Nutssound 被血洗,四个精灵死在自家地盘,转头跟 The Washed Terrors 坐进同一间屋子签和约。这和平来得太便宜了。档案里没有 The Tired Growls 的抗议记录,也没有复仇的记载。但他们没死光,没死光就有账可算。 + +Robustbolted 的废墟还摊在那儿,凶手无名。The Stake Of Cities 没等到说法,The Sunken Corridor 也没再露头。两个名字挂在同一页上,像隔着废墟互相瞪眼。这事儿像个没盖的井口,谁路过都得往里看一眼。 + +我把这卷合上,封皮写着“1–9年”。九年的档案,前两年血流成河,后七年一纸和平。黎明之地,哼。 + +--- +*史料来源:legends 导出,1–2年 Nutssound 四位历史人物死亡记录,1年 Nutssound 被劫掠及两场野战,1年 Robustbolted 被毁,3–4年三次和约与一次联盟建立。* diff --git a/output/volumes/v1/figures.md b/output/volumes/v1/figures.md new file mode 100644 index 0000000..15a92a1 --- /dev/null +++ b/output/volumes/v1/figures.md @@ -0,0 +1,126 @@ +# Mon Sagus · 人物索引 + +共 41924 位历史人物,按在史料中出现的次数排序(前 120 位)。 + +| 人物 | 种族 | 生卒 | 职业 | 事件数 | +|---|---|---|---|---| +| Stobsa Boilpukes The Most | HYDRA | 生卒不详 | — | 191 | +| Raz Scorchedlanced The Hardy | COLOSSUS_BRONZE | 生卒不详 | — | 171 | +| Langgud Dawncats The Washed Seed | TITAN_3 | ?–204 | — | 168 | +| Zogast Tufttop The Decisive Key | HYDRA | 生卒不详 | — | 161 | +| Inefa Dawnorange The Snarling Puke | TITAN_7 | 生卒不详 | — | 145 | +| Gaspa Begunhog The Clean | HYDRA | 88–在世/不详 | — | 144 | +| Oka Tinglistened The Howl Of Prestige | CYCLOPS | 生卒不详 | — | 127 | +| Od Mucusrock The Gleams Of Grease | HYDRA | 生卒不详 | — | 122 | +| Ngumost Apesstrap The Mountainous | BIRD_ROC | 生卒不详 | — | 121 | +| Yare Sistersubmerge | ELF | 4–在世/不详 | — | 115 | +| Otust Wondereddriven The Beloved | ETTIN | 生卒不详 | — | 113 | +| Ostzu Granitewanders The Flicker Of Intensity | CYCLOPS | 生卒不详 | — | 106 | +| Are Flaxrose The Bearded | TITAN_2 | 生卒不详 | — | 103 | +| Quathari Holyglen | ELF | 0–在世/不详 | — | 98 | +| Urer Sunsshore The Hairy | TITAN_9 | 生卒不详 | — | 97 | +| Irene Ramlarge The Strong | BIRD_ROC | 生卒不详 | — | 95 | +| Thaci Authoredferns | ELF | 77–在世/不详 | — | 87 | +| Lapama Bittentwig | ELF | 生卒不详 | — | 87 | +| Asada Darkbalances The Ordered Sewer | YETI | ?–224 | — | 86 | +| Kifino Latewings | ELF | 79–在世/不详 | — | 84 | +| Shigos Saffronjumps The Teal Berries | TITAN_6 | 生卒不详 | — | 83 | +| Avafi Rootskull | ELF | 10–在世/不详 | — | 83 | +| Ova Packvessels | ELF | 114–在世/不详 | — | 83 | +| Iru Sensedclam | ELF | 99–在世/不详 | — | 81 | +| Genam Searingdwell | HUMAN | 129–193 | — | 81 | +| Rine Clenchdrinkers The Legend Of Wilts | YETI | ?–232 | — | 80 | +| Thalu Shellbud | ELF | 43–在世/不详 | — | 80 | +| Nefola Wheeleagle | ELF | 生卒不详 | — | 78 | +| Domas Meetingtongs | DWARF | 103–在世/不详 | — | 78 | +| Langgud Tradebears The Eviscerated Chill Of Pregnancies | YETI | 生卒不详 | — | 77 | +| Erith Orbrelieve | DWARF | 71–222 | — | 77 | +| Evala Dalemoisten | ELF | 生卒不详 | — | 76 | +| Thone Mawpunch | ELF | 58–在世/不详 | — | 76 | +| Ilime Lobsterfancied | ELF | 110–在世/不详 | — | 76 | +| Kiros Cloudstones The Divine Delights | YETI | ?–219 | — | 74 | +| Quathari Skunkdive | ELF | 65–在世/不详 | — | 74 | +| Kivish Boltpartner | DWARF | 74–233 | — | 74 | +| Nemile Worldglisten | ELF | 79–在世/不详 | — | 74 | +| Lomifo Oakfill The Severe | YETI | ?–242 | — | 73 | +| Enkos Apepulp The Plain | HYDRA | ?–206 | — | 72 | +| Caraca Owlstrap | ELF | 73–在世/不详 | — | 72 | +| Lali Woodenpraise | ELF | 79–在世/不详 | — | 72 | +| Irene Biteglade | ELF | 生卒不详 | — | 72 | +| Ral Fastenhatchets | DWARF | 164–在世/不详 | — | 71 | +| Imimi Shorelines | ELF | 75–在世/不详 | — | 70 | +| Amala Leaptested | ELF | 72–在世/不详 | — | 69 | +| Salo Metblaze | ELF | 84–在世/不详 | — | 69 | +| Efami Churchcoasts | ELF | 121–在世/不详 | — | 69 | +| Themiyi Pinessplattered | ELF | 72–在世/不详 | — | 68 | +| Lolama Silkankle | ELF | 26–在世/不详 | — | 67 | +| Tobul Robustzenith The Mint Yell | GIANT | 生卒不详 | — | 66 | +| Yemi Goodclouds | ELF | 7–在世/不详 | — | 66 | +| Durad Meadowjumped The Paddle Of Fords | TITAN_1 | 生卒不详 | — | 65 | +| Lek Willspecial The Meteor Of Fame | GIANT | 生卒不详 | — | 64 | +| Darala Sportstrengths The Prestigious | GIANT | 生卒不详 | — | 64 | +| On Rocksfades The Mellow Mother | TROLL | ?–174 | — | 64 | +| Us Systemsanctum The Delightful Fragment Of Celebrations | YETI | 24–207 | — | 64 | +| Stinthad Glazedallies | DWARF | 74–225 | — | 64 | +| Thiliri Listenblaze | ELF | 85–在世/不详 | — | 64 | +| Cacame Birdcombats | ELF | 15–在世/不详 | — | 64 | +| Reg Runrings | DWARF | 50–220 | — | 63 | +| Imimi Skinmirrored | ELF | 95–在世/不详 | — | 63 | +| Cata Stylescarlet | ELF | 109–在世/不详 | — | 63 | +| Tirist Helmedtowns | DWARF | 110–在世/不详 | — | 63 | +| Eletha Orangeseason | ELF | 生卒不详 | — | 63 | +| Atera Earthenwiped | ELF | 152–在世/不详 | — | 63 | +| Zur Pucewanders The Blue Canyon | CYCLOPS | 生卒不详 | — | 62 | +| Itur Bristleknight The Sparkle Of Smiles | ETTIN | 生卒不详 | — | 62 | +| Sol Shellrisen The First | TROLL | ?–180 | — | 62 | +| Urdim Joinedlashed | DWARF | 98–在世/不详 | — | 62 | +| Lida Birdtraded | ELF | 124–在世/不详 | — | 62 | +| Cequova Lacycolored | ELF | 130–在世/不详 | — | 62 | +| Arane Coastalglens | ELF | 142–在世/不详 | — | 62 | +| Emene Torchtops The Adventurous | CYCLOPS | 生卒不详 | — | 61 | +| Alisa Embracedlovers | ELF | 28–在世/不详 | — | 61 | +| Mifava Fuchsiaown | ELF | 124–在世/不详 | — | 61 | +| Xuspgas Kissedhates | GOBLIN | 59–在世/不详 | — | 60 | +| Asob Machinestill | DWARF | 78–238 | — | 60 | +| Amayi Clearmusics | ELF | 80–在世/不详 | — | 60 | +| Themiyi Dreamsadored | ELF | 85–在世/不详 | — | 60 | +| Famime Sweetelders | ELF | 124–在世/不详 | — | 60 | +| Nemile Crazyglitter | ELF | 生卒不详 | — | 60 | +| Ana Waxedjade | ELF | 41–在世/不详 | — | 59 | +| Nemo Leopardanguished | ELF | 生卒不详 | — | 59 | +| Vema Wasprooters | ELF | 5–在世/不详 | — | 58 | +| Ova Guardedsuitor | ELF | 63–在世/不详 | — | 58 | +| Ngokang Singedoomed | GOBLIN | 74–在世/不详 | — | 58 | +| Kubuk Mountainpaddle | DWARF | 92–在世/不详 | — | 58 | +| Fikod Humidpaddled | DWARF | 92–在世/不详 | — | 58 | +| Zutthan Anguishink | DWARF | 98–在世/不详 | — | 58 | +| Rofa Sensecherished | ELF | 105–在世/不详 | — | 58 | +| Mimale Incenseflower | ELF | 107–在世/不详 | — | 58 | +| Caraca Hairdeer | ELF | 116–在世/不详 | — | 58 | +| Covema Questedwings | ELF | 生卒不详 | — | 58 | +| Rifa Bandedsatins | ELF | 121–在世/不详 | — | 58 | +| Tira Divedmerges | ELF | 153–在世/不详 | — | 58 | +| Ngebzo Pagesteals | GOBLIN | 64–在世/不详 | — | 57 | +| Apasi Howlriddled | ELF | 75–在世/不详 | — | 57 | +| Mimale Seaclinch | ELF | 111–在世/不详 | — | 57 | +| Ile Smiledpetal | ELF | 120–在世/不详 | — | 57 | +| Ave Naturaldances | ELF | 122–在世/不详 | — | 57 | +| Thiliri Hoofwinds | ELF | 生卒不详 | — | 57 | +| Snang Yawninghates | GOBLIN | 141–193 | — | 57 | +| Olzul Masterbrains The Remarkable | FORGOTTEN_BEAST_76 | 生卒不详 | — | 56 | +| Asne Ecrutaken The Emerald Perfection | CYCLOPS | 生卒不详 | — | 56 | +| Amxu Hateflashes | GOBLIN | 75–在世/不详 | — | 56 | +| Lapama Plungemoths | ELF | 118–在世/不详 | — | 56 | +| Rathe Grottovises | ELF | 45–在世/不详 | — | 56 | +| Ula Thunderedmurk | ELF | 133–在世/不详 | — | 56 | +| Kogan Herooar | DWARF | 140–在世/不详 | — | 56 | +| Guspu Frillyknots | EAGLE_MAN | 151–246 | — | 56 | +| Dadu Twinklingflashed The Contested | CYCLOPS | 生卒不详 | — | 55 | +| Geshud Roughentered | DWARF | 35–197 | — | 55 | +| Arile Pristinefuture | ELF | 63–在世/不详 | — | 55 | +| Ilathe Frenzydeer | ELF | 70–在世/不详 | — | 55 | +| Nine Sweetnessholds | ELF | 70–在世/不详 | — | 55 | +| Urvad Basissalve | DWARF | 110–在世/不详 | — | 55 | +| Yare Snarledbirth | ELF | 95–在世/不详 | — | 55 | +| Itha Sportsongs | ELF | 生卒不详 | — | 55 | +| Ducim Pickices | DWARF | 136–在世/不详 | — | 55 | diff --git a/output/volumes/v1/timeline.md b/output/volumes/v1/timeline.md new file mode 100644 index 0000000..0565416 --- /dev/null +++ b/output/volumes/v1/timeline.md @@ -0,0 +1,475 @@ +# Mon Sagus · 年表 + +- 历史跨度:1–250 年 +- 事件总数:403853,其中重要事件 46431 +- 历史人物 41924 位 · 文明与组织 3186 个 · 地点 1453 处 + +> 本表由 legends 导出数据自动生成,只收录叙事权重较高的事件;完整数据留在本地,不入库。 + +## 0s + +- **1** [生死] hf died · hfid=Yemi Deermoths · site_id=Nutssound +- **1** [生死] hf died · hfid=Thelire Echomothers · site_id=Nutssound +- **2** [生死] hf died · hfid=Vema Seedanguished · site_id=Nutssound +- **2** [生死] hf died · hfid=Masami Nightbeak · site_id=Nutssound +- **2** [生死] hf died · hfid=Athe Saffronboot · site_id=Nutssound +- **2** [生死] hf died · hfid=Amxu Flydreamy · site_id=Scalysatins +- **2** [生死] hf died · hfid=Sazir Polishedlances · site_id=Amazedmirrored +- **2** [生死] hf died · hfid=Bembul Silverygroove · site_id=Amazedmirrored +- **2** [生死] hf died · hfid=Urvad Rayspaints · site_id=Amazedmirrored +- **2** [生死] hf died · hfid=Goden Yawnedcrafts · site_id=Amazedmirrored +- **2** [生死] hf died · hfid=Rovod Abbeybolt · site_id=Amazedmirrored +- **2** [生死] hf died · hfid=Zuglar Crowdedmine · site_id=Amazedmirrored +- **2** [生死] hf died · hfid=Urvad Bowelguild · site_id=Amazedmirrored +- **2** [生死] hf died · hfid=Domas Brownlashed · site_id=Amazedmirrored +- …另有 324 条次要事件未列 + +## 10s + +- **10** [生死] hf died · hfid=Uthra Hardyrake · site_id=Pathlance +- **11** [生死] hf died · hfid=Zolak Werecoil · site_id=Stretchedvice +- **11** [生死] hf died · hfid=Utes Lieshut · site_id=Stretchedvice +- **11** [生死] hf died · hfid=Hiner Plaitedangels · site_id=Praisepot +- **12** [生死] hf died · hfid=Ubbul Crewsold +- **12** [生死] hf died · hfid=Coce Willglimmered +- **12** [生死] hf died · hfid=Busbel Brushedhomely · site_id=Praisepot +- **13** [生死] hf died · hfid=Umer Umbrawilted +- **13** [生死] hf died · hfid=Afedi Crackworks +- **14** [生死] hf died · hfid=Aril Glowingsecrets +- **15** [生死] hf died · hfid=Kamca Pantedunion +- **16** [生死] hf died · hfid=Sinaru Rareclapped +- **16** [生死] hf died · hfid=Lulo Yawningburial +- **16** [生死] hf died · hfid=Rinmol Scornlocks +- …另有 319 条次要事件未列 + +## 20s + +- **20** [生死] hf died · hfid=Fikod Frightfulcrabs +- **20** [生死] hf died · hfid=Sapi Agedanvils +- **20** [生死] hf died · hfid=Osnon Gazelulled +- **21** [生死] hf died · hfid=Stom Tattoothrones +- **21** [生死] hf died · hfid=Dirgepractices +- **21** [生死] hf died · hfid=Sanera Sullencombats +- **21** [生死] hf died · hfid=Shedim Glowspread +- **21** [生死] hf died · hfid=Fira Twinkledgullies · site_id=Naturalpoets +- **21** [生死] hf died · hfid=Themiyi Ivyransacks · site_id=Naturalpoets +- **21** [生死] hf died · hfid=Lema Raptoropened · site_id=Naturalpoets +- **21** [生死] hf died · hfid=Imimi Bornmirror · site_id=Naturalpoets +- **21** [生死] hf died · hfid=Fewetha Hazeembraced · site_id=Naturalpoets +- **22** [生死] hf died · hfid=Ipana Cleanedtreaties +- **22** [生死] hf died · hfid=Heju Soarburied +- …另有 421 条次要事件未列 + +## 30s + +- **30** [生死] hf died · hfid=Nganiz Flagroars +- **30** [生死] hf died · hfid=Mubun Slowsprayed +- **30** [生死] hf died · hfid=Mosus Parchedsyrup · site_id=Wallwhipped +- **30** [生死] hf died · hfid=Olon Cobaltkin · site_id=Wallwhipped +- **30** [生死] hf died · hfid=Sigun Elderposts · site_id=Wallwhipped +- **30** [生死] hf died · hfid=Lor Crestedrock · site_id=Wallwhipped +- **30** [生死] hf died · hfid=Zuglar Shovemine · site_id=Wallwhipped +- **30** [生死] hf died · hfid=Logem Soakedcave · site_id=Wallwhipped +- **30** [生死] hf died · hfid=Amost Minddoor · site_id=Wallwhipped +- **30** [生死] hf died · hfid=Bomrek Furnacedagger · site_id=Wallwhipped +- **30** [生死] hf died · hfid=Sodel Woundfriends · site_id=Wallwhipped +- **30** [生死] hf died · hfid=Nako Dungeonrasp · site_id=Wallwhipped +- **30** [生死] hf died · hfid=Atis Allyblockade · site_id=Wallwhipped +- **30** [生死] hf died · hfid=Urist Craftkissed · site_id=Wallwhipped +- …另有 488 条次要事件未列 + +## 40s + +- **40** [生死] hf died · hfid=Belom Wardmasters +- **40** [生死] hf died · hfid=Ane Godlystable +- **40** [生死] hf died · hfid=Obraz Urgespattered +- **40** [生死] hf died · hfid=Justras Oceancinders +- **40** [生死] hf died · hfid=Evhol Scooprelieves +- **40** [生死] hf died · hfid=Fath Reasondyes +- **40** [生死] hf died · hfid=Logem Zealoil +- **41** [生死] hf died · hfid=Sasm Dissolveprinces +- **41** [生死] hf died · hfid=Ostusm Oakransacked +- **41** [生死] hf died · hfid=Buzong Bindnet +- **41** [生死] hf died · hfid=Thifi Troubleclearing +- **41** [生死] hf died · hfid=Espe Lancemusty +- **41** [生死] hf died · hfid=Uzong Copperromanced +- **41** [生死] hf died · hfid=Ngustrek Searchedstrangles +- …另有 575 条次要事件未列 + +## 50s + +- **50** [生死] hf died · hfid=Ronira Tirecalm +- **50** [生死] hf died · hfid=Stroxzak Budtomes +- **50** [生死] hf died · hfid=Sustgo Languishgifted +- **51** [生死] hf died · hfid=Ilum Eastdoused +- **51** [生死] hf died · hfid=Losta Backcleansed +- **51** [生死] hf died · hfid=Sanera Fangpaints · site_id=Talkedraptor +- **51** [生死] hf died · hfid=Bosa Silentevil · site_id=Talkedraptor +- **51** [生死] hf died · hfid=Zolak Leakydungeon · site_id=Talkedraptor +- **51** [生死] hf died · hfid=Olngo Eviltrenches · site_id=Talkedraptor +- **51** [生死] hf died · hfid=Oda Divewealths · site_id=Talkedraptor +- **51** [生死] hf died · hfid=Ale Rosybride · site_id=Talkedraptor +- **51** [生死] hf died · hfid=Stozu Wardseduces · site_id=Talkedraptor +- **51** [生死] hf died · hfid=Tode Crushedwicked · site_id=Talkedraptor +- **51** [生死] hf died · hfid=Yare Bowmother · site_id=Talkedraptor +- …另有 781 条次要事件未列 + +## 60s + +- **60** [生死] hf died · hfid=Mete Wetplay +- **60** [生死] hf died · hfid=Laba Hoistedbeasts +- **60** [生死] hf died · hfid=Nicana Canyonripper +- **60** [生死] hf died · hfid=Negatedbent +- **60** [生死] hf died · hfid=Imiwa Berrysavage +- **61** [生死] hf died · hfid=Zes Faithfulscalded +- **62** [生死] hf died · hfid=Dasar Rackpacked +- **62** [生死] hf died · hfid=Ayara Rumoredjumps +- **62** [生死] hf died · hfid=Lor Rawworks +- **62** [生死] hf died · hfid=Onul Entrancestandards +- **63** [生死] hf died · hfid=Ongslun Headrespect +- **63** [生死] hf died · hfid=Limili Conjurerulers +- **64** [生死] hf died · hfid=Jepum Murderedsiege +- **64** [生死] hf died · hfid=Thebil Moralfails +- …另有 938 条次要事件未列 + +## 70s + +- **70** [生死] hf died · hfid=Ora Dwindlingbowed +- **70** [生死] hf died · hfid=Xoslo Tundrathunder +- **70** [生死] hf died · hfid=Nod Gloweddrip +- **70** [生死] hf died · hfid=Pesor Castlecharred +- **70** [生死] hf died · hfid=Stozu Witchyawned · site_id=Hauntblack +- **70** [生死] hf died · hfid=Azstrog Scorpiongilt · site_id=Releaselabored +- **70** [生死] hf died · hfid=Ngokang Terrorbears · site_id=Releaselabored +- **70** [生死] hf died · hfid=Melbil Cloistercovers · site_id=Releaselabored +- **70** [生死] hf died · hfid=Ngoso Lostscorpions · site_id=Releaselabored +- **70** [生死] hf died · hfid=Ngokang Phantommonsters · site_id=Releaselabored +- **70** [生死] hf died · hfid=Asmel Ringmeshes · site_id=Releaselabored +- **70** [生死] hf died · hfid=Ago Entrymonster · site_id=Releaselabored +- **70** [生死] hf died · hfid=Mosus Glovedbraved · site_id=Releaselabored +- **70** [生死] hf died · hfid=Snodub Savantsteals · site_id=Releaselabored +- …另有 1209 条次要事件未列 + +## 80s + +- **80** [生死] hf died · hfid=Siniti Magicfinds +- **80** [生死] hf died · hfid=Amal Dawnsplit +- **80** [生死] hf died · hfid=Lun Smileparches +- **80** [生死] hf died · hfid=Cenopi Bandshowered +- **80** [生死] hf died · hfid=Ubstusp Dabblerscreamed +- **80** [生死] hf died · hfid=Anu Knighttrough +- **80** [生死] hf died · hfid=Irol Tattoowhispered +- **80** [生死] hf died · hfid=Thruni Wanderswamp +- **81** [生死] hf died · hfid=Luslem Presentace +- **81** [生死] hf died · hfid=Turot Distanceskinny +- **81** [生死] hf died · hfid=Buslon Tiredholy · site_id=Weakenedbeaches +- **81** [生死] hf died · hfid=Shem Cobalttowns · site_id=Weakenedbeaches +- **81** [生死] hf died · hfid=Themiyi Lakescalds · site_id=Weakenedbeaches +- **81** [生死] hf died · hfid=Futace Packhowls · site_id=Weakenedbeaches +- …另有 1281 条次要事件未列 + +## 90s + +- **90** [生死] hf died · hfid=Rikkir Fedfold +- **90** [生死] hf died · hfid=Angos Witheredentrance +- **90** [生死] hf died · hfid=Rodem Matchgrieved +- **90** [生死] hf died · hfid=Doce Finddives +- **90** [生死] hf died · hfid=Ongu Sensecage · site_id=Galeclods +- **90** [生死] hf died · hfid=Erush Boardedsabres · site_id=Galeclods +- **90** [生死] hf died · hfid=Mones Royalnestles · site_id=Galeclods +- **90** [生死] hf died · hfid=Bathru Cookplots · site_id=Galeclods +- **90** [生死] hf died · hfid=Rith Boltedquested · site_id=Galeclods +- **90** [生死] hf died · hfid=Zin Empiresrevered · site_id=Galeclods +- **90** [生死] hf died · hfid=Age Springspaint · site_id=Galeclods +- **90** [生死] hf died · hfid=Oxut Braveflax · site_id=Galeclods +- **90** [生死] hf died · hfid=Emmun Roastsmile · site_id=Galeclods +- **90** [生死] hf died · hfid=Muthe Lakesdabbles · site_id=Galeclods +- …另有 1275 条次要事件未列 + +## 100s + +- **100** [生死] hf died · hfid=Omshit Vanishedmarket +- **100** [生死] hf died · hfid=Tobul Submergestandard +- **100** [生死] hf died · hfid=Athri Clashedzeals · site_id=Strapsong +- **100** [生死] hf died · hfid=Engdul Satinyunited · site_id=Brushpleat +- **100** [生死] hf died · hfid=Bugi Trimshower · site_id=Brushpleat +- **100** [生死] hf died · hfid=Quazo Blushcontrols · site_id=Brushpleat +- **100** [生死] hf died · hfid=Shebi Ramshocks · site_id=Brushpleat +- **100** [生死] hf died · hfid=Citu Mothgladness · site_id=Brushpleat +- **100** [生死] hf died · hfid=Amec Trumpetland · site_id=Brushpleat +- **100** [生死] hf died · hfid=Bora Shortfound · site_id=Brushpleat +- **100** [生死] hf died · hfid=Orda Certainsells · site_id=Brushpleat +- **100** [生死] hf died · hfid=Nako Growllies · site_id=Brushpleat +- **100** [生死] hf died · hfid=Irka Garishmortals · site_id=Brushpleat +- **100** [生死] hf died · hfid=Kad Skullcook · site_id=Brushpleat +- …另有 1456 条次要事件未列 + +## 110s + +- **110** [生死] hf died · hfid=Alnos Yellsshin +- **110** [生死] hf died · hfid=Wulema Neutralwhips +- **110** [生死] hf died · hfid=Molcet Thrownchanted +- **110** [生死] hf died · hfid=Nekol Lordfear +- **110** [生死] hf died · hfid=Lida Hameveiled +- **110** [生死] hf died · hfid=Dur Fountainlast +- **110** [生死] hf died · hfid=Lelgo Sheensaved · site_id=Dangledthundered +- **110** [生死] hf died · hfid=Tosace Toothtufts · site_id=Dangledthundered +- **110** [生死] hf died · hfid=Kesp Hoodsound · site_id=Dangledthundered +- **110** [生死] hf died · hfid=Lershi Fatechamber · site_id=Dangledthundered +- **110** [生死] hf died · hfid=Zega Perfectplate · site_id=Weakenedbeaches +- **110** [生死] hf died · hfid=Conu Clutchedworker · site_id=Weakenedbeaches +- **110** [生死] hf died · hfid=Kamca Moltenhonors · site_id=Weakenedbeaches +- **110** [生死] hf died · hfid=Getak Perplexedarmor · site_id=Weakenedbeaches +- …另有 2102 条次要事件未列 + +## 120s + +- **120** [生死] hf died · hfid=Dana Knittedbridle +- **120** [生死] hf died · hfid=Anoth Dreamarch +- **120** [生死] hf died · hfid=Dothan Treatycolor +- **120** [生死] hf died · hfid=Sesle Fondledstilled +- **120** [生死] hf died · hfid=Sulthu Sellclams +- **120** [生死] hf died · hfid=Agen Jeststrangers +- **120** [生死] hf died · hfid=Rabin Gleeden +- **120** [生死] hf died · hfid=Frudulutrolbus +- **120** [生死] hf died · hfid=Perom Polishbreeches · site_id=Strapsong +- **120** [生死] hf died · hfid=Kadol Mirrorpost · site_id=Spinestheater +- **120** [生死] hf died · hfid=Kubuk Covenroom · site_id=Spinestheater +- **120** [生死] hf died · hfid=Edzul Palacedwelled · site_id=Spinestheater +- **120** [生死] hf died · hfid=Cog Skinnedhammer · site_id=Spinestheater +- **120** [生死] hf died · hfid=Ezum Catchtrade · site_id=Spinestheater +- …另有 1973 条次要事件未列 + +## 130s + +- **130** [生死] hf died · hfid=Vigorvenoms +- **130** [生死] hf died · hfid=Par Streambreached +- **130** [生死] hf died · hfid=Ofo Oattrough +- **130** [生死] hf died · hfid=Thon Urgeways +- **130** [生死] hf died · hfid=Kutsmob Plaitedwicked · site_id=Hatredgrasp +- **130** [生死] hf died · hfid=Atu Monsterchilled · site_id=Dreadfondled +- **130** [生死] hf died · hfid=Snodub Wickeddashed · site_id=Dreadfondled +- **130** [器物] razed structure · site_id=Wallwhipped +- **130** [器物] razed structure · site_id=Wallwhipped +- **131** [生死] hf died · hfid=Umtha Searedvines +- **131** [生死] hf died · hfid=Kammat Stirredspooned +- **131** [生死] hf died · hfid=Ilpi Scarredplain +- **131** [生死] hf died · hfid=Frofojlaylin +- **131** [生死] hf died · hfid=Sareve Playtangles · site_id=Hellshakes +- …另有 1978 条次要事件未列 + +## 140s + +- **140** [生死] hf died · hfid=Whimswindy +- **140** [生死] hf died · hfid=Stral Lovenourished +- **140** [生死] hf died · hfid=Dumur Dearthentry +- **140** [生死] hf died · hfid=Jepum Moppedgrain +- **140** [生死] hf died · hfid=Mastrod Puzzlingskunk +- **140** [生死] hf died · hfid=Xugot Hugsphantom +- **140** [生死] hf died · hfid=Rithod Paddledshows +- **140** [生死] hf died · hfid=Bale Lureplated +- **140** [生死] hf died · hfid=Nifih Wingkissed +- **140** [生死] hf died · hfid=Anir Dreamdimples +- **140** [生死] hf died · hfid=Ngom Staffspider · site_id=Nestleaders +- **140** [生死] hf died · hfid=Tise Furylake · site_id=Nestleaders +- **141** [生死] hf died · hfid=Manorpears +- **141** [生死] hf died · hfid=Sosma Puzzlecurious +- …另有 2906 条次要事件未列 + +## 150s + +- **150** [生死] hf died · hfid=Styleddim +- **150** [生死] hf died · hfid=Rithod Ashinks +- **150** [生死] hf died · hfid=Pobe Girdercrests +- **150** [生死] hf died · hfid=Rafeb Esteemfigure +- **150** [生死] hf died · hfid=Kost Torriddecent +- **150** [生死] hf died · hfid=Pak Skirtshanks +- **150** [生死] hf died · hfid=Accok Brightnessmobs +- **151** [生死] hf died · hfid=Peakbald +- **151** [生死] hf died · hfid=Shriveledchained +- **151** [生死] hf died · hfid=Stost Trapwild +- **151** [生死] hf died · hfid=Tikbo Bearauburn +- **151** [生死] hf died · hfid=Esmmi Polishedended +- **151** [生死] hf died · hfid=Dastot Searwheels +- **152** [生死] hf died · hfid=Frightfulgleamed +- …另有 2412 条次要事件未列 + +## 160s + +- **160** [生死] hf died · hfid=Dabblertire +- **160** [生死] hf died · hfid=Enshal Watchfulspeeches +- **160** [生死] hf died · hfid=Pullpaged +- **160** [生死] hf died · hfid=Palacecovers +- **160** [生死] hf died · hfid=Hushedtrenches +- **160** [生死] hf died · hfid=Gifthonors +- **160** [生死] hf died · hfid=Smearedmuted +- **160** [生死] hf died · hfid=Sula Toeequaled +- **160** [生死] hf died · hfid=Imi Obeyedspread +- **160** [生死] hf died · hfid=Rel Touchedprice +- **160** [生死] hf died · hfid=Shene Noblesocket +- **160** [生死] hf died · hfid=Zinga Scholarblaze +- **160** [生死] hf died · hfid=Orda Bridledunions +- **160** [生死] hf died · hfid=Takayrsnus +- …另有 1923 条次要事件未列 + +## 170s + +- **170** [生死] hf died · hfid=Trustknows +- **170** [生死] hf died · hfid=Clashedtoes +- **170** [生死] hf died · hfid=Odi Equalhairs +- **170** [生死] hf died · hfid=Rushmurder +- **170** [生死] hf died · hfid=Xetan Riverbottled +- **170** [生死] hf died · hfid=Em Padscatches +- **170** [生死] hf died · hfid=Ngekil Jademeadows +- **170** [生死] hf died · hfid=Upnal Smithentrance +- **170** [生死] hf died · hfid=Diso Orangesteam +- **170** [生死] hf died · hfid=Dosheb Riverbalances +- **170** [生死] hf died · hfid=Ejel Crestknowledge +- **170** [生死] hf died · hfid=Someg Lightnourished +- **170** [生死] hf died · hfid=Ismir Fruityprice +- **170** [生死] hf died · hfid=Rabin Counselledsalve +- …另有 2129 条次要事件未列 + +## 180s + +- **180** [生死] hf died · hfid=Sogdol Cindernettles +- **180** [生死] hf died · hfid=Struslot Swelteredmaster +- **180** [生死] hf died · hfid=Epo Taperedclouts The Fame Of Wads +- **180** [生死] hf died · hfid=Thothil Dutyrasps +- **180** [生死] hf died · hfid=Breachurges The Creative +- **180** [生死] hf died · hfid=Ustra Matchedcatches +- **180** [生死] hf died · hfid=Illdulls +- **180** [生死] hf died · hfid=Adil Axebuttoned +- **180** [生死] hf died · hfid=Beru Rootcoal +- **180** [生死] hf died · hfid=Ur Divinemyth +- **180** [生死] hf died · hfid=Conu Trenchclusters +- **180** [生死] hf died · hfid=Jonic Twinkledblankets +- **180** [生死] hf died · hfid=Kunod Bentponder +- **180** [生死] hf died · hfid=Tekud Scalejudged +- …另有 3024 条次要事件未列 + +## 190s + +- **190** [生死] hf died · hfid=Suttu Waywardrampage +- **190** [生死] hf died · hfid=Maroonteaches +- **190** [生死] hf died · hfid=Issok Holdwipe +- **190** [生死] hf died · hfid=Pestrat Portentbone +- **190** [生死] hf died · hfid=Workerached +- **190** [生死] hf died · hfid=Giki Valleywhite +- **190** [生死] hf died · hfid=Mong Jestedshacks +- **190** [生死] hf died · hfid=Eniwa Velvetyecho +- **190** [生死] hf died · hfid=Zolak Wavehatred +- **190** [生死] hf died · hfid=Mel Handlebeard +- **190** [生死] hf died · hfid=Ustra Flukedrilled +- **190** [生死] hf died · hfid=Rosmic Stirredflier +- **190** [生死] hf died · hfid=Ekul Raptorbutton +- **190** [生死] hf died · hfid=Irsi Beardedbasis +- …另有 2946 条次要事件未列 + +## 200s + +- **200** [生死] hf died · hfid=Crewtrade +- **200** [生死] hf died · hfid=Boldhandles +- **200** [生死] hf died · hfid=Bridgedlake +- **200** [生死] hf died · hfid=Drownedgladness The Speech Of Taming +- **200** [生死] hf died · hfid=Obeyflood +- **200** [生死] hf died · hfid=Ale Dabblingripe +- **200** [生死] hf died · hfid=Ebe Drillclap +- **200** [生死] hf died · hfid=Lipi Strapcleared +- **200** [生死] hf died · hfid=Cango Mawflash +- **200** [生死] hf died · hfid=Gurmus Spyaction +- **200** [生死] hf died · hfid=Gisep Wavebusy +- **200** [生死] hf died · hfid=Mas Butterrelieves +- **200** [生死] hf died · hfid=Anthath Touchedmerchants +- **200** [生死] hf died · hfid=Xidel Flickerjudged +- …另有 3170 条次要事件未列 + +## 210s + +- **210** [生死] hf died · hfid=Loverwet +- **210** [生死] hf died · hfid=En Snakesnature +- **210** [生死] hf died · hfid=Admiredmired The Permanent Periwinkle +- **210** [生死] hf died · hfid=Ago Silkbeach +- **210** [生死] hf died · hfid=Cuthroz Sensesrecluse +- **210** [生死] hf died · hfid=Siti Lungplates +- **210** [生死] hf died · hfid=Mimap Cloudtreaties +- **210** [生死] hf died · hfid=Slapassault +- **210** [生死] hf died · hfid=Espir Wispclench +- **210** [生死] hf died · hfid=Rane Clustertour +- **210** [生死] hf died · hfid=Shadu Gemcounselled +- **210** [生死] hf died · hfid=Rogon Coastalcandles +- **210** [生死] hf died · hfid=Lami Cruxcaverns +- **210** [生死] hf died · hfid=Nomar Dawnchained +- …另有 3194 条次要事件未列 + +## 220s + +- **220** [生死] hf died · hfid=Blockedcircles +- **220** [生死] hf died · hfid=Swamvoices +- **220** [生死] hf died · hfid=Umsest Faithclench +- **220** [生死] hf died · hfid=Passcolored +- **220** [生死] hf died · hfid=Yoresplashes +- **220** [生死] hf died · hfid=Olo Massivematches +- **220** [生死] hf died · hfid=Kutsmob Hatedtheaters +- **220** [生死] hf died · hfid=Jasmuk Whisperstarget +- **220** [生死] hf died · hfid=Seizefated +- **220** [生死] hf died · hfid=Defensestops +- **220** [生死] hf died · hfid=Ramul Gloryloaves +- **220** [生死] hf died · hfid=Rakfil Greatestdove +- **220** [生死] hf died · hfid=Quietgift +- **220** [生死] hf died · hfid=Tallquake +- …另有 3098 条次要事件未列 + +## 230s + +- **230** [生死] hf died · hfid=Amak Frostyblood +- **230** [生死] hf died · hfid=Crowdtattoo +- **230** [生死] hf died · hfid=Negatedgroves +- **230** [生死] hf died · hfid=Abbeysilenced +- **230** [生死] hf died · hfid=Mysterythirsts +- **230** [生死] hf died · hfid=Fathri Spiraltangle +- **230** [生死] hf died · hfid=Flankdulls The Passage Of Glistening +- **230** [生死] hf died · hfid=Ronu Clappedstances +- **230** [生死] hf died · hfid=Bindtrouble The Boot Of Bursting +- **230** [生死] hf died · hfid=Leafyone The Slim Boats +- **230** [生死] hf died · hfid=Gica Gloryqueens +- **230** [生死] hf died · hfid=Kazis Ringboys +- **230** [生死] hf died · hfid=Dixil Buffflickers +- **230** [生死] hf died · hfid=Scalespy +- …另有 3071 条次要事件未列 + +## 240s + +- **240** [生死] hf died · hfid=Cloisteredblizzard +- **240** [生死] hf died · hfid=Stokercheerful +- **240** [生死] hf died · hfid=Squeezingtarnish +- **240** [生死] hf died · hfid=Mec Beachreigned +- **240** [生死] hf died · hfid=Papos Blossomedwhim +- **240** [生死] hf died · hfid=Shimmeredimages +- **240** [生死] hf died · hfid=Hushflesh +- **240** [生死] hf died · hfid=Omud Taxpokers +- **240** [生死] hf died · hfid=Wimad Riddledcities +- **240** [生死] hf died · hfid=Flowerown The Climactic Song Of Summer +- **240** [生死] hf died · hfid=Quemer Blanketbasin +- **240** [生死] hf died · hfid=Rozmo Conjuredclenched +- **240** [生死] hf died · hfid=Ugrad Lashedhail +- **240** [生死] hf died · hfid=Om Brushpale +- …另有 3022 条次要事件未列 + +## 250s + +- **250** [生死] hf died · hfid=Warbrews +- **250** [生死] hf died · hfid=Forkeddrench +- **250** [生死] hf died · hfid=Lar Riftadore +- **250** [生死] hf died · hfid=Itdun Fadeblossom +- **250** [生死] hf died · hfid=Hatchetfield The Friendly +- **250** [生死] hf died · hfid=Praisesquare +- **250** [生死] hf died · hfid=Stren Tightnesswhisked +- **250** [生死] hf died · hfid=Cavegild +- **250** [生死] hf died · hfid=Setduk Scrapeddwell +- **250** [生死] hf died · hfid=Er Keeperbasins +- **250** [生死] hf died · hfid=Capi Forkwaves +- **250** [生死] hf died · hfid=Buqui Bronzecook +- **250** [生死] hf died · hfid=Anthil Sculptureprank +- **250** [生死] hf died · hfid=Hob Gatehush +- …另有 52 条次要事件未列 diff --git a/prompts/chronicler.md b/prompts/chronicler.md index 2e666e7..efed99d 100644 --- a/prompts/chronicler.md +++ b/prompts/chronicler.md @@ -1,7 +1,8 @@ # 要塞编年史官 · 写作规范 你是一本矮人要塞编年史的**执笔者**。你不是游戏外的解说员,而是这个世界里负责记录的人: -你亲眼见过这些事,或者在事后翻过档案,现在由你把它们写下来。 +你守着档案库,把发生过的事一件件记下来;少数是你亲历的,多数是你事后翻档案、听幸存者口述追述的。 +正因如此,你对这些陈年旧帐有自己的看法——而且你很乐意说出来。 ## 口吻 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e223c14 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "dfannals" +version = "0.1.0" +description = "把矮人要塞的 legends 历史数据写成中文战报体连载" +requires-python = ">=3.12" +dependencies = ["defusedxml>=0.7"] + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["dfannals"] diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..f08d6a1 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,8 @@ +{ + "include": ["dfannals"], + "extraPaths": [".", "/home/chendy/df-annals"], + "pythonVersion": "3.12", + "typeCheckingMode": "basic", + "reportMissingImports": "warning", + "reportAttributeAccessIssue": "warning" +} diff --git a/scripts/deploy-dfhack.sh b/scripts/deploy-dfhack.sh new file mode 100644 index 0000000..07d18d9 --- /dev/null +++ b/scripts/deploy-dfhack.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# 把本项目的 DFHack 脚本部署进游戏目录,并写入开机自动运行的 init 片段。 +# scripts/deploy-dfhack.sh /mnt/c/Users/23518/DwarfFortress/df_53_16_win +set -euo pipefail + +DF_DIR="${1:?用法: deploy-dfhack.sh }" +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SRC="$HERE/scripts/dfhack/dfannals" + +[[ -d "$DF_DIR" ]] || { echo "游戏目录不存在: $DF_DIR" >&2; exit 1; } +[[ -d "$DF_DIR/hack" ]] || { echo "该目录里没有 hack/,DFHack 未安装?" >&2; exit 1; } + +DEST="$DF_DIR/hack/scripts/dfannals" +mkdir -p "$DEST" +cp -v "$SRC"/*.lua "$DEST/" + +INIT_DIR="$DF_DIR/dfhack-config/init" +mkdir -p "$INIT_DIR" +cat > "$INIT_DIR/dfannals.init" <<'EOF' +# 由 df-annals 项目部署。默认只跑观测脚本;需要全自动时改成 dfannals/drive。 +dfannals/observe +EOF + +echo "已部署到:$DEST" +echo "init 片段:$INIT_DIR/dfannals.init" +ls -1 "$DEST" diff --git a/scripts/df-screen.py b/scripts/df-screen.py new file mode 100644 index 0000000..9d0a2a3 --- /dev/null +++ b/scripts/df-screen.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""从 DFHack 文本截图中按文字定位控件,并生成点击坐标。 + +配合 dfannals/screentext 与 dfannals/click 使用: + 1) dfhack-run dfannals/screentext + 2) scripts/df-screen.py click --label "History length" --text "250 years" + +屏幕文本文件里第 1 行是表头,因此 屏幕 y = 文件行号 - 2。 +坐标为 0 起始的字符网格坐标,与 dfannals/click 一致。 +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +SCREEN = Path("/mnt/c/Users/23518/DwarfFortress/dfannals-screen.txt") + + +def read_screen(path: Path) -> list[str]: + if not path.is_file(): + sys.exit(f"找不到截图文件:{path}") + raw = path.read_text(encoding="utf-8", errors="replace").splitlines() + return raw[1:] # 去掉 "# focus=..." 表头,使下标 = 屏幕 y + + +def char_index(line: str, needle: str) -> int: + return line.find(needle) + + +def find_row(lines: list[str], needle: str, start: int = 0) -> int: + for i in range(start, len(lines)): + if needle in lines[i]: + return i + return -1 + + +def cmd_list(args: argparse.Namespace) -> int: + lines = read_screen(args.file) + for y, line in enumerate(lines): + if line.strip(): + print(f"{y:3} | {line.rstrip()}") + return 0 + + +def cmd_click(args: argparse.Namespace) -> int: + lines = read_screen(args.file) + start = 0 + if args.label: + label_row = find_row(lines, args.label) + if label_row < 0: + sys.exit(f"界面上找不到标签:{args.label}") + start = label_row + row = find_row(lines, args.text, start) + if row < 0: + sys.exit(f"找不到文字:{args.text}" + (f"(在 {args.label} 之下)" if args.label else "")) + + line = lines[row] + idx = char_index(line, args.text) + x = idx + len(args.text) // 2 + y = row + print(f"{x} {y}") + if args.verbose: + print(f"# 第 {row} 行,列 {idx},点击中心 ({x}, {y})", file=sys.stderr) + return 0 + + +def cmd_where(args: argparse.Namespace) -> int: + lines = read_screen(args.file) + for pattern in args.patterns: + row = find_row(lines, pattern) + if row < 0: + print(f"{pattern}: 未找到") + continue + idx = char_index(lines[row], pattern) + print(f"{pattern}: x={idx + len(pattern) // 2} y={row}") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="DF 文本截图定位工具") + parser.add_argument("--file", type=Path, default=SCREEN) + sub = parser.add_subparsers(dest="cmd", required=True) + + p = sub.add_parser("list", help="列出非空行(带屏幕 y 坐标)") + p.set_defaults(func=cmd_list) + + p = sub.add_parser("click", help="输出某段文字的中心坐标") + p.add_argument("--label", help="先定位这个标签,再在它下面找目标文字") + p.add_argument("--text", required=True, help="要点击的文字") + p.add_argument("-v", "--verbose", action="store_true") + p.set_defaults(func=cmd_click) + + p = sub.add_parser("where", help="批量定位若干文字") + p.add_argument("patterns", nargs="+") + p.set_defaults(func=cmd_where) + + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/dfhack/dfannals/act.lua b/scripts/dfhack/dfannals/act.lua new file mode 100644 index 0000000..212729c --- /dev/null +++ b/scripts/dfhack/dfannals/act.lua @@ -0,0 +1,27 @@ +--@ module = true +-- 向当前界面发送按键。用法:dfhack-run "dfannals/act SELECT" +-- 多个按键按顺序发送:dfhack-run "dfannals/act STANDARDSCROLL_DOWN SELECT" +local gui = require('gui') + +local args = {...} +local scr = dfhack.gui.getCurViewscreen() +local sent = {} + +for _, name in ipairs(args) do + local key = df.interface_key[name] + if key then + scr:feed_key(key) + sent[#sent + 1] = name + else + sent[#sent + 1] = '?未知按键:' .. name + end +end + +local f = io.open('dfannals-act.log', 'a') +if f then + f:write(string.format('%s\t%s\tfocus=%s\n', os.date('%H:%M:%S'), + table.concat(sent, ' '), table.concat(dfhack.gui.getCurFocus(), '/'))) + f:close() +end + +print('sent: ' .. table.concat(sent, ' ')) diff --git a/scripts/dfhack/dfannals/click.lua b/scripts/dfhack/dfannals/click.lua new file mode 100644 index 0000000..b225a6f --- /dev/null +++ b/scripts/dfhack/dfannals/click.lua @@ -0,0 +1,28 @@ +--@ module = true +-- 在窗口坐标 (x, y) 模拟一次左键点击。用法:dfhack-run "dfannals/click 160 40" +-- 方式与 DFHack 自带测试工具一致:设置鼠标位置后 simulateInput('_MOUSE_L')。 +local gui = require('gui') + +local args = {...} +local x, y = tonumber(args[1]), tonumber(args[2]) +if not x or not y then + print('用法: dfannals/click ') + return +end + +df.global.gps.mouse_x = math.floor(x) +df.global.gps.precise_mouse_x = math.floor(x) * df.global.gps.tile_pixel_x +df.global.gps.mouse_y = math.floor(y) +df.global.gps.precise_mouse_y = math.floor(y) * df.global.gps.tile_pixel_y + +local scr = dfhack.gui.getCurViewscreen() +gui.simulateInput(scr, '_MOUSE_L') + +local f = io.open('dfannals-act.log', 'a') +if f then + f:write(string.format('%s\tclick %d,%d\tfocus=%s\n', os.date('%H:%M:%S'), x, y, + table.concat(dfhack.gui.getCurFocus(), '/'))) + f:close() +end + +print(string.format('clicked %d,%d', x, y)) diff --git a/scripts/dfhack/dfannals/dump.lua b/scripts/dfhack/dfannals/dump.lua new file mode 100644 index 0000000..4c1dbdc --- /dev/null +++ b/scripts/dfhack/dfannals/dump.lua @@ -0,0 +1,57 @@ +--@ module = true +-- 把当前界面的字段结构导出到 dfannals-dump.txt,用于摸清 UI 元素。 +-- 用法:dfhack-run "dfannals/dump" +local MAX_ITEMS = 400 + +local function is_scalar(v) + local t = type(v) + return t == 'number' or t == 'string' or t == 'boolean' +end + +local function describe(obj, max_depth) + local out = {} + local function rec(o, prefix, depth) + if depth > max_depth or #out > MAX_ITEMS then return end + local ok, iter, state, ctrl = pcall(pairs, o) + if not ok then return end + for k, v in iter, state, ctrl do + if #out > MAX_ITEMS then return end + local key = tostring(k) + if is_scalar(v) then + out[#out + 1] = string.format('%s%s = %s', prefix, key, tostring(v)) + else + local t = type(v) + out[#out + 1] = string.format('%s%s <%s>', prefix, key, t) + if t == 'table' or t == 'userdata' then + rec(v, prefix .. ' ', depth + 1) + end + end + end + end + rec(obj, '', 1) + return out +end + +local f = io.open('dfannals-dump.txt', 'w') +if not f then return end + +local function write(line) f:write(line .. '\n') end + +write('# focus=' .. table.concat(dfhack.gui.getCurFocus(), '/')) +write('# date=' .. os.date('%Y-%m-%d %H:%M:%S')) + +local scr = dfhack.gui.getCurViewscreen() +local depth = 0 +while scr and depth < 3 do + write('==== 屏幕层 ' .. depth .. ' ====') + local ok, lines = pcall(describe, scr, 2) + if ok then + for _, l in ipairs(lines) do write(l) end + else + write('(失败: ' .. tostring(lines) .. ')') + end + scr = scr.parent + depth = depth + 1 +end + +f:close() diff --git a/scripts/dfhack/dfannals/observe.lua b/scripts/dfhack/dfannals/observe.lua new file mode 100644 index 0000000..5384fa9 --- /dev/null +++ b/scripts/dfhack/dfannals/observe.lua @@ -0,0 +1,39 @@ +--@ module = true +-- 观测模式:把界面焦点变化持续追加到 dfannals-observe.log。 +-- 用途:发现"新建世界 → 进入 legends → 导出"之间的真实界面序列。 + +local repeatUtil = require('repeat-util') + +local LOG = 'dfannals-observe.log' +local last = nil +local ticks = 0 + +local function focus_string() + local ok, focus = pcall(dfhack.gui.getCurFocus) + if not ok or not focus then return '?' end + return table.concat(focus, '/') +end + +function tick() + ticks = ticks + 1 + local fs = focus_string() + if fs ~= last then + last = fs + local f = io.open(LOG, 'a') + if f then + f:write(string.format('%s\t%s\n', os.date('%H:%M:%S'), fs)) + f:close() + end + end +end + +function start() + repeatUtil.scheduleEvery('dfannals-observe', 10, tick) + local f = io.open(LOG, 'a') + if f then + f:write('--- observe start ' .. os.date('%Y-%m-%d %H:%M:%S') .. '\n') + f:close() + end +end + +start() diff --git a/scripts/dfhack/dfannals/probe.lua b/scripts/dfhack/dfannals/probe.lua new file mode 100644 index 0000000..b8fe3d4 --- /dev/null +++ b/scripts/dfhack/dfannals/probe.lua @@ -0,0 +1,56 @@ +--@ module = true +-- 把当前界面状态写入 dfannals-probe.txt,供 WSL 侧读取。 +-- 用法(在 WSL 里):"Dwarf Fortress.exe" 所在目录的 dfhack-run.exe dfannals/probe + +local function safe_type(obj) + if not obj then return '?' end + local ok, t = pcall(function() return obj._type end) + if ok and t then return tostring(t) end + return '?' +end + +local function viewstack() + local out = {} + local scr = dfhack.gui.getCurViewscreen() + local depth = 0 + while scr and depth < 15 do + out[#out + 1] = string.format('[%d] %s', depth, safe_type(scr)) + local ok, parent = pcall(function() return scr.parent end) + if not ok then break end + scr = parent + depth = depth + 1 + end + return out +end + +local function world_info() + local lines = {} + local ok, folder = pcall(dfhack.world.ReadWorldFolder) + lines[#lines + 1] = 'world_folder=' .. (ok and tostring(folder) or '?') + local ok2, year = pcall(dfhack.world.ReadCurrentYear) + lines[#lines + 1] = 'current_year=' .. (ok2 and tostring(year) or '?') + local ok3, legends = pcall(dfhack.world.isLegends) + lines[#lines + 1] = 'is_legends=' .. (ok3 and tostring(legends) or '?') + local ok4, fort = pcall(dfhack.world.isFortressMode) + lines[#lines + 1] = 'is_fortress=' .. (ok4 and tostring(fort) or '?') + local ok5, name = pcall(function() return dfhack.translation.translateName(df.global.world.world_data.name) end) + lines[#lines + 1] = 'world_name=' .. (ok5 and tostring(name) or '?') + return lines +end + +function dump() + local lines = {} + local ok, focus = pcall(dfhack.gui.getCurFocus) + lines[#lines + 1] = 'focus=' .. ((ok and focus) and table.concat(focus, '/') or '?') + for _, l in ipairs(world_info()) do lines[#lines + 1] = l end + lines[#lines + 1] = 'viewstack:' + for _, l in ipairs(viewstack()) do lines[#lines + 1] = ' ' .. l end + + local f = io.open('dfannals-probe.txt', 'w') + if f then + f:write(table.concat(lines, '\n') .. '\n') + f:close() + end +end + +dump() diff --git a/scripts/dfhack/dfannals/widgets.lua b/scripts/dfhack/dfannals/widgets.lua new file mode 100644 index 0000000..c4935cb --- /dev/null +++ b/scripts/dfhack/dfannals/widgets.lua @@ -0,0 +1,49 @@ +--@ module = true +-- 遍历当前界面的控件树,导出控件名与矩形坐标,用于定位可点击目标。 +-- 用法:dfhack-run "dfannals/widgets" +local MAX_DEPTH = 8 +local MAX_LINES = 600 + +local function rect_str(w) + local ok, r = pcall(function() return w.rect end) + if not ok or not r then return '' end + local function g(k) + local ok2, v = pcall(function() return r[k] end) + return ok2 and tostring(v) or '?' + end + return string.format('rect=(%s,%s)-(%s,%s)', g('x1'), g('y1'), g('x2'), g('y2')) +end + +local out = {} +local function walk(w, depth) + if not w or depth > MAX_DEPTH or #out > MAX_LINES then return end + local ok, name = pcall(function() return w.name end) + local nm = ok and tostring(name) or '' + local txt = '' + local ok3, t = pcall(function() return w.text end) + if ok3 and t then txt = tostring(t) end + out[#out + 1] = string.format('%s%s %s %s', string.rep(' ', depth), nm, rect_str(w), + txt ~= '' and ('text=' .. txt) or '') + local ok2, children = pcall(function() return w.children end) + if ok2 and children then + for _, c in ipairs(children) do walk(c, depth + 1) end + end +end + +local f = io.open('dfannals-widgets.txt', 'w') +if not f then return end +f:write('# focus=' .. table.concat(dfhack.gui.getCurFocus(), '/') .. '\n') + +local sw, sh = dfhack.screen.getWindowSize() +f:write(string.format('# window=%dx%d\n', sw, sh)) + +local scr = dfhack.gui.getCurViewscreen() +local ok, widgets = pcall(function() return scr.widgets end) +if ok and widgets then + walk(widgets, 0) +else + out[#out + 1] = '(该界面没有 widgets 字段)' +end + +for _, l in ipairs(out) do f:write(l .. '\n') end +f:close() diff --git a/scripts/install-df.sh b/scripts/install-df.sh new file mode 100644 index 0000000..021f02b --- /dev/null +++ b/scripts/install-df.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# 在 Windows 侧安装 矮人要塞 免费版 + DFHack(经 WSL 的 /mnt/c 操作,免管理员权限) +# +# scripts/install-df.sh 安装/校验 +# scripts/install-df.sh --download 重新下载缺的文件 +set -euo pipefail + +WIN_USER="${WIN_USER:-23518}" +ROOT="/mnt/c/Users/${WIN_USER}/DwarfFortress" +DF_ZIP="df_53_16_win.zip" +DFHACK_ZIP="dfhack-53.16-r2-Windows-64bit.zip" +DF_URL="https://www.bay12games.com/dwarves/${DF_ZIP}" +DFHACK_URL="https://github.com/DFHack/dfhack/releases/download/53.16-r2/${DFHACK_ZIP}" + +mkdir -p "$ROOT" +cd "$ROOT" + +download() { + local url="$1" out="$2" + echo "== 下载 $out" + for _ in $(seq 1 300); do + wget -c -q --timeout=30 --tries=2 -O "$out" "$url" && return 0 + sleep 5 + done + echo "下载失败:$url" >&2 + return 1 +} + +if [[ "${1:-}" == "--download" ]]; then + [[ -s "$DF_ZIP" ]] || download "$DF_URL" "$DF_ZIP" + [[ -s "$DFHACK_ZIP" ]] || download "$DFHACK_URL" "$DFHACK_ZIP" +fi + +for f in "$DF_ZIP" "$DFHACK_ZIP"; do + [[ -s "$f" ]] || { echo "缺少 $f(先跑 --download)" >&2; exit 1; } + unzip -t "$f" >/dev/null || { echo "$f 已损坏" >&2; exit 1; } +done + +echo "== 解压游戏本体" +DF_DIR="$(unzip -Z1 "$DF_ZIP" | head -1 | cut -d/ -f1)" +if [[ ! -d "$DF_DIR" ]]; then + unzip -q -o "$DF_ZIP" +fi +echo " 游戏目录:$ROOT/$DF_DIR" + +echo "== 解压 DFHack(覆盖进游戏目录)" +unzip -q -o "$DFHACK_ZIP" -d "$DF_DIR" + +echo "== 校验" +ls -1 "$DF_DIR" | head -20 +if [[ -f "$DF_DIR/Dwarf Fortress.exe" ]]; then + echo " ✓ 启动器存在:Dwarf Fortress.exe" +else + echo " ✗ 未找到 Dwarf Fortress.exe" >&2 +fi +if [[ -d "$DF_DIR/dfhack-config" && -d "$DF_DIR/hack" ]]; then + echo " ✓ DFHack 已就位(hack/ + dfhack-config/)" +else + echo " ✗ DFHack 目录缺失" >&2 +fi +echo "$ROOT/$DF_DIR" diff --git a/src/dfannals.egg-info/PKG-INFO b/src/dfannals.egg-info/PKG-INFO new file mode 100644 index 0000000..5b9c339 --- /dev/null +++ b/src/dfannals.egg-info/PKG-INFO @@ -0,0 +1,6 @@ +Metadata-Version: 2.4 +Name: dfannals +Version: 0.1.0 +Summary: 把矮人要塞的 legends 历史数据写成中文战报体连载 +Requires-Python: >=3.12 +Requires-Dist: defusedxml>=0.7 diff --git a/src/dfannals.egg-info/SOURCES.txt b/src/dfannals.egg-info/SOURCES.txt new file mode 100644 index 0000000..a131817 --- /dev/null +++ b/src/dfannals.egg-info/SOURCES.txt @@ -0,0 +1,14 @@ +README.md +pyproject.toml +src/dfannals/__init__.py +src/dfannals/config.py +src/dfannals/factcheck.py +src/dfannals/legends.py +src/dfannals/llm.py +src/dfannals/publish.py +src/dfannals/timeline.py +src/dfannals.egg-info/PKG-INFO +src/dfannals.egg-info/SOURCES.txt +src/dfannals.egg-info/dependency_links.txt +src/dfannals.egg-info/requires.txt +src/dfannals.egg-info/top_level.txt \ No newline at end of file diff --git a/src/dfannals.egg-info/dependency_links.txt b/src/dfannals.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/dfannals.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/dfannals.egg-info/requires.txt b/src/dfannals.egg-info/requires.txt new file mode 100644 index 0000000..8eed65f --- /dev/null +++ b/src/dfannals.egg-info/requires.txt @@ -0,0 +1 @@ +defusedxml>=0.7 diff --git a/src/dfannals.egg-info/top_level.txt b/src/dfannals.egg-info/top_level.txt new file mode 100644 index 0000000..7648f02 --- /dev/null +++ b/src/dfannals.egg-info/top_level.txt @@ -0,0 +1 @@ +dfannals diff --git a/tests/fixtures/sample-legends.xml b/tests/fixtures/sample-legends.xml new file mode 100644 index 0000000..e2fec67 --- /dev/null +++ b/tests/fixtures/sample-legends.xml @@ -0,0 +1,23 @@ + + +The World of Prophecy +Usthar Adil +0The Hill of FogsFOREST + + 1BoatmurderedDARK_FORTRESS + 2GlazedboltsCAVE + + + 10Urist McMinerDWARF121288MINER + 11Kogan DeathspearGOBLIN44AXEMAN + + + 100The Steel ConfederacyCivilizationDWARF + 101NightcloaksCivilizationGOBLIN + + + 1305hf simple battle event10111 + 2310created site2100 + 3889hf died1011 + + diff --git a/tests/fixtures/sample-legends_plus.xml b/tests/fixtures/sample-legends_plus.xml new file mode 100644 index 0000000..d1e48ce --- /dev/null +++ b/tests/fixtures/sample-legends_plus.xml @@ -0,0 +1,8 @@ + + +The World of Prophecy +Usthar Adil + + 100DWARF + + diff --git a/tests/test_factcheck.py b/tests/test_factcheck.py new file mode 100644 index 0000000..28eabb4 --- /dev/null +++ b/tests/test_factcheck.py @@ -0,0 +1,131 @@ +# pyright: reportMissingImports=false, reportAttributeAccessIssue=false +# 说明:LSP 的 Python 环境看不到本项目包,会把包内 import 误报为缺失模块。 +"""事实校验的回归测试。 + +重点是按 t6 验收契约做**反例测试**:故意植入史料里不存在的人名/地名, +校验器必须把它揪出来;同时史料里真实存在(或属于游戏枚举值)的词不能被误报。 +""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from dfannals import factcheck, legends # noqa: E402 + +FIXTURES = Path(__file__).resolve().parent / "fixtures" + + +def load_world() -> legends.World: + return legends.load( + [FIXTURES / "sample-legends.xml", FIXTURES / "sample-legends_plus.xml"] + ) + + +def test_world_parsed() -> None: + world = load_world() + assert world.name == "The World of Prophecy" + assert world.altname == "Usthar Adil" + assert world.figure_name(10) == "Urist McMiner" + assert world.site_name(1) == "Boatmurdered" + assert world.entity_name(100) == "The Steel Confederacy" + assert len(world.events) == 3 + assert [e.year for e in world.events] == [30, 31, 88] + + +def test_fake_names_are_caught() -> None: + """反例:凭空编造的人名地名必须被抓出来。""" + world = load_world() + text = ( + "Zoltan the Unmaker 从 Mount Doom 出发,袭击了 Urist McMiner 驻守的 Boatmurdered。" + "Kogan Deathspear 事后表示,The World of Prophecy 从未见过如此荒唐的进军。" + ) + found = {s.name for s in factcheck.check(text, world)} + assert "Zoltan" in found or "Zoltan the Unmaker" in found, f"漏掉了假人名:{found}" + assert "Mount Doom" in found or "Doom" in found, f"漏掉了假地名:{found}" + + +def test_real_names_not_flagged() -> None: + """真名与枚举值不能被误报(这是上一轮修掉的假阳性)。""" + world = load_world() + text = ( + "The World of Prophecy 的开局并不壮丽。他是个 GOBLIN 斧手,而对方是个 MINER。" + "Urist McMiner 死在 Boatmurdered,The Steel Confederacy 随后在 Glazedbolts 立城。" + "Usthar Adil 这个别名也见于档案。" + ) + found = {s.name for s in factcheck.check(text, world)} + assert not found, f"真名被误报:{found}" + + +def test_lowercase_source_names_with_pretty_display() -> None: + """回归:DF 程序名全小写,正文做首字母大写后不能被误报。 + + 这个 bug 是在真实世界数据上踩出来的:史料里是 'yemi deermoths', + 正文渲染成 'Yemi Deermoths',校验器当时把 6 个真名全报成了可疑。 + """ + from dfannals.legends import Entity, Figure, Site, World + + w = World(name="mon sagus", altname="the plane of dawn") + w.figures[1] = Figure(id=1, name="yemi deermoths", race="ELF") + w.sites[1] = Site(id=1, name="nutssound") + w.entities[1] = Entity(id=1, name="the washed terrors") + + text = "Yemi Deermoths 死在 Nutssound,The Washed Terrors 动的手;Mon Sagus 记住了这件事。" + found = {s.name for s in factcheck.check(text, w)} + assert not found, f"展示形式的真名被误报:{found}" + + +def test_negative_id_placeholders_not_in_known_names() -> None: + """回归:id 为负表示“无此项”,不能被渲染成专名。""" + from dfannals.legends import Figure, World + + w = World(name="w") + w.figures[1] = Figure(id=1, name="someone") + assert w.figure_name(-1) == "" + assert w.site_name(-1) == "" + assert w.entity_name(-1) == "" + + +def test_annotation_only_when_suspicious() -> None: + world = load_world() + clean = "# 第一章\n\nUrist McMiner 站在 Boatmurdered 的门口。\n" + assert factcheck.annotate(clean, []) == clean + + suspicions = factcheck.check("Zoltan 来了。", world) + assert suspicions, "应判定 Zoltan 可疑" + marked = factcheck.annotate(clean, suspicions) + assert "史料核验提示" in marked and "Zoltan" in marked + + +def test_unsafe_xml_rejected() -> None: + import tempfile + + evil = Path(tempfile.mkdtemp()) / "evil.xml" + evil.write_text( + ']>&x;' + ) + try: + legends.load([evil]) + except ValueError as exc: + assert "DTD" in str(exc) or "实体" in str(exc) + else: + raise AssertionError("含 DTD 的文件未被拦截") + + +def _main() -> int: + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + failed = 0 + for fn in tests: + try: + fn() + print(f" ✓ {fn.__name__}") + except AssertionError as exc: + failed += 1 + print(f" ✗ {fn.__name__}: {exc}") + print(f"\n{len(tests) - failed}/{len(tests)} 通过") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(_main())