# 矮人要塞人物连载 · Dwarf Fortress Annals 把《矮人要塞》(Dwarf Fortress) 世界生成的历史数据,变成**跟着人走的中文连载故事**,自动推送到这个仓库。 不是编年史。编年史的主语是年代,读起来必然像流水账(本项目第一版就是这样被否掉的)。 这里的主语是**人**:先从史料里自动挖出一组关系密切、反复交锋的角色,再按因果链切集, 以"这些人物的传记作者"的视角贴着他们写。 --- ## 一、它怎么工作 ```text ① 线索挖掘 从 40 万条事件里建人物关系图,找出关系密切、有戏的 3–6 人小圈子 (只用有叙事含义的互动:对战/结缘/反目/绑架/构陷/迫害……) ② 切集 沿这条线按因果链切集:相邻事件间隔 >2 年断开,短链合并,过长拆上下集 ③ 人物表 表格 + 每人一两句小传;"身份"由事件字段反推,不是猜的 ④ 写作 主角的传记作者视角:他们的目标、算计、得失做主语; 世界大事只在影响到主角时提及 ⑤ 自评闸门 5 个维度各 0–5 分,总分 <12 判"流水账" → 先重写一次 → 仍不合格就换下一条线索,并把放弃原因写进 notes/ ⑥ 文笔层 骨架稿扩写成成稿:按年份场景分块扩写(目标 8000 字), 全程做 AI 味机检、标点规范化,并用专名白名单防幻觉 ⑦ 本土化 英文专名换成中文译名:人名=音译+绰号意译,地名整体意译 ⑧ 发布 git commit & push 到本仓库(原始史料与存档不入库) ``` ## 二、这一卷的主角 | | | |---|---| | 世界 | Mon Sagus(The Plane of Dawn),Medium 129×129 / 250 年历史 | | 主角组 | 古斯普·褶结(Guspu Frillyknots,鹰人掮客)、拉尔·缚斧(Ral Fastenhatchets,矮人)、阿拉斯·污矿(Alath Blottedmine,矮人) | | 线索 | 46 年间一个掮客反复收买、构陷一位管货栈的矮人,屡屡失手 | | 选线依据 | 挖掘器排序第一名:有效互动 11 次、转折 11 次、阴谋×8 + 构陷×3 | 详见 [`output/volumes/v1/cast.md`](output/volumes/v1/cast.md)。 ## 三、目录 | 路径 | 内容 | 入库 | |---|---|---| | `output/volumes/v/chapters/` | 连载正文:`NNN-….md` 是成稿,`NNN-….skeleton.md` 是过闸门的骨架稿 | ✅ | | `output/volumes/v/cast.md` | 人物表(表格 + 小传) | ✅ | | `output/volumes/v/timeline.md` | 迷你年表 | ✅ | | `output/volumes/v/figures.md` | 人物索引(按史料出场次数排序) | ✅ | | `notes/switched-threads.md` | 被放弃的线索及原因 | ✅ | | `dfannals/` | 管道源码 | ✅ | | `prompts/biographer.md` | 写作规范(决定视角、边界、篇幅) | ✅ | | `prompts/literary-expander.md` | 扩写规范(逐事件展开、禁用词、标点) | ✅ | | `dfannals/glossary.py` | **冻结的译名表**(词干/姓/地名/种族) | ✅ | | `dfannals/localize.py` | 专名本土化(渲染层,不改史料) | ✅ | | `dfannals/deslop.py` | AI 味机械诊断(禁用词/句式/标点,按千字密度) | ✅ | | `dfannals/normalize.py` | 标点与结构清理(引号、重复段、模型自加的小节标记) | ✅ | | `dfannals/expand.py` | 分块大量扩写 + 专名白名单 + 段级返修 | ✅ | | `scripts/` | 安装、部署、驱动、启动器 | ✅ | | `tests/` | 回归测试(解析、切集、闸门、事实校验、机检、清理、本土化) | ✅ | | `data/exports/` | 原始 legends 数据与预处理缓存 | ❌ | | `data/state.json`、`data/threads.json`、`data/selection.json` | 进度、候选、选线依据 | ❌ | ## 四、依赖 | 依赖 | 版本 / 说明 | |---|---| | Dwarf Fortress | **53.16** 免费版(Bay 12),必须与 DFHack 严格配对 | | DFHack | **53.16-r2**,Windows 版经 `dfhooks.dll` 注入,不必替换启动器 | | Python | 3.12 | | defusedxml | `sudo apt install -y python3-defusedxml` | | 写作/自评模型 | 经 `https://wb2api.hajim1.art/v1`(OpenAI 兼容)调用 `cn:deepseek-v4-pro`(骨架与自评) | | 扩写模型 | 同上网关的 `cn:minimax-m3`:叙事向模型写场景更贴地,推理向模型天然偏总结陈词 | | 发布 | 现有 `~/.ssh/id_ed25519`(已注册到 Gitea 账号 gitadmin) | **启动器**:一律用 `scripts/dfannals ...`,不要直接 `python3 -m dfannals.cli`。 会话 PATH 上的 `python3` 可能是编辑器工具链自建的 venv(没有 defusedxml, 且关闭了 user site),启动器会自动挑一个能 `import defusedxml` 的解释器。 ## 五、安装 ```bash scripts/install-df.sh --download # 装 DF + DFHack(Bay12 慢,会断点续传) sudo apt install -y python3-defusedxml scripts/deploy-dfhack.sh /mnt/c/Users/$USER_WIN/DwarfFortress # 部署观测/驱动脚本 ``` ## 六、产出一卷 ```bash # 1) 生成新世界并导出 legends(详见第九节) scripts/make-world.sh 3 3 # 3 = Medium 大小,3 = 250 年 # 2) 建索引:年表 + 人物索引 scripts/dfannals index # 3) 看候选线索(含信号明细),挑一条 scripts/dfannals threads --top 8 --samples 2 # 4) 看这条线切出的剧集与预算 scripts/dfannals episodes --thread 1 # 5) 生成人物表 scripts/dfannals cast --thread 1 # 6) 逐集产出:骨架过闸门 → 分块扩写成成稿(骨架另存 .skeleton.md) scripts/dfannals episode --index 1 scripts/dfannals episode --index 2 scripts/dfannals episode --index 3 # 7) 只跑文笔层:拿已有的某一集骨架做大量扩写(A/B 对照,不覆盖原稿) scripts/dfannals expand --index 1 --target 8000 ``` 实测:**单集 3–8 分钟**(骨架写作 1 次 + 自评 1 次 + 分块扩写 5 次,若判平淡会多一次重写); 骨架稿 800–1500 字,成稿约 8000–12000 字。 ## 七、配置在哪 | 想改什么 | 改哪 | |---|---| | 哪些互动算"关系"、权重、转折标记 | `dfannals/threads.py` 的 `SIGNALS` | | 主线口径(≥5 次互动、3–6 人、跨度 ≥30 年) | `dfannals/threads.py` 顶部常量 | | 切集参数(间隔 >2 年断开、每集预算) | `dfannals/episodes.py` 顶部常量 | | 自评维度、阈值 12、重写/换线上限 | `dfannals/gate.py` 的 `DIMENSIONS` / `THRESHOLD` / `MAX_REWRITES` / `MAX_CANDIDATES` | | 视角、文风、演绎边界、篇幅 | `prompts/biographer.md` | | 扩写规格(逐事件展开、禁用词、标点) | `prompts/literary-expander.md` | | 译名(人名/地名/种族) | `dfannals/glossary.py`——**译名必须冻结**,否则同一角色每集会换名字 | | 扩写目标字数、分块大小、AI 味规则 | `dfannals/config.py` 的 `EXPAND_*`、`dfannals/deslop.py` 的 `HARD_WORDS`/`PATTERNS` | | 同人同类事件的重复上限 | `dfannals/volume.py` 的 `PER_KEY_CAP` | | 模型与网关 | `dfannals/config.py` 的 `PROVIDER` / `MODEL` / `LITERARY_MODEL` | | 发布目标、仓库名 | `dfannals/config.py` 的 `GITEA_*` | **API key 不入库**:运行时从 `~/.pi/agent/auth.json` 读取,只在内存中,不打印、不落盘、不进 git。 ## 八、子命令 ```bash scripts/dfannals status 当前世界 / 卷号 / 已完成集数 scripts/dfannals index 解析 exports → 年表 + 人物索引 scripts/dfannals threads 候选线索与信号明细 scripts/dfannals episodes --thread N 某条线索的剧集规划与预算 scripts/dfannals cast --thread N 人物表(--no-bios 只出表格) scripts/dfannals episode --index N 产出第 N 集(骨架 + 扩写) --no-expand 只出骨架|--target 8000|--model 换模型 --dry-run 不落盘、不推送 scripts/dfannals expand --index N 只跑文笔层:把已有的一集大量扩写(A/B 对照) ``` 测试:`for t in tests/test_*.py; do /usr/bin/python3 "$t"; done` (六个文件:解析与事实校验 12 项、切集 7 项、闸门 6 项、AI 味机检 7 项、标点清理 9 项、专名本土化 13 项) ## 九、世界是怎么造出来的 `scripts/make-world.sh` 全程从外部驱动游戏,不需要人工点击: - `dfannals/screentext` 把画面读成字符网格,`scripts/df-screen.py` 按文字定位按钮 - `dfannals/click` 模拟鼠标,`dfannals/setparams` 直接改内存里的世界生成参数 - `dfannals/legends` 进入 legends 模式(做法取自 DFHack 官方 `open-legends.lua`) - 导出 `Export XML`(原生 legends.xml)与 `exportlegends`(扩展数据) > 备选:`"Dwarf Fortress.exe" -gen 1 RANDOM "MEDIUM ISLAND"` 可静默生成并导出, > 但**不留存档**,无法继续进 legends,只适合快速验证参数。 ## 十、踩过的坑(失败排查参考) | 症状 | 原因与解法 | |---|---| | `not well-formed (invalid token)` | DF 在名字里嵌了 `\x10`/`\x11` 等 CP437 控制字符,XML 1.0 禁止。预处理会剔除并转 UTF-8,缓存到 `data/exports/clean/` | | 事件数莫名翻倍 | 预处理缓存若与原始文件同目录,会被 `*.xml` 扫描当成第二份史料。缓存因此放在子目录 | | 素材里出现 `HF#-1`、`Site#-1` | DF 用负数占位表示"无此项"。查询接口对负 id 返回空串,渲染时跳过 | | 人物关系在素材里丢失 | 真实史料用 `hfid_target`/`group_1_hfid`/`snatcher_hfid`/`seeker_hfid`/`hfid1`… 共 40+ 个字段表达关系;只认 `hfid` 会把"谁对谁做了什么"丢掉。现按"字段名含 hfid 即为人物"分类 | | 校验器把真名报成可疑 | 史料里是全小写程序名(`yemi deermoths`),正文做了首字母大写。已知名字集同时收录两种形式(有回归测试) | | 候选线索全是"同一对人反复对战" | 裸互动次数会被机械重复刷满(第一名 49 次里有 35 次是"反复请求拜师被拒")。现剔除重复性请求,并对同人同类事件封顶、要求 ≥2 种信号 | | 主角组里混进泰坦巨兽 | 用"文明种族白名单(五族 + `*_MAN`)",实测覆盖 96.5% 的历史人物 | | 一集被均分成两个 770 字的碎片 | 均分只看条数、不看预算。改为先取最小段数,再确保每段不低于下限 | | 自评/小传返回空正文 | 推理模型的思考与正文共用 max_tokens,喂长材料时会烧光预算。结构化输出统一用 `MAX_TOKENS_STRUCTURED` | | `ModuleNotFoundError: defusedxml` | PATH 上的 `python3` 被编辑器工具链的 venv 抢走了。用 `scripts/dfannals` 启动器 | | `dfhack-run "cmd a b"` 报 not recognized | 参数要分开传:`dfhack-run dfannals/click 10 2`,不要整体加引号 | | Bay12 下载只有 ~10KB/s;GitHub 直连 0 字节 | 官网无国内镜像,放后台断点续传;GitHub 用 `https://gh-proxy.com/` 前缀 | | 自定义 init 脚本不生效 | 53.16 只执行固定几个 init 文件。改用 `dfhack-run` 从外部驱动更可靠 | | 编造的地名混过校验 | 正则里的 `\b` 在 Unicode 下把汉字也当词字符,`在Mistbreath的` 这种**紧贴汉字的英文名根本不会被检查**。改成前后看「是不是英文字母」后才抓得住 | | 校验器把 `Yeti` 报成可疑 | 种族值在史料里是全大写(`YETI`),展示形式是 `Yeti`。只改首字母不转小写就永远对不上;现在两种写法都收,且比较时忽略大小写 | | 一集里冒出 14 处破折号 | 扩写提示词明确禁用也拦不住。确定性替换会读着别扭,改为**定点返修**:只把命中的段落发回去重写(硬伤密度 1.5→0.0/千字) | | 正文里出现 `## 一`、孤立的「七」 | 分块扩写时模型每写一块就自制编号小节。清理阶段按「非首行的标题行/纯数字行」删除 | | 同一句被写两遍、段落跨块重复 | 分块之间会重写上一块的结尾。清理阶段合并相邻重复段落与段内重复句子 | | 扩写稿里满屏英文引号 | 模型用 ASCII `"`,统计对白时会误判成「没有对白」。按出现顺序配对换成中文引号 | ## 十一、闸门到底能不能判出流水账 `scripts/gate-selfcheck.py` 是这个闸门的校准证据(跑两次真实自评): | 样本 | 总分 | 判定 | |---|---|---| | 故意构造的平淡稿(纯事件罗列) | 0/25 | 判平淡 ✓ | | 场景级有戏剧本 | 16/25 | 通过 ✓ | 阈值 12 在两者之间,区分有效。注意:阈值是校准出来的,不是拍出来的—— 第一版用"梗概体"当好稿样本时只得 11 分,换了场景级样本才验证出尺度没偏严。 **真实的拒绝案例**:第 3 集的骨架连换三条线索、每轮重写一次,仍然没过 (11→10、8→11、7→8 分)。管道按设计报"需要人工干预"而不是硬推。 这说明闸门在"薄材料"上会持续拒绝——正确的反应是回退到已有骨架再扩写, 而不是把阈值调低。 ## 十二、当前状态与限制 - **已完成**:第 1 卷 3 集成稿(9492 / 11260 / 11869 字)+ 人物表 + 年表; 旧的两章流水账已用普通提交删除(git 历史保留) - **每集两稿**:`NNN-….md` 是成稿,`NNN-….skeleton.md` 是过闸门的骨架稿,便于溯源 - **定时任务未启用**:cron / 开机自启等稳定运行一段时间后再落地,目前按需手动执行 - **原始数据不入库**:`data/` 已在 `.gitignore` 中 - **已知局限**: - 关系图的边来自成对事件,独狼型人物(独自创作、被自然杀死)进不了线索挖掘 - 多参与者事件(比赛、集体迫害)会把同场者两两连边,圈子的"关系"偏共处而非互动 - 事实校验只拦得住凭空专名,**且只认英文形态**:本土化之后,编造的中文名只能靠 "发给模型的白名单 + 段级返修"来防,没有机械防线 - 闸门判的是**骨架稿**,不判扩写后的成稿。成稿有机械指标(AI 味密度、专名校验), 但戏剧性没有自动闸门 - 译名表是手工冻结的:遇到表外的新名字会原样保留英文并进待译清单, 不会自己造一个音译(宁可难看,也不让同一角色每集换名字) ## 十三、声明 《Dwarf Fortress》版权归 Bay 12 Games 所有。本仓库只包含由其生成数据的**衍生文本**, 不含游戏本体、美术资源或存档。