删除旧的事件驱动章节(流水账),并同步文档到人物主线

- 移除 output/volumes/v1/chapters/001-1-9.md 与 002-10-19.md(仅普通提交,git 历史保留)
- 移除被取代的写作规范 prompts/chronicler.md 与旧的按年代切章模块
- README 全面改写为人物主线流程、参数位置与踩坑记录
- scripts/dfannals 启动器:自动挑一个装了 defusedxml 的解释器
This commit is contained in:
Chen Yi
2026-10-05 19:32:41 +08:00
parent e0073e90bd
commit 08a032120a
11 changed files with 164 additions and 833 deletions
+125 -92
View File
@@ -1,151 +1,184 @@
# 矮人要塞编年史 · Dwarf Fortress Annals
# 矮人要塞人物连载 · Dwarf Fortress Annals
把《矮人要塞》(Dwarf Fortress) 世界生成的历史数据,写成**中文战报体连载故事**,自动推送到这个仓库。
把《矮人要塞》(Dwarf Fortress) 世界生成的历史数据,变成**跟着人走的中文连载故事**,自动推送到这个仓库。
本仓库是**成品仓**:放小说章节、迷你年表、人物索引,以及生成它们的代码。
原始 `legends` 导出数据(数百 MB)与游戏存档留在本地,不入库。
不是编年史。编年史的主语是年代,读起来必然像流水账(本项目第一版就是这样被否掉的)。
这里的主语是**人**:先从史料里自动挖出一组关系密切、反复交锋的角色,再按因果链切集,
以"这些人物的传记作者"的视角贴着他们写。
---
## 一、它是怎么工作的
## 一、它怎么工作
```
Windows 侧:Dwarf Fortress 53.16 + DFHack 53.16-r2
│ ① 生成世界(Medium 129×129 / 250 年历史)
│ ② 进入 legends 模式,导出 legends.xml + legends_plus.xml
▼ ③ 经 /mnt/c 文件交换(不需要 WSL↔Windows 网络互访)
WSL2 Ubuntu 侧管道
│ ④ 预处理:剔除 DF 塞进名字里的非法控制字符,CP437 → UTF-8
│ ⑤ 解析史料:40 万条事件 / 4 万位人物 → 事件流 + 年表 + 人物索引
│ ⑥ 切章:10 年一窗,每窗按显著度取前 12 条,同类事件≤4(避免整章讣告)
│ ⑦ 写作:要塞编年史官口吻 / 战报体 / 允许自由演绎
│ ⑧ 校验:正文专名必须能在史料里找到,可疑处标注但不阻断
▼ ⑨ git commit & push
Gitea:gitadmin/dwarf-fortress-annals(本仓库)
① 线索挖掘 从 40 万条事件里建人物关系图,找出关系密切、有戏的 3–6 人小圈子
(只用有叙事含义的互动:对战/结缘/反目/绑架/构陷/迫害……)
② 切集 沿这条线按因果链切集:相邻事件间隔 >2 年断开,短链合并,过长拆上下集
③ 人物表 表格 + 每人一两句小传;"身份"由事件字段反推,不是猜的
④ 写作 主角的传记作者视角:他们的目标、算计、得失做主语;
世界大事只在影响到主角时提及
⑤ 自评闸门 5 个维度各 0–5 分,总分 <12 判"流水账" → 先重写一次 →
仍不合格就换下一条线索,并把放弃原因写进 notes/
⑥ 发布 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<N>/chapters/` | 连载正文 | ✅ |
| `output/volumes/v<N>/cast.md` | 人物表(表格 + 小传) | ✅ |
| `output/volumes/v<N>/timeline.md` | 迷你年表 | ✅ |
| `output/volumes/v<N>/figures.md` | 人物索引 | ✅ |
| `output/volumes/v<N>/figures.md` | 人物索引(按史料出场次数排序) | ✅ |
| `notes/switched-threads.md` | 被放弃的线索及原因 | ✅ |
| `dfannals/` | 管道源码 | ✅ |
| `scripts/` | 安装、部署、驱动脚本 | ✅ |
| `prompts/chronicler.md` | 写作规范(决定文风与人设) | ✅ |
| `tests/` | 事实校验回归测试(含合成史料样例) | ✅ |
| `prompts/biographer.md` | 写作规范(决定视角、边界、篇幅) | ✅ |
| `scripts/` | 安装、部署、驱动、启动器 | ✅ |
| `tests/` | 回归测试(解析、切集、闸门、事实校验) | ✅ |
| `data/exports/` | 原始 legends 数据与预处理缓存 | ❌ |
| `data/state.json` | 进度状态(当前世界 / 卷号 / 已写章节) | ❌ |
| `data/GRILL-ME.md` | 立项访谈记录 | ❌ |
| `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(用到 `X \| None` 语法) |
| defusedxml | `sudo apt install python3-defusedxml`(解析不可信 XML 用) |
| 写作模型 | 经 `https://wb2api.hajim1.art/v1`(OpenAI 兼容)调用 `cn:deepseek-v4-pro` |
| 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` |
| 发布 | 现有 `~/.ssh/id_ed25519`(已注册到 Gitea 账号 gitadmin) |
> 无独显也能跑:DF 本体不吃 GPU;写故事走云端 API,不做本地推理。
**启动器**:一律用 `scripts/dfannals ...`,不要直接 `python3 -m dfannals.cli`。
会话 PATH 上的 `python3` 可能是编辑器工具链自建的 venv(没有 defusedxml,
且关闭了 user site),启动器会自动挑一个能 `import defusedxml` 的解释器。
## 四、安装
## 五、安装
```bash
# 1) Windows 侧装游戏与 DFHack(经 WSL 的 /mnt/c 操作,免管理员权限)
scripts/install-df.sh --download # Bay12 与 GitHub 在国内较慢,会断点续传
# GitHub 直连不通时改用镜像:https://gh-proxy.com/<原地址>
# 2) 装依赖
scripts/install-df.sh --download # 装 DF + DFHack(Bay12 慢,会断点续传)
sudo apt install -y python3-defusedxml
# 3) 把驱动脚本部署进游戏目录
scripts/deploy-dfhack.sh /mnt/c/Users/$USER_WIN/DwarfFortress
scripts/deploy-dfhack.sh /mnt/c/Users/$USER_WIN/DwarfFortress # 部署观测/驱动脚本
```
## 五、产出一卷(生成新世界 → 导出 → 连载)
## 六、产出一卷
```bash
# 1) 启动游戏,停在标题界面
"/mnt/c/Users/23518/DwarfFortress/Dwarf Fortress.exe" &
# 1) 生成新世界并导出 legends(详见第九节)
scripts/make-world.sh 3 3 # 3 = Medium 大小,3 = 250 年
# 2) 自动生成世界(Medium / 250 年)并导出 legends 数据到 data/exports/
scripts/make-world.sh 3 3
# 2) 建索引:年表 + 人物索引
scripts/dfannals index
# 3) 一条命令产出下一章并推送
python3 -m dfannals.cli run
# 3) 看候选线索(含信号明细),挑一条
scripts/dfannals threads --top 8 --samples 2
# 4) 看这条线切出的剧集与预算
scripts/dfannals episodes --thread 1
# 5) 生成人物表
scripts/dfannals cast --thread 1
# 6) 逐集产出(自动过闸门;不合格自动重写,再不合格自动换线索)
scripts/dfannals episode --index 1
scripts/dfannals episode --index 2
scripts/dfannals episode --index 3
```
`make-world.sh` 全程从外部驱动游戏,不需要人工点击:
- 用 `dfannals/screentext` 把画面读成字符网格,`scripts/df-screen.py` 按文字定位按钮
- 用 `dfannals/click` 模拟鼠标、`dfannals/setparams` 直接设定世界参数
- 用 `dfannals/legends` 进入 legends 模式(做法取自 DFHack 官方 `open-legends.lua`)
- 用 `dfannals/act` / `devel/send-key` 发按键
> 备选:DF 自带命令行世界生成器 `"Dwarf Fortress.exe" -gen 1 RANDOM "MEDIUM ISLAND"`,
> 可静默生成并导出,但**不留存档**,因此无法继续进 legends 导出,只适合快速验证参数。
## 六、子命令
```bash
python3 -m dfannals.cli status # 当前世界 / 卷号 / 已写章节
python3 -m dfannals.cli plan # 只看切章方案,不调模型
python3 -m dfannals.cli index # 解析 exports → 年表 + 人物索引
python3 -m dfannals.cli next # 生成下一章(--dry-run 只生成不推送)
python3 -m dfannals.cli run # index + next,一条命令走完
python3 tests/test_factcheck.py # 校验器回归测试
```
实测:**约 2.5 分钟 / 章**(解析 33 秒 + 模型 1–2 分钟),单章 1000–1400 字。
实测:**单集约 2–4 分钟**(写作 1 次 + 自评 1 次,若判平淡会多一次重写),篇幅 800–1500 字。
## 七、配置在哪
| 想改什么 | 改哪 |
|---|---|
| 世界大小 / 历史长度 | `scripts/make-world.sh` 的两个档位参数 |
| 切章粒度(几年一章、每章几条、同类上限) | `dfannals/slice.py` 顶部常量 |
| 哪些事件值得写(显著度评分) | `dfannals/score.py` 的 `NARRATIVE` 表 |
| 文风、人设、演绎边界 | `prompts/chronicler.md` |
| 章节字数区间 | `dfannals/config.py` 的 `CHAPTER_MIN/MAX_CHARS` |
| 哪些互动算"关系"、权重、转折标记 | `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` |
| 同人同类事件的重复上限 | `dfannals/volume.py` 的 `PER_KEY_CAP` |
| 模型与网关 | `dfannals/config.py` 的 `PROVIDER` / `MODEL` |
| 发布目标、仓库名 | `dfannals/config.py` 的 `GITEA_*` |
**API key 不入库**:脚本运行时从 `~/.pi/agent/auth.json` 读取,只在内存中,不打印、不落盘、不进 git。
**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 集(--dry-run 不落盘)
```
测试:`/usr/bin/python3 tests/test_factcheck.py`、`test_episodes.py`、`test_gate.py`。
## 九、世界是怎么造出来的
`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/` |
| `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`,不要整体加引号 |
| 切出几千章 | 40 万条事件里绝大多数是流水账(`change hf state` 7.3 万条)。必须先按显著度筛选,再按时间窗分桶 |
| Bay12 下载只有 ~10KB/s | 官网无国内镜像。放后台断点续传即可(16MB 约 27 分钟) |
| GitHub release 下载 0 字节 | 直连被断。用 `https://gh-proxy.com/` 前缀 |
| 观测 / init 脚本不生效 | 53.16 只执行固定几个 init 文件。改用 `dfhack-run` 从外部驱动,更可靠 |
| Bay12 下载只有 ~10KB/s;GitHub 直连 0 字节 | 官网无国内镜像,放后台断点续传;GitHub 用 `https://gh-proxy.com/` 前缀 |
| 自定义 init 脚本不生效 | 53.16 只执行固定几个 init 文件。改用 `dfhack-run` 从外部驱动更可靠 |
## 九、写作设定
## 十一、闸门到底能不能判出流水账
- **叙述者**:世界档案库里的编年史官——少数亲历,多数事后依据档案与口述追述,因此对陈年旧账有看法
- **口吻**:战报体,严肃的骨架 + 诙谐的血肉,允许吐槽
- **语言**:中文;专有名词保留英文原文(首字母大写以便阅读)
- **演绎边界**:允许虚构对白、心理、场景;**不许发明史料里没有的人名地名**,不许改写史实骨架(年份、参与者、胜负)
- **连载**:同一世界长期连载;一部史写完则用 `make-world.sh` 生成新世界开新卷
`scripts/gate-selfcheck.py` 是这个闸门的校准证据(跑两次真实自评):
## 十、当前状态与限制
| 样本 | 总分 | 判定 |
|---|---|---|
| 故意构造的平淡稿(纯事件罗列) | 0/25 | 判平淡 ✓ |
| 场景级有戏剧本 | 16/25 | 通过 ✓ |
- **已完成**:3 卷框架 + 第 1、2 章上线(Mon Sagus 世界,1–250 年,26 章规划)
- **定时任务未启用**:`cron` / 开机自启等稳定运行一段时间后再落地;目前按需手动执行
阈值 12 在两者之间,区分有效。注意:阈值是校准出来的,不是拍出来的——
第一版用"梗概体"当好稿样本时只得 11 分,换了场景级样本才验证出尺度没偏严。
## 十二、当前状态与限制
- **已完成**:第 1 卷 3 集上线 + 人物表 + 年表;旧的两章流水账已用普通提交删除(历史保留)
- **定时任务未启用**:cron / 开机自启等稳定运行一段时间后再落地,目前按需手动执行
- **原始数据不入库**:`data/` 已在 `.gitignore` 中
- **诚实说明**:世界创建各步骤均已逐步实测通过,并已整合为 `make-world.sh`;但该脚本尚未作为**单次完整调用**重跑验证(单次约 15 分钟),首次使用时请留意各阶段日志
- **已知局限**:
- 关系图的边来自成对事件,独狼型人物(独自创作、被自然杀死)进不了线索挖掘
- 多参与者事件(比赛、集体迫害)会把同场者两两连边,圈子的"关系"偏共处而非互动
- 事实校验只拦得住凭空专名;动机、心理、对白全是允许的演绎,没有自动防线
## 十一、声明
## 十三、声明
《Dwarf Fortress》版权归 Bay 12 Games 所有。本仓库只包含由其生成数据的**衍生文本**,
不含游戏本体、美术资源或存档。
+3 -1
View File
@@ -135,8 +135,10 @@ def _bio_prompt(world: World, rows: list[CastRow], events: list[Event]) -> str:
lines = [BIO_INSTRUCTIONS, "", "材料:"]
for row in rows[:BIO_LIMIT]:
facts = [
(
f"{row.name}({row.race or '种族不详'},{row.span},本卷出场 {row.appearances} 次,"
f"身份:{row.identity or '不详'},在故事里是{row.role})"
)
]
for e in events:
if row.fid not in e.figure_ids():
@@ -159,7 +161,7 @@ def _parse_bios(raw: str) -> dict[str, str]:
except json.JSONDecodeError as exc:
raise ValueError(f"小传 JSON 无法解析:{exc}|原文:{raw[:200]}") from None
if not isinstance(data, dict):
raise ValueError("小传返回的不是对象")
raise TypeError("小传返回的不是对象")
return {str(k): str(v) for k, v in data.items()}
+26 -115
View File
@@ -1,59 +1,50 @@
# 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 dataclasses import dataclass, field
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
from dfannals.config import DATA_DIR
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 = []
done: list[str] = field(default_factory=list)
@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:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return cls()
return cls(world=data.get("world", ""), volume=data.get("volume", 1), done=data.get("done", []))
if not isinstance(data, dict):
return cls()
return cls(
world=data.get("world", ""),
volume=data.get("volume", 1),
done=list(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))
path.write_text(
json.dumps({"world": self.world, "volume": self.volume, "done": self.done},
ensure_ascii=False, indent=2),
encoding="utf-8",
)
def mark_done(self, key: str) -> None:
if key not in self.done:
@@ -61,7 +52,11 @@ class State:
def cjk_length(text: str) -> int:
"""按中文习惯计字数:CJK 字符与拉丁单词都算 1。"""
"""按中文习惯计字数:CJK 字符、拉丁字母数字、中文标点各算 1。
统计前先去掉代码块与 Markdown 行首标记(标题、引用、列表符),
使结果接近读者感知的"正文字数"。
"""
without_code = re.sub(r"```.*?```", "", text, flags=re.DOTALL)
without_meta = re.sub(r"^\s*[#>|\-*].*$", "", without_code, flags=re.MULTILINE)
n = 0
@@ -73,87 +68,3 @@ def cjk_length(text: str) -> int:
):
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
-333
View File
@@ -1,333 +0,0 @@
# 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 (
cast,
chronicle,
episodes,
factcheck,
legends,
publish,
threads,
timeline,
)
from dfannals import slice as chapter_slice
from dfannals import volume as volume_mod
from dfannals.config import (
DATA_DIR,
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_threads(args: argparse.Namespace) -> int:
ensure_dirs()
world = load_world(EXPORT_DIR)
found = threads.find_threads(
world,
min_interactions=args.min_interactions,
min_span=args.min_span,
limit=args.top,
)
print(threads.render_report(world, found, per_thread=args.samples))
path = threads.save_json(world, found, DATA_DIR / "threads.json")
print(f"共 {len(found)} 条候选;明细已写入 {path}")
return 0
def cmd_episodes(args: argparse.Namespace) -> int:
ensure_dirs()
world = load_world(EXPORT_DIR)
found = threads.find_threads(
world, min_interactions=args.min_interactions, min_span=args.min_span
)
if not found:
print("没有候选线索")
return 1
if not 1 <= args.thread <= len(found):
print(f"候选只有 {len(found)} 条,--thread 超出范围")
return 1
thread = found[args.thread - 1]
_, planned = volume_mod.extended_plan(world, thread)
print(episodes.render_plan(world, thread, planned))
print()
print(episodes.budget_report(planned))
if args.samples:
print("\n=== 前两集事件样例 ===")
for ep in planned[:2]:
print(f" 第 {ep.index} 集({ep.span})")
for e in ep.events[: args.samples]:
print(f" · {e.year}年 {e.type} " + " · ".join(world.render_refs(e)))
return 0
def cmd_cast(args: argparse.Namespace) -> int:
ensure_dirs()
world = load_world(EXPORT_DIR)
found = threads.find_threads(
world, min_interactions=args.min_interactions, min_span=args.min_span
)
if not found:
print("没有候选线索")
return 1
if not 1 <= args.thread <= len(found):
print(f"候选只有 {len(found)} 条,--thread 超出范围")
return 1
thread = found[args.thread - 1]
_, planned = volume_mod.extended_plan(world, thread)
text, rows, suspicions = cast.build_cast(
world, thread, planned, with_bios=not args.no_bios
)
if args.dry_run:
print(text[:2000])
return 0
state = chronicle.State.load()
volume = state.volume if state.world == world.name else 1
path = cast.write_cast(volume_dir(volume) / "cast.md", text)
print(f"人物表已写入 {path}\n涵盖 {len(rows)} 人:{', '.join(r.name for r in rows)}")
print(factcheck.report(suspicions) if suspicions else "专名校验:小传里的专名全部能在史料中找到。")
return 0
def cmd_episode(args: argparse.Namespace) -> int:
ensure_dirs()
world = load_world(EXPORT_DIR)
found = threads.find_threads(
world, min_interactions=args.min_interactions, min_span=args.min_span
)
if not found:
print("没有候选线索")
return 1
out_dir = None if args.dry_run else chapter_dir(1)
produced = volume_mod.produce_episode(
world, found, episode_index=args.index, out_dir=out_dir
)
print("选择依据:" + produced.selection.reason)
print(f"本集字数:{chronicle.cjk_length(produced.text)}")
if args.dry_run:
print("\n--- 试运行,不落盘 ---\n")
print(produced.text[:1600])
return 0
print(f"已写入 {produced.path}")
if not args.no_push:
publish.ensure_repo()
head = publish.commit_and_push(
f"第 1 卷第 {produced.episode.index} 集(人物主线):{produced.episode.span}"
)
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("threads", help="挖掘人物线索候选")
p.add_argument("--min-interactions", type=int, default=threads.DEFAULT_MIN_INTERACTIONS,
help="强边阈值:一对人物至少反复互动多少次(默认 5)")
p.add_argument("--min-span", type=int, default=threads.DEFAULT_MIN_SPAN,
help="线索最小跨度年数(默认 30)")
p.add_argument("--top", type=int, default=0, help="只显示前 N 条(0 为全部)")
p.add_argument("--samples", type=int, default=3, help="每条线索展示几条事件样例")
p.set_defaults(func=cmd_threads)
p = sub.add_parser("episodes", help="按人物线索切出剧集")
p.add_argument("--thread", type=int, default=1, help="用第几条候选线索(默认 1)")
p.add_argument("--min-interactions", type=int, default=threads.DEFAULT_MIN_INTERACTIONS)
p.add_argument("--min-span", type=int, default=threads.DEFAULT_MIN_SPAN)
p.add_argument("--samples", type=int, default=0, help="额外打印每集前 N 条事件")
p.set_defaults(func=cmd_episodes)
p = sub.add_parser("cast", help="生成每卷人物表(表格 + 小传)")
p.add_argument("--thread", type=int, default=1, help="用第几条候选线索(默认 1)")
p.add_argument("--min-interactions", type=int, default=threads.DEFAULT_MIN_INTERACTIONS)
p.add_argument("--min-span", type=int, default=threads.DEFAULT_MIN_SPAN)
p.add_argument("--no-bios", action="store_true", help="只出表格,不让模型写小传")
p.add_argument("--dry-run", action="store_true", help="只打印不落盘")
p.set_defaults(func=cmd_cast)
p = sub.add_parser("episode", help="按人物主线产出下一集")
p.add_argument("--index", type=int, default=1, help="写该线索的第几集(默认 1)")
p.add_argument("--min-interactions", type=int, default=threads.DEFAULT_MIN_INTERACTIONS)
p.add_argument("--min-span", type=int, default=threads.DEFAULT_MIN_SPAN)
p.add_argument("--dry-run", action="store_true", help="只生成不落盘、不推送")
p.add_argument("--no-push", action="store_true", help="落盘但不推送")
p.set_defaults(func=cmd_episode)
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())
+5 -3
View File
@@ -97,7 +97,7 @@ def _parse(raw: str) -> dict:
except json.JSONDecodeError as exc:
raise ValueError(f"自评返回的 JSON 无法解析:{exc}|原文:{raw[:200]}") from None
if not isinstance(data, dict):
raise ValueError(f"自评返回的不是对象:{type(data).__name__}")
raise TypeError(f"自评返回的不是对象:{type(data).__name__}")
return data
@@ -120,8 +120,10 @@ def evaluate(
scores: dict[str, int] = {}
for key, _label, _hint in DIMENSIONS:
value = data.get(key)
if not isinstance(value, (int, float)):
raise ValueError(f"自评维度 {key} 缺失或非数字:{data.get(key)!r}")
if value is None:
raise ValueError(f"自评维度 {key} 缺失:{data!r}")
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError(f"自评维度 {key} 不是数字:{value!r}")
scores[key] = max(0, min(MAX_SCORE, int(value)))
reason = str(data.get("reason", "")).strip() or "(未给出理由)"
-184
View File
@@ -1,184 +0,0 @@
"""把史料切成章节素材。
真实数据(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:
# render_refs 会带出双方(比如 slayer_hfid=谁),而不是只剩单方面 hfid
chunks = [f"{e.year}年", e.type, *world.render_refs(e)]
for i in e.figure_ids():
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)
-26
View File
@@ -1,26 +0,0 @@
# 第一章:黎明之地的第一场火
我翻开 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年三次和约与一次联盟建立。*
-22
View File
@@ -1,22 +0,0 @@
# 第十一年,三笔死账与一纸废纸
上一卷我合上时,还指望十年往后能省点墨。结果第十年 The Coalition Of Rags 建了个 Charcoalmop,我原以为是和平年间百无聊赖的拓荒。
可第10年还没过完,Pathlance 传回死讯:Uthra Hardyrake,COLOSSUS_BRONZE,死了。档案里它出现过八次,次次都不是小事。现在只剩一句“死亡”,没敌军,没战斗编号,没有遗言。那位 COLOSSUS_BRONZE 倒下,像把一座山从账本上划掉。
真正的乱子在11年。The Kingdom Of Mystery 洗了 Stretchedvice,这地方归 The Ruthlessness Of Elevating 管。两个 GOBLIN——Zolak Werecoil 和 Utes Lieshut——死在城里。同一年,档案并排躺着“和平被拒绝”和“和平被接受”。前一页摔门,后一页握手,中间隔了多久,没人记。使节的面子和膝盖,总得碎一个。
Praisepot 这笔更糊涂。它名义上是 The Robust Band 的聚落,The Speechless Fellowship 上门劫掠,冲上来守的却是 The Ace Confederations。Hiner Plaitedangels,一个 HUMAN,死在这场乱子里。这还没完:12年 The Speechless Fellowship 又去抢了一次,还是 The Ace Confederations 挨打,The Robust Band 依然没露面。
我已经不知道该替谁难为情了。
同样是11年,Oilywheels 被夷平。那块地归 The Figure Of Clearings,守是 The Righteous Trumpets 在守,毁掉它的所谓 historical figure 连名字都没留下。这最可恨:你想报仇,不知道刀朝谁磨。像有人烧了房子,登记簿上只写“有人放火”。
第十七年,和约又被接受了。这次档案干净得让人发毛——没谈判方,没条款,没附带声明,只有一句 peace accepted。抄录的人写到那时,恐怕已经不信“和平”这两个字了。
这十年,Charcoalmop 建了,Oilywheels 没了,Stretchedvice 和 Praisepot 各挨一顿。两个 GOBLIN、一个 HUMAN、一个 COLOSSUS_BRONZE 死得不明不白。The Robust Band 全程像块肥肉躺着,The Ace Confederations 白挨两顿,The Speechless Fellowship 抢完也没个说法。
我不合这卷了,直接翻下一页。债越积越厚,总该有人来讨。
---
*史料来源:legends 导出,10年 Charcoalmop 建立、Uthra Hardyrake 死亡;11年 Stretchedvice 被劫掠及 Zolak Werecoil、Utes Lieshut 死亡,和约先拒后受,Praisepot 被劫掠及 Hiner Plaitedangels 死亡,Oilywheels 被毁;12年 Praisepot 再度被劫掠;17年和平接受。*
-52
View File
@@ -1,52 +0,0 @@
# 要塞编年史官 · 写作规范
你是一本矮人要塞编年史的**执笔者**。你不是游戏外的解说员,而是这个世界里负责记录的人:
你守着档案库,把发生过的事一件件记下来;少数是你亲历的,多数是你事后翻档案、听幸存者口述追述的。
正因如此,你对这些陈年旧帐有自己的看法——而且你很乐意说出来。
## 口吻
- **战报体**:像一份写给上级、但作者本人憋不住嘴的报告。严肃的骨架,诙谐的血肉。
- 允许吐槽、暗讽、夸张、替死人惋惜、替蠢货翻白眼。可以骂人,但骂得有趣。
- 情绪可以饱满,但叙事要清楚:读者必须始终知道**谁、在哪、干了什么、结果如何**。
## 语言
- 正文用**中文**。
- 所有**专有名词保留英文原文**:人物名(Urist McMiner)、地名(Boatmurdered)、
文明名、生物名、神器名。不要音译,不要自造中文译名。
- 不要把英文词硬塞成中式表达;名字是个代号,句子本身是中文。
## 演绎的边界
- **允许**:虚构对白、心理活动、场景细节、围观群众的反应。
- **允许**:把数据里冷冰冰的因果,写成有人味的故事。
- **禁止**:发明数据里不存在的**专有人名、地名、文明名**。新角色只能来自给定的史料。
- **禁止**:改变大事件的年份、参与者、胜负结果。史实骨架不可动。
- 若史料本身残缺(只留下一个年份和一句话),就按残缺写——写成"史料到此中断"也是一种味道。
## 篇幅
- 正文 **800–1500 字**(中文字符计)。
- 宁可写短写实,不要为凑字数灌水。
## 输出格式
```
# <章标题>
<正文,可分段,可用小标题>
---
*史料来源:<本段所依据的史料条目摘要,一到两行>*
```
- 章标题自己起,要有战报味,能概括本章。
- 不要在正文里写"以下是……"之类的话,直接进入叙述。
- 不要在正文中提到你是在读数据、表格、XML 或 AI。
## 连续性
- 这是一部长篇连载的其中一章。读者已经读过前面几章。
- 前情提要用一两句带过即可,不要复述整部前文。
- 同一批人物、家族、恩怨要延续上一章的状态;本章结束时应留下可被下一章接住的线头。
+1 -1
View File
@@ -16,7 +16,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from dfannals import gate # noqa: E402
from dfannals import gate
DULL = """# 第二十三章:大事记
+2 -2
View File
@@ -13,7 +13,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from dfannals import gate # noqa: E402
from dfannals import gate
def verdict(total: int, reason: str = "测试用") -> gate.Verdict:
@@ -57,7 +57,7 @@ def test_rewrite_once_then_pass() -> None:
def test_not_accepted_after_max_rewrites() -> None:
"""一直不合格:重写次数不得越过上限,且标记为未通过。"""
runs: list[int] = []
text, run = gate.gate_episode(
_text, run = gate.gate_episode(
"003",
lambda attempt, _p: (runs.append(attempt), "稿")[1],
evaluate_fn=lambda _t: verdict(3),