文档与脚本:世界创建自动化 make-world.sh、完整运行文档与失败排查

This commit is contained in:
Chen Yi
2026-10-05 17:34:23 +08:00
parent 494cfa1ce9
commit 594cf48a1a
2 changed files with 221 additions and 32 deletions
+129 -32
View File
@@ -2,53 +2,150 @@
把《矮人要塞》(Dwarf Fortress) 世界生成的历史数据,写成**中文战报体连载故事**,自动推送到这个仓库。 把《矮人要塞》(Dwarf Fortress) 世界生成的历史数据,写成**中文战报体连载故事**,自动推送到这个仓库。
本仓库是**成品仓**:只放小说章节、迷你年表与人物索引。 本仓库是**成品仓**:放小说章节、迷你年表、人物索引,以及生成它们的代码。
原始 `legends` 导出数据与游戏存档留在本地,不入库。 原始 `legends` 导出数据(数百 MB)与游戏存档留在本地,不入库。
## 这本"书"是怎么来的 ---
## 一、它是怎么工作的
``` ```
Windows 侧 DF 53.16 + DFHack 53.16-r2 Windows 侧:Dwarf Fortress 53.16 + DFHack 53.16-r2
│ ① 生成 Medium 世界 / 250 年历史 │ ① 生成世界(Medium 129×129 / 250 年历史)
│ ② 进入 legends 模式,导出 legends.xml + legends_plus.xml │ ② 进入 legends 模式,导出 legends.xml + legends_plus.xml
▼ ③ 经 /mnt/c 文件交换(无需网络互访) ▼ ③ 经 /mnt/c 文件交换(不需要 WSL↔Windows 网络互访)
WSL2 Ubuntu 侧管道 WSL2 Ubuntu 侧管道
│ ④ 解析史料 → 构建年表与人物索引 │ ④ 预处理:剔除 DF 塞进名字里的非法控制字符,CP437 → UTF-8
│ ⑤ 按事件链切章(每章 800–1500 字) │ ⑤ 解析史料:40 万条事件 / 4 万位人物 → 事件流 + 年表 + 人物索引
│ ⑥ 组 prompt(要塞编年史官口吻 / 战报体)→ 调用 LLM 写作 │ ⑥ 切章:10 年一窗,每窗按显著度取前 12 条,同类事件≤4(避免整章讣告)
│ ⑦ 专名事实校验:正文里的专有名词必须出现在史料中,可疑处标注 │ ⑦ 写作:要塞编年史官口吻 / 战报体 / 允许自由演绎
▼ ⑧ git commit & push │ ⑧ 校验:正文专名必须能在史料里找到,可疑处标注但不阻断
Gitea: gitadmin/dwarf-fortress-annals(本仓库) ▼ ⑨ git commit & push
Gitea:gitadmin/dwarf-fortress-annals(本仓库)
``` ```
## 目录 ## 二、目录
| 路径 | 内容 | 是否入库 | | 路径 | 内容 | 入库 |
|---|---|---| |---|---|---|
| `output/chapters/` | 连载正文(Markdown) | ✅ | | `output/volumes/v<N>/chapters/` | 连载正文 | ✅ |
| `output/timeline.md` | 迷你年表 | ✅ | | `output/volumes/v<N>/timeline.md` | 迷你年表 | ✅ |
| `output/figures.md` | 人物索引 | ✅ | | `output/volumes/v<N>/figures.md` | 人物索引 | ✅ |
| `src/dfannals/` | 管道源码 | ✅ | | `dfannals/` | 管道源码 | ✅ |
| `prompts/` | 写作规范(决定文风与人设) | ✅ | | `scripts/` | 安装、部署、驱动脚本 | ✅ |
| `data/exports/` | 原始 legends 数据 | ❌ | | `prompts/chronicler.md` | 写作规范(决定文风与人设) | ✅ |
| `vendor/` | 游戏与 DFHack 安装包 | ❌ | | `tests/` | 事实校验回归测试(含合成史料样例) | ✅ |
| `data/exports/` | 原始 legends 数据与预处理缓存 | ❌ |
| `data/state.json` | 进度状态(当前世界 / 卷号 / 已写章节) | ❌ |
| `data/GRILL-ME.md` | 立项访谈记录 | ❌ |
## 写作设定 ## 三、依赖
- **叙述者**:要塞里的编年史官,在场记录,负责把事写下来 | 依赖 | 版本 / 说明 |
- **口吻**:战报体——严肃的骨架,诙谐的血肉 |---|---|
- **语言**:中文;专有名词一律保留英文原文(Urist、Boatmurdered……) | 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` |
| 发布 | 现有 `~/.ssh/id_ed25519`(已注册到 Gitea 账号 gitadmin) |
## 运行 > 无独显也能跑:DF 本体不吃 GPU;写故事走云端 API,不做本地推理。
## 四、安装
```bash ```bash
# 凭据:API key 运行时从 Pi 的 ~/.pi/agent/auth.json 读取,不落本仓库 # 1) Windows 侧装游戏与 DFHack(经 WSL 的 /mnt/c 操作,免管理员权限)
python3 -m dfannals.cli --help scripts/install-df.sh --download # Bay12 与 GitHub 在国内较慢,会断点续传
# GitHub 直连不通时改用镜像:https://gh-proxy.com/<原地址>
# 2) 装依赖
sudo apt install -y python3-defusedxml
# 3) 把驱动脚本部署进游戏目录
scripts/deploy-dfhack.sh /mnt/c/Users/$USER_WIN/DwarfFortress
``` ```
## 声明 ## 五、产出一卷(生成新世界 → 导出 → 连载)
《Dwarf Fortress》版权归 Bay 12 Games 所有。本仓库仅包含由其生成数据的**衍生文本**, ```bash
# 1) 启动游戏,停在标题界面
"/mnt/c/Users/23518/DwarfFortress/Dwarf Fortress.exe" &
# 2) 自动生成世界(Medium / 250 年)并导出 legends 数据到 data/exports/
scripts/make-world.sh 3 3
# 3) 一条命令产出下一章并推送
python3 -m dfannals.cli run
```
`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 字。
## 七、配置在哪
| 想改什么 | 改哪 |
|---|---|
| 世界大小 / 历史长度 | `scripts/make-world.sh` 的两个档位参数 |
| 切章粒度(几年一章、每章几条、同类上限) | `dfannals/slice.py` 顶部常量 |
| 哪些事件值得写(显著度评分) | `dfannals/score.py` 的 `NARRATIVE` 表 |
| 文风、人设、演绎边界 | `prompts/chronicler.md` |
| 章节字数区间 | `dfannals/config.py` 的 `CHAPTER_MIN/MAX_CHARS` |
| 模型与网关 | `dfannals/config.py` 的 `PROVIDER` / `MODEL` |
| 发布目标、仓库名 | `dfannals/config.py` 的 `GITEA_*` |
**API key 不入库**:脚本运行时从 `~/.pi/agent/auth.json` 读取,只在内存中,不打印、不落盘、不进 git。
## 八、踩过的坑(失败排查参考)
| 症状 | 原因与解法 |
|---|---|
| `not well-formed (invalid token)` | DF 在名字里嵌了 `\x10`/`\x11` 等 CP437 控制字符,XML 1.0 禁止。管道开头的预处理会剔除并转 UTF-8,缓存到 `data/exports/clean/` |
| 事件数莫名翻倍 | 预处理缓存若与原始文件同目录,会被 `*.xml` 扫描当成第二份史料。缓存因此放在子目录 |
| 素材里出现 `HF#-1`、`Site#-1` | DF 用负数占位表示"无此项"。查询接口对负 id 返回空串,渲染时跳过 |
| 校验器把真名报成可疑 | 史料里是全小写程序名(`yemi deermoths`),正文做了首字母大写。已知名字集同时收录两种形式(有回归测试) |
| `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` 从外部驱动,更可靠 |
## 九、写作设定
- **叙述者**:世界档案库里的编年史官——少数亲历,多数事后依据档案与口述追述,因此对陈年旧账有看法
- **口吻**:战报体,严肃的骨架 + 诙谐的血肉,允许吐槽
- **语言**:中文;专有名词保留英文原文(首字母大写以便阅读)
- **演绎边界**:允许虚构对白、心理、场景;**不许发明史料里没有的人名地名**,不许改写史实骨架(年份、参与者、胜负)
- **连载**:同一世界长期连载;一部史写完则用 `make-world.sh` 生成新世界开新卷
## 十、当前状态与限制
- **已完成**:3 卷框架 + 第 1、2 章上线(Mon Sagus 世界,1–250 年,26 章规划)
- **定时任务未启用**:`cron` / 开机自启等稳定运行一段时间后再落地;目前按需手动执行
- **原始数据不入库**:`data/` 已在 `.gitignore` 中
- **诚实说明**:世界创建各步骤均已逐步实测通过,并已整合为 `make-world.sh`;但该脚本尚未作为**单次完整调用**重跑验证(单次约 15 分钟),首次使用时请留意各阶段日志
## 十一、声明
《Dwarf Fortress》版权归 Bay 12 Games 所有。本仓库只包含由其生成数据的**衍生文本**,
不含游戏本体、美术资源或存档。 不含游戏本体、美术资源或存档。
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# 自动生成一个新世界并导出 legends 数据。
#
# scripts/make-world.sh [世界大小档位] [历史年数档位]
# 世界大小:0=Pocket 1=Smaller 2=Small 3=Medium 4=Large (默认 3 = Medium)
# 历史长度:0=5年 1=50年 2=100年 3=250年 4=500年 (默认 3 = 250 年)
#
# 前置:游戏已启动到标题界面(Dwarf Fortress.exe,DFHack 已通过 dfhooks 注入)。
# 原理:全程用 hack/dfhack-run.exe 从外部驱动,不依赖人工点击:
# 1. 文本截图定位按钮(scripts/df-screen.py)
# 2. 模拟鼠标点击 / 直接改内存里的世界生成参数
# 3. 用 DFHack 的 open-legends 原语进入 legends 模式
# 4. 点 "Export XML" 导出原生 legends.xml,并跑 exportlegends 导出扩展数据
set -euo pipefail
WIN_USER="${WIN_USER:-23518}"
DF_DIR="${DF_DIR:-/mnt/c/Users/${WIN_USER}/DwarfFortress}"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RUN="$DF_DIR/hack/dfhack-run.exe"
WORLD_SIZE="${1:-3}"
HISTORY="${2:-3}"
[[ -x "$RUN" ]] || { echo "找不到 dfhack-run.exe:$RUN" >&2; exit 1; }
cd "$DF_DIR"
say() { echo "[$(date +%H:%M:%S)] $*"; }
probe() { timeout 30 "$RUN" dfannals/probe >/dev/null 2>&1 || true; head -1 dfannals-probe.txt | cut -d= -f2; }
shot() { timeout 60 "$RUN" dfannals/screentext >/dev/null 2>&1; }
click_text() { # click_text <文字> [标签]
local coord
coord=$(python3 "$HERE/scripts/df-screen.py" click ${2:+--label "$2"} --text "$1")
say "点击「$1」@ $coord"
timeout 30 "$RUN" dfannals/click $coord >/dev/null 2>&1
}
say "当前界面:$(probe)"
[[ "$(probe)" == title* ]] || { echo "请先把游戏停在标题界面" >&2; exit 1; }
say "—— 进入世界创建"
shot; click_text "Create new world"
sleep 3
say "—— 设定参数:大小档 $WORLD_SIZE / 历史档 $HISTORY"
timeout 30 "$RUN" dfannals/setparams "$WORLD_SIZE" "$HISTORY" >/dev/null 2>&1
cat dfannals-params.txt
say "—— 开始生成(Medium+250 年实测约 7 分钟)"
shot; click_text "Create world"
for _ in $(seq 1 60); do
sleep 30
shot
if ! grep -q "Recounting legends" dfannals-screen.txt && ! grep -q "Generating world" dfannals-screen.txt; then
say "地形与历史生成结束"
break
fi
say " 生成中… 年份 $(grep current_year dfannals-probe.txt | cut -d= -f2)"
done
grep -m1 -oE "year [0-9]+" dfannals-screen.txt || true
say "—— 保存世界并回到主菜单"
shot; click_text "Keep world and return to main menu"
for _ in $(seq 1 30); do
sleep 20
[[ "$(probe)" == export_region ]] || break
done
say "存档完成,当前界面:$(probe)"
say "—— 进入 legends 模式"
timeout 40 "$RUN" dfannals/legends >/dev/null 2>&1
sleep 4
[[ "$(probe)" == legends* ]] || { echo "未能进入 legends,界面=$(probe)" >&2; exit 1; }
say "—— 导出(原生 Export XML + 扩展数据)"
shot; click_text "Export XML"
for _ in $(seq 1 60); do
sleep 20
n=$(ls -1 region*-legends.xml 2>/dev/null | wc -l)
if [ "$n" -gt 0 ]; then
a=$(stat -c%s "$(ls -1t region*-legends.xml | head -1)" 2>/dev/null || echo 0)
sleep 20
b=$(stat -c%s "$(ls -1t region*-legends.xml | head -1)" 2>/dev/null || echo 0)
[ "$a" = "$b" ] && { say "legends.xml 已停止增长($b 字节),导出完成"; break; }
fi
say " 导出中…"
done
EXPORTS="$HERE/data/exports"
mkdir -p "$EXPORTS"
cp -v region*-legends.xml region*-world_history.txt region*-world_sites_and_pops.txt "$EXPORTS"/ 2>/dev/null | tail -5
say "导出文件已复制到 $EXPORTS"
say "接下来跑:python3 -m dfannals.cli run"