From 594cf48a1a046ec0dd57b6196122b4a4150cc59b Mon Sep 17 00:00:00 2001 From: Chen Yi <466354947@qq.com> Date: Mon, 5 Oct 2026 17:34:23 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=87=E6=A1=A3=E4=B8=8E=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=EF=BC=9A=E4=B8=96=E7=95=8C=E5=88=9B=E5=BB=BA=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=8C=96=20make-world.sh=E3=80=81=E5=AE=8C=E6=95=B4=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=96=87=E6=A1=A3=E4=B8=8E=E5=A4=B1=E8=B4=A5=E6=8E=92?= =?UTF-8?q?=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 161 +++++++++++++++++++++++++++++++++--------- scripts/make-world.sh | 92 ++++++++++++++++++++++++ 2 files changed, 221 insertions(+), 32 deletions(-) create mode 100755 scripts/make-world.sh diff --git a/README.md b/README.md index c75b27f..96c9960 100644 --- a/README.md +++ b/README.md @@ -2,53 +2,150 @@ 把《矮人要塞》(Dwarf Fortress) 世界生成的历史数据,写成**中文战报体连载故事**,自动推送到这个仓库。 -本仓库是**成品仓**:只放小说章节、迷你年表与人物索引。 -原始 `legends` 导出数据与游戏存档留在本地,不入库。 +本仓库是**成品仓**:放小说章节、迷你年表、人物索引,以及生成它们的代码。 +原始 `legends` 导出数据(数百 MB)与游戏存档留在本地,不入库。 -## 这本"书"是怎么来的 +--- + +## 一、它是怎么工作的 ``` -Windows 侧 DF 53.16 + DFHack 53.16-r2 - │ ① 生成 Medium 世界 / 250 年历史 +Windows 侧:Dwarf Fortress 53.16 + DFHack 53.16-r2 + │ ① 生成世界(Medium 129×129 / 250 年历史) │ ② 进入 legends 模式,导出 legends.xml + legends_plus.xml - ▼ ③ 经 /mnt/c 文件交换(无需网络互访) + ▼ ③ 经 /mnt/c 文件交换(不需要 WSL↔Windows 网络互访) WSL2 Ubuntu 侧管道 - │ ④ 解析史料 → 构建年表与人物索引 - │ ⑤ 按事件链切章(每章 800–1500 字) - │ ⑥ 组 prompt(要塞编年史官口吻 / 战报体)→ 调用 LLM 写作 - │ ⑦ 专名事实校验:正文里的专有名词必须出现在史料中,可疑处标注 - ▼ ⑧ git commit & push -Gitea: gitadmin/dwarf-fortress-annals(本仓库) + │ ④ 预处理:剔除 DF 塞进名字里的非法控制字符,CP437 → UTF-8 + │ ⑤ 解析史料:40 万条事件 / 4 万位人物 → 事件流 + 年表 + 人物索引 + │ ⑥ 切章:10 年一窗,每窗按显著度取前 12 条,同类事件≤4(避免整章讣告) + │ ⑦ 写作:要塞编年史官口吻 / 战报体 / 允许自由演绎 + │ ⑧ 校验:正文专名必须能在史料里找到,可疑处标注但不阻断 + ▼ ⑨ git commit & push +Gitea:gitadmin/dwarf-fortress-annals(本仓库) ``` -## 目录 +## 二、目录 -| 路径 | 内容 | 是否入库 | +| 路径 | 内容 | 入库 | |---|---|---| -| `output/chapters/` | 连载正文(Markdown) | ✅ | -| `output/timeline.md` | 迷你年表 | ✅ | -| `output/figures.md` | 人物索引 | ✅ | -| `src/dfannals/` | 管道源码 | ✅ | -| `prompts/` | 写作规范(决定文风与人设) | ✅ | -| `data/exports/` | 原始 legends 数据 | ❌ | -| `vendor/` | 游戏与 DFHack 安装包 | ❌ | +| `output/volumes/v/chapters/` | 连载正文 | ✅ | +| `output/volumes/v/timeline.md` | 迷你年表 | ✅ | +| `output/volumes/v/figures.md` | 人物索引 | ✅ | +| `dfannals/` | 管道源码 | ✅ | +| `scripts/` | 安装、部署、驱动脚本 | ✅ | +| `prompts/chronicler.md` | 写作规范(决定文风与人设) | ✅ | +| `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 -# 凭据:API key 运行时从 Pi 的 ~/.pi/agent/auth.json 读取,不落本仓库 -python3 -m dfannals.cli --help +# 1) Windows 侧装游戏与 DFHack(经 WSL 的 /mnt/c 操作,免管理员权限) +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 所有。本仓库只包含由其生成数据的**衍生文本**, 不含游戏本体、美术资源或存档。 diff --git a/scripts/make-world.sh b/scripts/make-world.sh new file mode 100755 index 0000000..b8bfa25 --- /dev/null +++ b/scripts/make-world.sh @@ -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"