军阀请求邻军援助
军阀甲正在交战,请求相邻的军阀乙出兵支援。一开始,军阀乙知道双方有共同的敌人, 但无法确认补给线是否安全,所以唯一的选项是拒绝。补给线得到确认后,“派兵支援” 成为第二个选项。军阀乙的 AI 给两个选项打分并做出选择,选中的结果会调动军阀乙的军队。
这个示例回答的问题是:AI、玩家或模型做出的选择,怎样按角色的权限校验、记录下来, 并在重放时不必再次询问决策者?
场景是带北洋时期风格的合成数据,不对应具体人物或史实事件。
示例涉及的引擎部件
Section titled “示例涉及的引擎部件”- 决策票据(
DecisionTicket):一个持久化保存的决策问题, 附带带版本号的决策选项(DecisionOption)列表。 - 控制者(controller,
DecisionControllerBinding): 由谁决策、使用哪个决策策略、以谁的权限行动。 - 效用策略
WeightedUtilityPolicy:用整数权重为选项打分,并把每个因子的贡献写入 决策轨迹(DecisionTrace)。 - 选中的选项转成命令,和其他命令走同一套校验。
- 整个决策的快照恢复与精确重放。
cargo run -p canwu-api --example decision_ticket程序以 JSON 打印决策轨迹,再确认存档恢复和重放结果。下面的输出做了删减,只保留
send-aid 的因子列表:
{ "id": 1, "ticket_id": 1, "ticket_version": 2, "controller_id": "warlord-b-ai", "policy": { "kind": "utility", "id": "aid-utility", "version": "1" }, "outcome": { "type": "selected", "option_id": "send-aid" }, "summary": "utility policy selected send-aid", "evaluations": [ { "option_id": "decline", "available": true, "score": -40, ... }, { "option_id": "send-aid", "available": true, "score": 250, "factors": [ { "factor": "alliance", "value": 90, "weight": 3, "contribution": 270 }, { "factor": "home_defense", "value": -20, "weight": 1, "contribution": -20 } ] } ], "command_request_id": 1}snapshot_restore=ok exact_replay=okticket_version 为 2,因为选项被替换过一次。decline 得分为
-40 × 3 + 80 × 1 = -40,send-aid 得分为 90 × 3 − 20 × 1 = 250。
查看图表源码
sequenceDiagram
participant Host as 上层应用
participant Canwu as 参伍
participant Policy as WeightedUtilityPolicy
Host->>Canwu: 注册控制者,开启票据(只有 decline)
Note over Canwu: 边界 1:票据版本 1
Host->>Canwu: ReplaceOptions(补给线已确认)
Note over Canwu: 边界 2:票据版本 2
Host->>Canwu: drive_decision(policy)
Canwu->>Policy: 为票据版本 2 打分
Policy-->>Canwu: 选择 send-aid
Note over Canwu: 边界 3:写入决策轨迹,执行调动命令
代码都在
decision_ticket.rs
中。示例使用 Canwu::demo(1918) 提供的兼容场景(已标记为 deprecated),
通过 Canwu::demo_ids() 取得指挥官、军队和地区的 ID。
1. 注册控制者
Section titled “1. 注册控制者”open_aid_request 把控制者 warlord-b-ai 绑定到一个决策策略身份、它借用其权限的
指挥官,以及它可以调动的军队:
let controller = DecisionControllerBinding::new( "warlord-b-ai", DecisionPolicyIdentity::new(DecisionPolicyKind::Utility, "aid-utility", "1"), DecisionAuthority::Actor { actor: ids.commander, },).with_command_subject(EntityRef::Army(ids.army));它以 DecisionMutation::RegisterController 提交这个绑定。决策策略本身只拿到票据;签发者、
权限和命令对象都保存在持久化的绑定里。
2. 开启只有一个选项的票据
Section titled “2. 开启只有一个选项的票据”同一个函数用 DecisionTicketDraft 提交 DecisionMutation::Open。决策上下文记录
军阀乙已知的情况,唯一的选项是 decline:
context: DecisionContext::new( "beiyang.aid-request.v1", json!({ "requester": "warlord-a", "battle": "ongoing-front", "common_enemy": true }),),options: vec![DecisionOption { action: DecisionAction::None, utility_inputs: BTreeMap::from([ ("home_defense".to_owned(), 80), ("alliance".to_owned(), -40), ]), ..DecisionOption::new("decline", "Decline aid")}],deadline: Some(now + SimDuration::days(2)),两条变更都通过 enqueue_decision 入队,step_canonical() 结算的边界会开启票据,
版本为 1。实际应用中,应当用军阀乙的角色知识构造
决策上下文,让决策策略只看到军阀乙知道的事实。
3. 补给线确认后替换选项
Section titled “3. 补给线确认后替换选项”refresh_aid_options 提交 DecisionMutation::ReplaceOptions,带上
expected_version: 1、加入 "route_confirmed": true 的新上下文,以及两个选项。
新的 send-aid 选项携带一条序列化命令:
DecisionOption { action: DecisionAction::Command { command: serde_json::to_value(Command::OrderMovement { subject: EntityRef::Army(ids.army), destination: ids.eastern_territory, cargo: Vec::new(), })?, }, utility_inputs: BTreeMap::from([ ("home_defense".to_owned(), -20), ("alliance".to_owned(), 90), ]), ..DecisionOption::new("send-aid", "Send the neighboring army")},票据升到版本 2。针对版本 1 准备的 Human、External 或 LLM 答复会被拒绝,过期答复 无法在新的选项集合里生效。在这个示例中,补给线确认是作者设定的场景输入;研究型应用 应为它引用一条证据记录。
Command::OrderMovement 是为这个兼容示例保留的旧命令变体。新应用应使用
canwu-reference-world 的 MovementCommand,或自己插件中的命令,参见
移动军队。票据选项以 JSON 保存命令,两种写法都可以。
4. 为选项打分
Section titled “4. 为选项打分”resolve_aid_request 构造决策策略:
let policy = WeightedUtilityPolicy::new( "aid-utility", "1", UtilityProfile { weights: BTreeMap::from([("alliance".to_owned(), 3), ("home_defense".to_owned(), 1)]), },);每个可用选项的得分是其 utility_inputs 中各项 value * weight 之和;没有配置权重的
因子按权重 0 计算。最高分胜出,同分时取 option ID 最小的选项。“同盟”和“本土防御”
代表什么、各占多少权重,由你的领域代码决定。
5. 结算票据并执行命令
Section titled “5. 结算票据并执行命令”let evaluation = canwu.drive_decision( canwu.time(), 0, DecisionRequestId::new(4), Some(CommandRequestId::new(1)), DecisionTicketId::new(1), &policy,)?;assert!(matches!(evaluation, DecisionEvaluation::Prepared(_)));canwu.step_canonical()?.expect("decision resolution boundary");drive_decision 对当前票据运行决策策略,并把结果作为决策输入入队。下一个边界中,
参伍写入决策轨迹,并以控制者的权限提交 send-aid 的命令。这条命令同样要通过常规的
权限、修订版本、时间和领域校验。决策策略只返回 option ID,因此换成玩家、外部服务或
LLM,也无法生成新命令或冒用他人权限。
6. 检查存档与重放
Section titled “6. 检查存档与重放”verify_persistence_and_replay 从 JSON 恢复快照,再用 Canwu::replay_from_journal
重建整个运行,断言两者都与原快照一致。重放使用已记录的决策输入和决策尝试,决策策略
不再运行。
在自己的代码中读取结果时,用 Canwu::decision_trace(id) 读取决策轨迹,用
Canwu::decision_attempt(request_id) 读取某次决策请求被接受或拒绝的结果。修订版本
或票据版本过期、或票据已关闭的请求,会记为一次被拒绝的决策尝试(DecisionAttemptRecord),模拟照常推进。
值得注意的地方
Section titled “值得注意的地方”- 决策策略只能从已有选项中选择,既不能增加选项,也不能改动命令。
- 替换选项会提升票据版本,针对旧版本准备的答复随之失效。
- 决策轨迹逐个因子解释了选择的依据,重放时直接复用。
- 想在同一局面下试另一种决策策略,就从快照派生分支
(
fork)并提交新的决策输入。这样得到的是平行现实, 与精确重放分开。
- 权重。 把
home_defense改成 5。此时decline得分为-120 + 400 = 280, 高于send-aid的270 - 100 = 170。 - 决策策略。 把
WeightedUtilityPolicy换成canwu-api中的其他策略类型:OrderedRulePolicy按有序规则选择或推迟。GuardedUtilityPolicy(前置规则效用策略)先运行有序前置规则,选择、推迟或排除 选项,再为剩余选项打分,并可在分数接近的最高选项之间随机平局决胜(见 随机决策与 LLM 选择)。它的决策轨迹 会记录决策阶段(Guard、Utility或Random)和触发的前置规则。QueuedHumanPolicy、QueuedExternalPolicy和QueuedLlmPolicy分别等待玩家、 外部服务或模型针对当前票据版本返回一个 option ID。
- 决策者不可用。 如果军阀乙死亡或被俘,参伍会在该边界结束时取消以军阀乙为决策者的
未关闭票据,并在军阀乙不可用期间拒绝为其开启新票据。如果只是控制者的权限人物不可用,
参伍取消的是该控制者的未关闭票据。要继续这项决策,就为可用的控制者开启新票据,并把
parent_ticket设为被取消的票据;这条关联会复制到新的决策轨迹上。详见 接入并驱动模拟运行。 - 席位继任。 用
with_seat把军阀乙的控制者绑定到席位,例如faction-b.leader。军阀乙死亡后,把继任者的控制者绑定到同一席位,以继任者为决策者 开启新票据,并让parent_ticket指向被取消的票据。两个控制者占据同一席位,参伍便接受 这条关联。决策者不同、席位也不同的前序票据会被拒绝。
打开可运行示例
阅读决策框架测试