跳转到内容

从零搭建 STS2 mod 工程框架

基于 STS2_taku-agent 的实践,梳理《杀戮尖塔2》mod 工程从环境、加载、构建安装到 AI 自动测试闭环的搭建方式。

一个面向 AI 迭代的《杀戮尖塔2》mod 工程,不应该只停留在“能编译一个 DLL”。更实用的框架是四件事合在一起:

  • 一个能被游戏识别的 mod 包:mod_manifest.json.pck.dll
  • 一个稳定的 C# 入口:[ModInitializer] 初始化、Harmony patch、运行时服务启动。
  • 一个本地自动化链路:构建、打包、复制到游戏 mods/<mod_id>/、重启游戏、等待服务恢复。
  • 一个给 AI 用的外部控制面:低 token 的 HTTP API、CLI、日志、等待语义和 smoke check。

STS2_taku-agent 的核心经验是:先把 mod 做成一个“游戏内探针 + 本地服务”,再把 AI 的读写行为收束到 CLI。AI 不直接猜 Godot 节点,也不直接操作游戏窗口,而是通过 mod 暴露的稳定动作面来读状态、执行动作、等待转场、复盘日志。

这个仓库的本地开发环境主要由四类东西组成。

第一类是游戏本体。脚本默认使用 macOS Steam 安装路径:

~/Library/Application Support/Steam/steamapps/common/Slay the Spire 2/SlayTheSpire2.app

实际开发时要关心两个目录:

  • Contents/Resources/data_sts2_macos_arm64:编译时引用游戏程序集,例如 sts2.dll0Harmony.dllGodotSharp.dll
  • Contents/MacOS/mods/<mod_id>/:运行时放置 mod 的 mod_manifest.json.pck.dll

第二类是 .NET。当前 mod 工程是 net9.0src/TakuAgentMod.csproj 通过 STS2_GAME_DIR 指向游戏数据目录,并引用游戏暴露出来的程序集:

<Reference Include="sts2">
<HintPath>$(STS2_GAME_DIR)/sts2.dll</HintPath>
<Private>false</Private>
</Reference>

第三类是 Godot 打包能力。pack/ 目录会被 tools/build_pck.gd 打成 .pck。即使当前主要逻辑在 DLL,.pck 仍然是 mod 包的一部分,用来承载 manifest 之外的资源、localization 或后续 Godot 侧内容。

第四类是 Node 24。仓库的 ./sts CLI 是 TypeScript 源码直接运行,依赖 Node 24 的内置 TypeScript stripping;CLI 的检查命令是:

Terminal window
npm run verify:cli

从当前实践看,STS2 的 mod 加载契约可以理解成一个目录协议:游戏启动时扫描 mods/<mod_id>/,读取 mod_manifest.json,再按 manifest 声明加载同目录下的 .pck.dll

当前 manifest 是:

{
"id": "taku_agent",
"has_pck": true,
"has_dll": true,
"affects_gameplay": true
}

DLL 里真正的入口是 src/ModEntry.cs

[ModInitializer(nameof(Initialize))]
public static class ModEntry
{
public static void Initialize()
{
_harmony ??= new Harmony("io.retr0.sts2.taku_agent");
_harmony.PatchAll(typeof(ModEntry).Assembly);
ObservationServer.Start();
}
}

这里有三个关键点。

第一,入口用游戏 modding API 的 [ModInitializer] 标记,而不是自己去找进程注入点。

第二,初始化只做一次,并把启动过程写入 ~/Library/Application Support/STS2TakuAgent/bootstrap.log。这类 marker 很重要,因为 mod 没启动、Harmony patch 没打上、HTTP server 没开,外部看起来都可能只是“CLI 连不上”。

第三,真正扩展游戏行为靠 Harmony patch。当前仓库 patch 了三个战斗生命周期点:

  • CombatManager.SetUpCombat 的 postfix:战斗建立后采样。
  • CombatManager.SetupPlayerTurn 的 postfix 包装异步返回:玩家回合开始后采样。
  • Hook.AfterCardPlayed 的 postfix 包装异步返回:卡牌打出并结算后采样和记录。

这三个点不追求覆盖全游戏,而是先把“战斗状态能不能稳定读”打穿。后面的地图、奖励、商店、事件、选牌等状态,更多依赖运行时对象和 Godot UI 节点实时构建快照,而不是每个场景都先 patch 一个事件。

一个可维护的 STS2 mod 工程至少要分出这些边界:

src/ C# mod 源码
src/Patches/ Harmony patch,接游戏生命周期
src/State/Builders/ 从运行时对象和 UI 节点构建快照
src/Observation/ localhost HTTP server 和 API catalog
src/Execution/ 动作执行器与执行日志
pack/ 打进 PCK 的资源与 localization
tools/ PCK 打包脚本等辅助工具
tools/sts-cli/ 面向 AI/开发者的 CLI

这个拆法背后的原则是:patch 只负责接入,builder 负责观察,executor 负责写操作,server 负责协议,CLI 负责外部体验。不要把“读状态、打动作、等转场、输出日志”混在一个 patch 里。

build_and_deploy.sh 是当前仓库最重要的工程化脚本。它做了三步:

  1. dotnet build src/TakuAgentMod.csproj -c Release 编译 DLL。
  2. 用游戏的 Godot engine headless 执行 tools/build_pck.gd,把 pack/ 打成 dist/taku_agent.pck
  3. mod_manifest.jsontaku_agent.pcktaku_agent.dll 复制到游戏的 mods/taku_agent/

默认 macOS 路径已经写进脚本,但仍然保留环境变量覆盖:

Terminal window
STS2_GAME_DIR=... STS2_MODS_DIR=... DOTNET_BIN=... ./build_and_deploy.sh

这比手动复制可靠,因为它让 AI 和人类使用同一条安装路径。只要脚本成功,运行时目录里应该总是同时存在 manifest、PCK 和 DLL。

构建安装之后,还需要让游戏重新加载 mod。restart_game.sh --wait-for-server 负责停止并重启本地游戏,然后等待观察服务恢复。dev_cycle.sh 则把完整循环合在一起:

Terminal window
./dev_cycle.sh --smoke

它的语义是:

  1. build + deploy。
  2. restart game。
  3. wait for observer server。
  4. 执行最小 smoke:./sts ping./sts context./sts actions./sts doctor

这里的关键不是“省几条命令”,而是给 AI 一个确定的迭代边界。一次改动能不能进入下一轮,不靠肉眼猜,而靠服务是否启动、context 是否可读、actions 是否能稳定返回。

AI 自动迭代 mod 的前提,是把游戏运行时转成可验证的外部协议。当前仓库的闭环是:

代码修改
-> ./dev_cycle.sh --smoke
-> ./sts context
-> ./sts actions
-> ./sts exec <action> ...
-> ./sts wait <condition>
-> ./sts delta 或 ./sts room snapshot
-> 日志复盘

读状态时,AI 默认从 context 开始,而不是读完整状态。context 返回 stateTypeisStableisTransitioning 和推荐查询。只有当前屏幕稳定时,actions 才暴露合法动作;转场中则刻意返回空动作面,避免 AI 对半稳定 UI 下手。

执行动作时,AI 不直接点击坐标,而是调用:

Terminal window
./sts exec play_card 0 jaw_worm_0
./sts exec end_turn
./sts exec choose_map_node 1

这些命令最终落到 POST /api/v1/actions/execute。服务端会在游戏主线程执行动作,等待 post-action 快照,返回 deltacontextrecoverysuggestedNext。失败时还会写 debug snapshot。

复盘靠两类日志:

  • action-execution.jsonl:server 端写动作执行记录,包含 correlation id、执行前后资源、动作面摘要、delta facts、失败原因。
  • cli-command.jsonl:CLI 端写每条命令的参数、耗时、HTTP 请求路径、退出结果。

这个设计让 AI 能把“我打错了”拆成具体问题:是 stale index、状态还没稳定、动作 surface 没暴露、server 执行失败,还是游戏本体已经进入终局 overlay。

从零做类似工程,可以按这个顺序推进:

  1. 先做最小 mod:manifest、PCK、DLL、[ModInitializer]、bootstrap log。
  2. 加 Harmony patch,只验证 1 到 2 个生命周期点,不急着覆盖所有场景。
  3. 在 mod 内启动 localhost server,并保证所有读写游戏对象的逻辑回到 Godot 主线程执行。
  4. context,先分类当前处于 menu、combat、map、reward 还是 overlay。
  5. actions,把“当前能做什么”变成机器可读的 action surface。
  6. actions/execute,让写接口复用 actions 的动作名和参数。
  7. 建 CLI,把 AI 的所有操作限制在 context -> actions -> exec -> wait -> delta 这条路径。
  8. 加 dev cycle、doctor、telemetry,让每次迭代都能自动验证。

真正的重点不是一次做出很多 API,而是让每个 API 都能被 AI 稳定组合。对游戏自动化来说,稳定的状态机比大的全量 JSON 更重要。