跳转到内容

CLI 与 API 分层设计

解释 STS2_taku-agent 的 CLI 工具、HTTP API 分层、低 token 设计思想,以及各类 API 背后的运行时 hook 和 UI 接入点。

STS2_taku-agent 的 CLI 不是一个简单的 curl 包装器。它的设计目标是把游戏运行时整理成适合 AI 使用的协议:

AI / developer
-> ./sts CLI
-> localhost:15527 HTTP API
-> ObservationServer 主线程队列
-> GameSnapshotBuilder / ActionSurfaceBuilder / ActionExecutor
-> STS2 运行时对象、Godot UI 节点、Harmony hooks

这套分层的核心思想有三个:

  • context first:先识别当前状态,再决定查哪个窄接口。
  • action surface first:执行前先读取合法动作和参数结构,写接口复用同一套 action name。
  • wait and delta:动作后等待稳定状态,再读增量,避免重复消费完整状态。

因此 CLI 的价值不只是“命令更短”,而是把 AI 最容易犯错的地方封住:状态不稳定时不暴露动作、转场后自动等待、索引类动作只在当前快照内有效、失败时返回恢复建议和可关联日志。

CLI 入口在 tools/sts-cli/main.ts,仓库根目录的 ./sts 和 npm 包里的 bin/sts 都指向它。入口做四件事:

  1. 解析顶层 command 和 args。
  2. 创建 telemetry session。
  3. 用 telemetry 包装 HTTP client。
  4. 调用 commands/dispatch.ts

dispatch.ts 是命令路由层。简单命令直接映射 HTTP endpoint,例如:

sts context -> GET /api/v1/context
sts actions -> GET /api/v1/actions
sts combat hand -> GET /api/v1/combat/hand
sts get /api/v1/... -> GET 任意内部路径

复杂命令会在 CLI 层聚合多个 endpoint:

  • sts next:并发读 contextobservation/compact
  • sts combat snapshot:并发读 contextplayer/summaryactionscombat/summarycombat/actionscombat/handcombat/enemies
  • sts room summary:读 contextactions、必要的 player/summary 和当前 room 的窄接口。
  • sts run snapshot:把 run、compact、room summary 和 combat snapshot 组织成默认规划视图。
  • sts rewards claim-all-safe:循环领取非卡牌奖励,遇到卡牌选择停下。

这是一种有意的分层:底层 API 保持小而稳定,CLI 可以为常见工作流提供“刚好够用”的聚合视图。

sts exec 的参数合同很窄:

Terminal window
./sts exec ACTION [INDEX] [TARGET]
./sts exec ACTION key=value key=value

CLI 会把它转成:

{
"actionType": "play_card",
"parameters": {
"index": 0,
"target": "jaw_worm_0"
}
}

然后请求:

POST /api/v1/actions/execute

exec.ts 还维护了一组默认等待条件:

continue_game -> run_active
end_turn -> player_turn
choose_map_node -> room_ready
proceed -> room_ready
skip_card_reward -> room_ready
open_treasure -> treasure

这让命令语义更接近“做完这个动作并等到下一步可判断”,而不是“发出一次 HTTP POST 就结束”。如果默认等待不合适,可以显式传 --wait-for--wait-for-ready--wait-for-room--wait-for-run

HTTP server 在 mod 内部运行,监听 localhost:15527ObservationServerHttpListener 接请求,但所有需要读写 Godot/游戏对象的逻辑都会通过 RunOnMainThread 进入主线程队列。队列挂在 Godot SceneTree.ProcessFrame 上,每帧处理有限数量的 action,避免后台 HTTP 线程直接碰游戏对象。

读接口的统一流程是:

  1. 主线程构建 GameSnapshot
  2. 从快照构建 ActionSurfaceSnapshot
  3. 从快照构建 CurrentKnowledgeSnapshot
  4. 更新 observation version。
  5. 根据 path 返回窄切片。

写接口 actions/execute 的流程更长:

  1. 解析 actionType 和参数,支持 actionaction_typeactionType 三种字段。
  2. 读取执行前快照和动作面。
  3. 在主线程调用 ActionExecutor.Execute(...)
  4. 等待 post-action 快照,必要时等待稳定状态。
  5. 生成 deltarecovery、当前动作面。
  6. action-execution.jsonl
  7. 返回状态、correlation id、context、delta、恢复建议和下一步建议查询。

这就是为什么 API 返回的不是裸 success,而是一个便于 AI 继续推理的结果对象。

当前 API 可以分成六层。

  • /api/v1/capabilities
  • /api/v1/context

capabilities 来自 ObservationApiCatalog,告诉调用方有哪些 endpoint、成本和适用 state。context 是默认第一跳,返回 stateTyperoomTypeoverlayTypeisStableisTransitioningrecommendedQueries

背后的运行时接入点主要是:

  • RunManager.Instance.IsInProgress
  • RunManager.Instance.DebugOnlyGetState()
  • LocalContext.GetMe(runState)
  • runState.CurrentRoom
  • NOverlayStack.Instance.Peek()
  • NMapScreen.Instance.IsOpen
  • /api/v1/observation/compact
  • /api/v1/observation/delta
  • /api/v1/run
  • /api/v1/state/full

compact 是给 AI 的摘要,不追求完整;delta 比较前后快照,只返回变化事实;run 是长程规划需要的层级信息;state/full 只用于 debug。

这一层不依赖单独 hook,而是由 GameSnapshotBuilder 汇总运行时状态。战斗部分会受 patch 触发的 snapshot/export 体系辅助验证,但实时 API 仍以当前运行时对象为准。

  • /api/v1/actions
  • /api/v1/actions/execute

actionsActionSurfaceBuilder 根据 context.stateType 分派:

menu -> continue_game
combat -> play_card / use_potion / discard_potion / end_turn / selection actions
map -> choose_map_node
event -> choose_event_option / advance_dialogue
shop / fake_merchant -> shop_purchase / proceed
rest_site -> choose_rest_option / proceed
rewards -> claim_reward / proceed
card_reward -> select_card_reward / skip_card_reward
card_select -> select_card / confirm_selection / cancel_selection
treasure -> open_treasure / claim_treasure_relic / proceed

actions/executeActionExecutor 执行同名动作。读写合同复用同一套 action name,是这个仓库最重要的 API 约束:AI 只要从 actions 里挑,就不应该自己发明动作。

  • /api/v1/player/summary
  • /api/v1/player/deck
  • /api/v1/player/relics
  • /api/v1/player/potions
  • /api/v1/player/status
  • /api/v1/knowledge/current
  • /api/v1/knowledge/cards
  • /api/v1/knowledge/relics
  • /api/v1/knowledge/potions
  • /api/v1/knowledge/status

玩家层负责动态构筑状态,知识层负责当前上下文里卡牌、遗物、药水、状态的静态文本缓存。这样 AI 可以先用 ID、计数和摘要做判断,只有需要解释效果时再查文本。

运行时来源主要是 PlayerPlayerCombatState、deck/relic/potion/status model,以及 ObservationText 对本地化文本的安全读取。

  • /api/v1/combat/summary
  • /api/v1/combat/actions
  • /api/v1/combat/hand
  • /api/v1/combat/enemies
  • /api/v1/combat/piles

战斗读接口主要来自:

  • CombatManager.Instance.IsInProgress
  • CombatManager.Instance.DebugOnlyGetState()
  • CombatManager.Instance.IsPlayPhase
  • CombatManager.Instance.PlayerActionsDisabled
  • CombatRoom
  • PlayerCombatState.Hand
  • Creature.CombatState
  • 敌人的 Monster.NextMove
  • NPlayerHand 里的 in-combat selection 状态

战斗 hook 目前有三个:

  • CombatManager.SetUpCombat:战斗开始时采样。
  • CombatManager.SetupPlayerTurn:玩家回合准备完成后采样。
  • Hook.AfterCardPlayed:卡牌结算后记录出牌和采样。

写操作里,play_card 最终使用 RunManager.Instance.ActionQueueSynchronizer.RequestEnqueue(new PlayCardAction(card, target))end_turn 使用 PlayerCmd.EndTurn(...);药水执行会按是否在战斗中选择 target 或走非战斗使用路径;弃药水调用 PotionCmd.Discard(...)

这里最需要注意的是索引时效:play_card index=0 只对当前 hand/action surface 有意义。打出一张牌后,手牌顺序可能改变,所以 AI 必须重新读 actionscombat hand。当前 executor 已支持 expected_card_idexpected_titleexpected_cost 这类 guard 来避免 stale index 误打。

这些接口覆盖战斗外的实际 UI:

  • /api/v1/map/summaryNMapScreenNMapPoint
  • /api/v1/eventNEventRoomNEventOptionButtonNAncientEventLayout
  • /api/v1/fake-merchantNFakeMerchantNMerchantInventory、merchant/proceed/back controls。
  • /api/v1/shopMerchantRoomNMerchantRoom、库存和购买控件。
  • /api/v1/rest-siteRestSiteRoomNRestSiteRoomNRestSiteButton
  • /api/v1/rewardsNRewardsScreenNRewardButtonNProceedButton
  • /api/v1/card-rewardNCardRewardSelectionScreenNCardHolder、skip button。
  • /api/v1/card-selectionNCardGridSelectionScreenNChooseACardSelectionScreenNCardGrid、confirm/cancel controls。
  • /api/v1/bundle-selectionNChooseABundleSelectionScreenNCardBundle
  • /api/v1/relic-selectionNChooseARelicSelectionNRelicBasicHolder
  • /api/v1/crystal-sphereNCrystalSphereScreenNCrystalSphereCell、工具按钮、proceed button。
  • /api/v1/treasureNTreasureRoomChestNTreasureRoomRelicCollectionNTreasureRoomRelicHolder
  • /api/v1/overlay:未处理 overlay 或终局 overlay。

这些层面的“hook”多数不是 Harmony hook,而是 Godot UI 节点接入点。读接口通过节点树发现控件,写接口通过 ForceClick()EmitSignal(...) 或游戏 command API 触发同一套 UI 逻辑。

wait.ts 是 CLI 里最有工程价值的一层。它不只是轮询 context.stateType,还会根据条件读取 combat summary、actions 或 overlay。

例如:

  • player_turn 要求处于 combat,且 combat.side === "player",并有 action。
  • room_ready 要求 context 稳定;战斗房要能读 combat summary,非战斗房要出现该 state 的 primary action。
  • run_active 要求离开 menu,稳定,且当前状态有可解释的动作面或战斗摘要。
  • 终局 overlay 会返回 status=terminal,而不是一直等到 timeout。

这避免了一个常见问题:只看 stateType=treasurestateType=rewards 不代表 UI 已经能点。真正可行动的标志是稳定 context 加可用 primary action。

这套 CLI/API 不是为了暴露最完整的游戏状态,而是为了让 AI 少犯错。

所以它有几个明确取舍:

  • 默认不读 full state;从 context 开始,按需深入。
  • 动作必须来自 actions;写接口不鼓励调用方自造参数。
  • 转场中隐藏动作;宁可等待,也不要对半稳定 UI 执行。
  • 日志面向 replay;每条 CLI 命令和每次 server action 都能按 correlation id 追踪。
  • 聚合放 CLI 层;底层 endpoint 保持小接口,便于后续替换或加缓存。

如果要继续演进,下一步不是把 API 做大,而是让动作合同更稳定:例如给手牌里的每张卡暴露稳定 instance id,减少 index 在战斗中的脆弱性;再往上则可以做 replay timeline,把 CLI telemetry、action execution log 和战斗 action history 合成一条可读轨迹。