KARDS 本地对局模拟器:把客户端蓝图直接转译成 C#,用它来训练 / 评测 AI。
不是「读卡面文本猜效果」,而是把游戏自己的 Kismet 字节码逐条翻译成 C# 控制流, 所以卡牌逻辑与客户端一致,没有猜测成分。
| 层 | 做什么 |
|---|---|
KismetDecompiler(另一个仓库) |
补出反编译器的控制流边界(EX_ComputedJump 分发入口) |
KardsTranspiler |
Kismet AST → C#,一个函数一个静态方法,一条语句一个 label |
KardsSim/Generated |
1670 个资产的直译产物,约 37 万行 |
KardsSim/Kismet |
运行时:IHost 接口 + 值模型 + 纯函数/引擎库实现 |
KardsSim/Bridge |
把直译产物接到游戏引擎(这是「hook 缝」) |
KardsSim/Engine |
对局规则、动作合法性、回合流程 |
关键点: Kismet 字节码自带跳转(EX_Jump / EX_JumpIfNot / EX_ComputedJump),
所以直译成 label + goto 是 1:1 的,不需要重建控制流图。
EX_PushExecutionFlow / EX_PopExecutionFlow 用一个真的 Stack<int> + switch 承载。
为什么不用反编译出来的伪代码: 伪代码是有损的;FunctionExport.ScriptBytecode
是 UAssetAPI 解析好的 KismetExpression[] AST,无损。转译走 AST。
为什么不用文本解析: 见 KardsSim/Effects/EffectPlanner.cs(保留作对照,已不启用)。
文本解析只能覆盖简单卡,且永远有偏差。
KardsTranspiler/ Kismet AST → C#。核心是 Emitter.cs
KardsSim/
Generated/ 直译产物(1670 个 .g.cs,已入库,克隆即可编译)
Kismet/ IHost / Val / KObj / Host(纯函数 + 引擎库)
Bridge/
EngineHost.cs 分派器:原语 → 转译产物递归
CardDispatch.cs 触发器 → 卡牌事件函数
Core/ GameState / CardDef / 枚举 / RNG / DeckCode(卡组码解析)
Engine/ 对局规则、动作、伤害、回合
Server/HttpServer.cs HTTP 接口(给不同 AI 后端用)
Ai/Encoder.cs 状态/动作编码
KardsDataExtract/ 从资产里抽 cards.json
tools/gen-deckcodes.ps1 从私服数据表生成 deckcodes.json
cards.json 1906 张卡的定义(运行时需要)
deckcodes.json 卡组码里 2 字符代码 -> 卡牌 id(2499 条,运行时需要)
decks.json 22 套社区推荐卡组预设(卡组码)
dotnet build KardsSim/KardsSim.csproj -c ReleaseGenerated/ 已在仓库里,直接编译即可,不需要游戏资产。
想跳过生成代码编译(只调引擎):
dotnet build KardsSim/KardsSim.csproj -c Release -p:SkipGenerated=true# 自对弈(默认走直译产物),并打印宿主 API 覆盖率 + 触发点完整性检查
dotnet run --project KardsSim -c Release -- --mode selfplay --games 200
# 用旧的文本解析效果表跑(对照用)
dotnet run --project KardsSim -c Release -- --mode selfplay --games 200 --legacy
# HTTP 接口,默认 http://127.0.0.1:8642/
dotnet run --project KardsSim -c Release -- --mode serve
# 机制单测(带断言的,能失败)
dotnet run --project KardsSim -c Release -- --mode tests
# 卡组码审计:22 套推荐卡组逐条解析,报「哪几套这套模拟器能打完整」
dotnet run --project KardsSim -c Release -- --mode decks
# 用推荐卡组自对弈(预设名 / 卡组码 / 卡牌 id 列表都能传;--swap-sides 每局交换左右)
dotnet run --project KardsSim -c Release -- --mode selfplay --games 200 \
--left-deck jp-pol-boom --right-deck "%%15|2L2Q2Z31…" --swap-sides# 默认 http://127.0.0.1:8642/
dotnet run --project KardsSim -c Release -- --mode serve
# 换地址/端口
dotnet run --project KardsSim -c Release -- --mode serve --url http://127.0.0.1:9000/一个 session = 一局对局:POST /games 拿 id,之后反复
GET /games/{id}/state(观测)→ 挑合法动作 → POST /games/{id}/step 推进。
引擎、宿主、直译产物与 --mode selfplay 完全是同一套(HttpServer.NewEngine
建完引擎立刻挂 EngineHost)。这一点很要紧:不挂宿主时引擎会退回读卡面文本猜效果的
EffectPlanner,那 API 跑的就是另一套、而且是最差的规则。
用 TcpListener 手写 HTTP/1.1,而不是 HttpListener —— 后者在 Windows 上依赖
HTTP.sys 与 URL ACL,需要额外权限,受限环境直接起不来。
| 方法 | 路径 | 请求体 | 返回 |
|---|---|---|---|
| GET | / |
— | 服务信息 + 端点清单 + stateSize/actionSize + actionTypes |
| GET | /cards?q=&limit= |
— | 卡池检索(q 匹配 id/name,limit 默认 100、上限 2000) |
| GET | /cards/{name} |
— | 单卡详情(文本、数值、关键词、注册的触发点、Develop 出的卡名) |
| GET | /stats |
— | 卡池统计(按类型/阵营计数、有触发点/有衍生卡的张数) |
| GET | /decks |
— | 预设卡组清单(decks.json):主国/盟国、HQ、张数、complete/missing —— 训练前先看这里 |
| GET | /decks/{key|名字|#序号} |
— | 单个预设:解析结果 + 展开后的卡牌清单(id/copies/name/费用) |
| POST | /games |
{seed?,leftDeck?,rightDeck?,log?} |
建局,返回第一帧观测(含 sessionId) |
| GET | /games/{id} |
— | 等价于 /state |
| GET | /games/{id}/state |
— | 当前观测:状态向量 + 双方视图 + 共享前线(frontline{owner,count,cards})+ 合法动作 + 抉择预览 |
| GET | /games/{id}/legal |
— | {actions, indices, mask, allActions},只要动作集时用它(不带 204 维向量) |
| POST | /games/{id}/step |
{index:N} 或 {action:{...}} |
推进一步:{ok, applied, reward, obs, done} |
| POST | /games/{id}/reset |
{seed?} |
用同一套卡组重开(seed 省略则沿用) |
| GET | /games/{id}/log |
— | 引擎的可读日志(log:true 建局才有内容) |
| DELETE | /games/{id} |
— | 删掉 session |
| POST | /selfplay |
{games?,maxSteps?} |
在服务里跑自对弈并汇总(games 默认 10,maxSteps 默认 2000) |
seed 省略时取 Environment.TickCount;/selfplay 固定用 1000+i 作种,同参数可复现。
leftDeck/rightDeck 有三种写法(省略或 "random" = 随机 30 张):
- 卡组码:
"%%38|5CjQp2pezq;…"(真实构筑卡组,39 张,见「卡组码」一节); - 预设名:
"jp-pol-boom"/"日波炸槽"/"#7",来自decks.json; - 卡牌 id 列表:
"card_event_bpf,card_unit_7_schutzen,…"(,或;分隔)。
# 用推荐卡组建局:左 = 日波炸槽,右 = 另一套(卡组码直接贴)
curl -s -X POST http://127.0.0.1:8642/games -H 'content-type: application/json' \
-d '{"seed":42,"leftDeck":"jp-pol-boom","rightDeck":"ger-usa"}'卡组里有本卡库没有的牌时不报错、不静默:解析时按能解析的牌发牌,
并在 stderr 打一行 [卡组] … 只有 35/39 张;卡库缺 2 张(…)(每种卡组只打一次)。
要事先知道哪些卡组能打完整,用 GET /decks 或 --mode decks。
# 建局(seed 固定 → 可复现)
curl -s -X POST http://127.0.0.1:8642/games \
-H 'content-type: application/json' \
-d '{"seed":42}'
# → {"sessionId":"1a2bf118b","turn":1,"currentSide":"Left","done":false,
# "state":[...204 个数...],"stateSize":204,"actionSize":299,
# "legalIndices":[290],"left":{...},"right":{...},
# "frontline":{"owner":"None","count":0,"cards":[]},
# "legalActions":[{"text":"EndTurn","Type":"EndTurn",
# "SourceId":-1,"TargetId":-1,"HandIndex":-1}],
# "stats":{"illegal":0,"triggers":0,"unhandled":0},"pendingChoice":null}
# 按下标推进(推荐给模型:下标是动作空间的唯一定位)
curl -s -X POST http://127.0.0.1:8642/games/1a2bf118b/step \
-H 'content-type: application/json' -d '{"index":290}'
# → {"ok":true,"applied":"EndTurn","reward":0,"obs":{...},"done":false}
# 也可以给动作对象(便于手写脚本;PlayCard 需要 handIndex 或 sourceId)
curl -s -X POST http://127.0.0.1:8642/games/1a2bf118b/step \
-H 'content-type: application/json' \
-d '{"action":{"type":"Attack","sourceId":17,"targetId":31}}'reward 只在终局给分:1 = 本步行动方(推进前的 currentSide)赢,-1 = 输,
未结束或平局为 0。applied 是动作的可读形式
(EndTurn / Play(hand=3,target=17) / Attack(17->HQ) / MoveFront(17) /
MoveSupport(17) / Choose(#1 id=0))。
[0,180) 3 行 × 5 格 × 12 维。行序:**前线(双方共用一条)** / 己方支援线 / 敌方支援线;
行内格序 = 站位顺序(locationNumber 从小到大,也就是引擎列表顺序);
未占用的格子全 0。**「己方」指当前行动方,所以支援线两块会随轮到谁而互换。**
每格 12 维:
0 占用 1 type/7 2 TotalAttack/12 3 TotalDefense/12
4 KreditCost/12 5 Guard 6 Blitz 7 Smokescreen
8 本回合已攻击 9 Pinned|Suppressed 10 Range/5 11 HeavyArmor/5
[180,204) 24 个全局量(顺序即下表):
双方 HQ/HqDefense、当前方 Kredits/MaxKredits、双方 KreditSlots/MaxKredits、
双方 Hand.Count/MaxCardsOnHand、双方 Deck.Count/StandardDeckSize(39)、双方 Discard.Count/StandardDeckSize、
Turn/40、当前方是否 Left、前线归属(己方 1 / 无主 0.5 / 敌方 0)、
双方场上单位数/(5×2)、双方 Fatigue/10、双方手牌平均费用/MaxKredits、
双方场上总攻击/30、双方场上总防御/60
前线只有一块 5 格:前线同时只能被一方占据(见 场地:只有一条前线),所以这里不需要「己方前线 / 敌方前线」两份 —— 现在是谁占着,看全局量里的前线归属。
手牌内容不在状态向量里(只有张数与平均费用),对手手牌同理 —— 它本来就是隐藏信息,
要打某张牌得先看 left.hand[i](自己的)并记下它的 handIndex。
0..159 出牌区:handIndex × 32 + 目标槽 × 2 + 三选一分支
目标槽 0 = 不需要目标;1..5 前线(第 0..4 格,不分敌我);
6..10 敌方支援线;11..15 己方支援线
160..289 单位区:160 + 己方槽位 × 13
槽位 0..4 己方前线、5..9 己方支援
槽内 0..9 = 攻击敌方前线/支援的目标槽,10 = 打 HQ,
11 = MoveToFrontline,12 = MoveToSupport
290 EndTurn
291..298 待决选择的候选(下标 291 + 候选序号)
GET /legal 与观测里的 legalIndices 是同一套下标;mask 是 299 长的 0/1 数组,
可以直接当策略网络的合法动作掩码。同一个下标恒对应同一语义(同一帧内)。
出牌区的下标可以反推:handIndex = index / 32、目标槽 = index % 32 / 2、分支 = index % 2。
出牌目标槽里前线也只有一份:前线被谁占着是唯一状态,分「己方前线 / 敌方前线」 两套槽位必然有一套恒空。攻击目标槽同理(敌方占着前线时,第 0..4 格就是那些单位)。
GET /legal 与观测里的动作列表按下标一一对应:
| 字段 | 含义 |
|---|---|
indices[i] |
第 i 个合法动作在动作空间里的下标(/legal 的 indices、观测的 legalIndices) |
actions[i] / legalActions[i] |
与 indices[i] 对应的同一个动作(text 可读形式 + Type/SourceId/TargetId/HandIndex) |
mask |
长度 = actionSize 的 0/1 数组,非零个数 = indices 个数,可直接当策略网络的合法动作掩码 |
allActions |
原始合法动作(只有可读文本)。它会比 indices 长:出牌区只编码 5 张手牌,手牌第 6 张起的出牌没有槽位 |
三条不变量(--mode tests 的 HttpActionListIsAligned 用真的 HTTP 路由验过):
actions.Length == indices.Length、mask.Length == actionSize、mask 非零个数 == indices.Length。
手牌超过 5 张时:第 6 张起仍然打得出去,只是没有下标 (动作空间是给模型用的稠密编码,不是引擎的能力边界)—— 用动作对象即可:
curl -s -X POST .../games/{id}/step -H 'content-type: application/json' \
-d '{"action":{"type":"PlayCard","handIndex":5}}' # 实测 ok=true想把这 4 张也纳入动作空间就得把出牌区从 5 张手牌扩到 9 张(PlayBlock 160 → 288),
actionSize 会跟着从 299 变到 427。当前没有改,因为已经按 299 训练/对接的一方会受影响。
二段式选择(Develop / 选手牌)出现时,pendingChoice 非空,而且
legalActions / legalIndices 只剩 ChooseCard 一种,对局不会在你选完之前往下走:
{"kind":"selectCardToDraw","sourceCard":"card_event_bpf","sourceId":12,"isEffect":false,
"options":[{"index":0,"id":0,"label":"card_event_blast"},
{"index":1,"id":0,"label":"card_event_plan"},
{"index":2,"id":0,"label":"card_event_production"}]}用 {"index":291}(= 291 + options[].index)或 {"action":{"type":"ChooseCard","handIndex":1}}
结算。options[].id 为 0 表示这条路径给的是卡名(label)而不是卡 id
(客户端 Develop 传的就是名字数组)。
- HTTP 码只管路由与参数:未知路由 / 找不到 session / 找不到卡 →
404, 请求体不是合法 JSON 之类 →500 {error,type}。方法不对也是 404(没有 405)。 - 动作层面的失败不占 HTTP 错误:
step恒返回200,失败时{"ok":false,"reason":"..."},reason取值illegal index/bad action type/PlayCard needs handIndex or sourceId/body must contain 'index' or 'action'。失败会计入观测里的stats.illegal。 - 支持的请求方法只有
GET/POST/DELETE/OPTIONS(OPTIONS回空对象,方便浏览器直连;HEAD不参与路由)。响应带Access-Control-Allow-Origin: *, 且一律Connection: close,不要依赖长连接。 - 字段命名不统一:匿名类型里显式写的字段是小写(
sessionId/state/legalIndices/pendingChoice/ card view 的id、attack…), 而直接引用 C# 属性的那些保持原名(legalActions[].Type/SourceId/TargetId/HandIndex、/cards的Id/Name/Kredits/Attack/Defense/Range、/cards/{name}的CardSet/OperationCost/HeavyArmor)。 枚举一律序列化成名字("PlayCard"而不是数字)。 - session 只在内存里,重启即丢;一个 session 的多步请求要串行发 (每个连接一个线程、session 没有加锁)。
训练一个「会打 KARDS」的模型,牌得是真人的牌。客户端复制出来的卡组是一串
%%… 开头的代码,解析规则在私服协议文档
(06 · 卡组与卡组码)里,
参考实现(fyserver / kards-server-go)也有一份简化版。这里按文档实现,
并和参考实现的 GetCardsFromDeck 对过账。
%%21|4v3232…sU;;;~;;;|0N1b
││ │└┬┘└┬┘└┬┘└┬┘ │
││ │ │ │ │ └──────┴─ 第 3 段:HQ。前 2 字符是主国 HQ 的卡牌代码(0N=伦敦)
││ │ │ │ └────────── 第 2 段:用 ; 分成最多 4 组,**组号 i = 组里每张算 i+1 张**
││ │ └──┴───────────── (组 0 算 1 张、组 1 算 2 张、组 2 算 3 张、组 3 算 4 张)
││ └──────────────────── 主国代码 + 盟国代码,各 1 位
│└────────────────────── 固定前缀
└─────────────────────── 固定前缀
国家代码:1 Germany、2 Britain、3 Japan、4 Soviet、5 USA、
6 France、7 Italy、8 Poland、9 Finland、a Anzac(OceaniaStorm 的新阵营,
项目里 Faction.Anzac)。前 9 个就是 EFactionEnum 的值,a 是第 10 个。
卡牌代码:每张牌 2 个字符,取自客户端数据表的 deck_code_id
(deckcodes.json,2499 条)。它和 card_type/资产名无关 ——
card_unit_… 那套名字是另一回事。
张数:组号就是倍数,同一张牌可以出现在多个组里累加,但总和不能超过 4。
私服文档的例子 %%21|4v32323232sT… 里 32 出现 4 次 = 4 张。
- 卡牌代码大小写敏感。
0A是card_event_the_empire、0a是card_event_daylight_bombing;ux是德军的 Sd.Kfz. 10、uX是苏军的rush_bal。 用大小写不敏感的字典读这张表,整副牌会悄悄错一截(本仓库的测试盯着这条)。 顺带一提:这份数据表里确实有 17 个代码被两张卡共用(完全相同的字符串, 不是大小写差异),形态很一致 —— 一张 Anzac 卡对一张别的阵营的_bal(平衡版)卡, 例如xX=card_unit_dingo_armored_car(Anzac) /card_unit_lancaster_bal(Britain)。deckcodes.json里这种写成数组,解析时按「卡库里有哪张 + 阵营是否合法(主国/盟国/中立)」 挑:日澳卡组里的xX只能是那张 Anzac 装甲车。 ~到第 2 段结尾要整段丢掉。客户端在它后面塞备注/卡背(官方默认卡组就是…sU;;;~;;;|0N1b)。不先截断再切组,~后面的空组会被算成 5/6/7 张。- 卡组是 39 张,不是 30。
Rules.DeckSize = 30是 BP_Logic CDO 里的NumCardsInDeck,和实测对不上:卡组码解开全是 39 张,私服发牌也是 「每侧 40 = HQ 1 + 手牌 4/5 + 牌库 35/34」。所以引擎给显式卡组按Rules.StandardDeckSize = 39发牌(随机凑牌那条路仍是 30), 状态向量也改用 39 归一化。HQ 卡片本身不进卡组,只当元数据 (没有 HQ 段时用该国默认值:3v柏林 /0N伦敦 /6l长春 /8v斯大林格勒 /ce瑟堡;老写法把 HQ 代码塞在第 0 组开头的那种会先摘出来再数牌)。
| 文件 | 内容 | 出处 / 复现 |
|---|---|---|
deckcodes.json |
2 字符代码 → 卡牌资产名(数组 = 一码多卡),2499 条 | 私服参考实现自带的客户端数据表 library/deckCodeIDsTable2.json;pwsh tools/gen-deckcodes.ps1 重新生成 |
decks.json |
22 套社区推荐卡组(短名 + 名字 + 卡组码) | 用户提供的推荐列表,原样保留卡组码 |
cards.json |
卡牌定义 | 客户端资产导出(KardsDataExtract) |
代码:Core/DeckCode.cs(解析)、Core/DeckPresets.cs(预设)、
Core/GameState.cs 的 DeckLists.Resolve(卡组串入口)、DeckAudit.cs(审计)。
# 22 套预设逐条审计(主国/盟国、HQ、张数、缺哪些牌、有没有超 4 张、一码多卡怎么挑)
dotnet run --project KardsSim -c Release -- --mode decks
# 只审一套 / 审任意卡组码 / 顺带建一局验证发牌 / CI 里严格模式(不完整就退出码 1)
dotnet run --project KardsSim -c Release -- --mode decks --deck jp-pol-boom
dotnet run --project KardsSim -c Release -- --mode decks \
--code "%%38|5CjQp2pezq;5B5Z6w6xtFztzw;p1p6zpzs;pczi" --build
dotnet run --project KardsSim -c Release -- --mode decks --strict
# 自对弈 / 打印 / fuzz 都能直接吃卡组码、预设名、卡牌 id 列表
dotnet run --project KardsSim -c Release -- --mode selfplay --games 200 \
--left-deck jp-pol-boom --right-deck ger-usa --swap-sides
dotnet run --project KardsSim -c Release -- --mode dump --seed 42 --left-deck "%%15|2L2Q2Z31…"HTTP:GET /decks(清单 + 覆盖度)、GET /decks/{key|名字|#序号}(解析结果 + 卡牌清单)、
POST /games {leftDeck:"jp-pol-boom"}。
程序里的入口是 DeckLists.Resolve(spec, …):%% 开头当卡组码、能在 decks.json
里查到的当预设、否则按 ,/; 切 id 列表。解析出缺牌时在 stderr 打一行警告
(每种卡组只打一次),不抛异常也不静默 —— 训练脚本想要严格模式,
先读 GET /decks 的 complete 字段。
22 套里 6 套能完整还原 39 张,其余缺 1~4 张牌 ——
缺的原因是本仓库的卡牌导出不全(_input/Cards 里没有那些 _bal 平衡版资产),
不是解析错误:所有 22 套的代码都能解析、没有未知代码、没有超过 4 张。
| # | 短名 | 名字 | 主+盟 | 张数 | 缺的牌 |
|---|---|---|---|---|---|
| 7 | jp-pol-boom |
日波炸槽 | Japan+Poland | 39/39 | — |
| 17 | ger-usa |
无名德美 | Germany+USA | 39/39 | — |
| 18 | bri-jp |
无名英日 | Britain+Japan | 39/39 | — |
| 19 | bri-usa-2 |
无名英美 | Britain+USA | 39/39 | — |
| 20 | jp-usa |
无名日美 | Japan+USA | 39/39 | — |
| 22 | bri-fin |
无名英芬 | Britain+Finland | 39/39 | — |
| 14 | usa-anz-ramp |
美澳跳 | USA+Anzac | 37/39 | card_event_repel_the_attack |
| 21 | jp-fra |
无名日法 | Japan+France | 37/39 | card_unit_j2m_raiden |
| 13 | sov-selfharm |
自残苏 | Soviet+Poland | 36/39 | card_unit_28th_infantry_regiment |
| 3 | ger-fin-cars |
德芬车 | Germany+Finland | 35/39 | card_unit_14_panzergrenadier、card_event_tank_wedge |
| 4 | ger-anz-vet |
德澳老兵 | Germany+Anzac | 35/39 | 同上 |
| 8 | jp-anz-aggro |
日澳快攻 | Japan+Anzac | 35/39 | card_event_repel_the_attack、card_event_ferocious_assault |
| 12 | sov-anz-mid |
苏澳中速 | Soviet+Anzac | 35/39 | card_event_repel_the_attack、card_unit_stz_5_katyusha |
| 15 | usa-bri-ramp |
美英跳 | USA+Britain | 35/39 | card_unit_2nd_west_africa |
| 9 | jp-pol-intel-sid |
Sid日波情报 | Japan+Poland | 34/39 | card_event_ferocious_assault、card_unit_1st_recon |
| 6 | bri-usa-mice |
米色团 | Britain+USA | 33/39 | card_unit_yokosuka_e14y_bal、card_unit_2nd_west_africa |
| 10 | jp-pol-intel |
日波情报 | Japan+Poland | 33/39 | card_event_ferocious_assault、card_unit_1st_recon |
| 16 | usa-anz-aggro |
美澳极限快 | USA+Anzac | 33/39 | card_unit_4th_brigade_nz、card_unit_7th_brigade_anzac |
| 1 | ger-fin |
无名德芬 | Germany+Finland | 32/39 | card_unit_panther_a_zimm、card_event_tank_wedge、card_unit_30_infantry_regiment |
| 5 | bri-sov |
英苏中立 | Britain+Soviet | 32/39 | card_unit_the_holy_boys、card_unit_stz_5_katyusha、card_unit_2nd_west_africa |
| 11 | sov-bri-demo |
苏英爆破 | Soviet+Britain | 32/39 | 同上 |
| 2 | bri-anz |
无名英澳 | Britain+Anzac | 30/39 | card_event_repel_the_attack、card_event_the_rock_of_gibraltar、card_event_pams、card_unit_2nd_west_africa |
把缺牌补齐的路子:把客户端完整的 Content/Blueprints/Cards 重新导出到
_input/Cards(比现在这份多 600 来个资产),再跑 KardsDataExtract +
KardsTranspiler,然后 --mode decks 应该全绿。
棋盘是 1 条共享前线 + 各自的支援线,不是「每人一条前线」:
GameState.Frontline是一个列表,FrontlineOwner是一个归属标记。- 占领必须有空位:
CanEnterFrontline(s)= 归属为None(无主)或己方。 敌方单位还在前线上时,己方单位进不去(TryPlaceOnFrontline会拒绝, 卡牌效果里「直接放到前线」的入口SpawnCardOnBattlefield(Frontline:true)也走同一个判定)。 - 归属只在整条线清空时回到无主:最后一张前线单位离场/被摧毁时
FrontlineOwner = None,之后双方谁先上谁占。 - 前线被敌方占着时,己方只能打前线的单位(打不到 HQ 与敌方支援线);
己方占着或无人占时,HQ 与敌方支援线才暴露。见
AttackTargetsFor。
射程只限制「支援线打支援线」。客户端规则库的权威判据(cardsCheckFunctions):
not_enough_range ⟺ defender.location != 7 // 7 = 前线
&& attacker.location != 7
&& attacker.range < 2
也就是:
| 攻方 | 目标 | 需要射程 ≥ 2? |
|---|---|---|
| 支援线 | 敌方前线 | 不需要(前线就是挨着的那条线) |
| 支援线 | 敌方支援线 | 需要(射程 1 必须自己上前线才够得到) |
| 前线 | 敌方前线 / 敌方支援线 | 不需要 |
| 任意 | 敌方 HQ | 与射程无关(只看前线是否清空、HQ 是否被 Guard 掩护) |
引擎不自己重写这条:CanAttack 里的逐对判定交给
AttackTargetsFor → ClientCanAttack(客户端规则库),--mode rules 会验一致。
(这里曾经在 CanAttack 里写死 if (!u.OnFrontline && u.Range < 2) return false;,
把支援线上的射程 1 单位整条封死 —— 连打敌方前线都被否掉,比客户端严。)
跑多少局自对弈都不能证明「完备」—— 随机对局抽不到那些卡,等于没测。 所以除了对局,还有九个专门查完备性的模式:
| 模式 | 查什么 | 为什么需要 |
|---|---|---|
decks |
卡组码解析:22 套推荐卡组逐条对账(张数/缺牌/一码多卡/超 4 张) | 训练前确认「哪些卡组这套模拟器能打完整」;缺牌是导出不全,不是引擎问题 |
apicheck |
静态全扫 H.Call 的所有目标,判断有没有地方接 |
不依赖采样运气;能区分「卡牌逻辑可达」和「仅 UI 可达」 |
smoke |
逐卡强制触发它注册的每一个触发点 | 自对弈抽不到的卡在这里一定被跑到 |
triggerhits |
运行期实测每个触发点命中多少次 | 「引擎发了」不等于「有卡接住」,静态审计看不出发空跑 |
triggers |
对照「卡牌注册 / 引擎发出 / 游戏侧分派器」三张表 | 找出白板卡 |
rules |
客户端规则库(cardsCheckFunctions)的 18 条否决理由 vs 引擎侧现状,外加一个 Guard 行为抽检 |
规则只在客户端存在时,AI 会学到不存在的规则 |
paramaudit |
把引擎原语的实现和 UHT 头文件签名逐条比对,找「UHT 是 void 带 out、实现却直接 return」 | 这类缺陷不报错、不进 Unhandled,只是值永远传不回来 |
byref |
扫直译产物:out 形参的本地槽还是零初始化、函数体里却先读的那些函数 | UE 里被调帧能读到调用方传的值,旧发射形态读成 Val.Nothing → 整条效果空转。已清零(见下方「已修:by-ref」),留着当回归哨兵 |
repro1 |
issue #1 的端到端复现:击杀 → 变老兵 → 打 HQ 3 点 | 跨「事件载荷 → 持久帧变量 → 老兵换用 → HQ 合成对象」四层,是回归哨兵 |
paramaudit 存在的理由值得单独说,因为这类缺陷最难发现:
- 写错通道:调用点读的是 out 槽(
Val.Out(__v => L["x"] = __v)), 实现却return了值。调用方永远读到空值。 反过来(调用点读返回值、实现却写 out 槽)同样恒为空。 判断依据是调用点形态,不是函数名。 - 根本没判出 out:签名查不到时转译器按 in 处理,
而 out 实参在调用点是个裸局部变量,被调方写进去的值传不回来。
这类靠
NativeOutParams从调用点证据反推(见该文件的文档)。
两种都表现为「效果跑了但什么都没发生」:不抛异常、不进 Unhandled、
自对弈的「未实现调用 0%」照样是绿的。
这三个层次会给出互相矛盾的结论,而这正是它们的价值:
- 静态覆盖说 61/61 完整 —— 因为它只扫
Fire(Trigger.X)字面量, 包括那些从来没被调用过的辅助函数。本轮就是这么抓出 23 个死包装函数的。 - 运行期命中说 34 个触发点空跑 —— 但其中大多数只是随机对局没抽到对应卡。
- smoke 才能分辨:强制触发后 0 异常 0 未实现,说明分派是通的,空跑纯粹是采样问题。
apicheck 把 2814 个调用目标分成两类:卡牌逻辑可达(从卡牌 ubergraph 或
BP_CardFunctions / BP_GameState_Battle 发起)和仅 UI / 平台可达。
前者是 0 —— 这才是「完备」的可验证定义。
游戏的触发分发链路(已从直译产物里确认):
卡牌 CDO.usedTriggers (TArray<ERegisteredCardFunction>)
↓ CreateCardObject 遍历
UpdateCardFunctionTriggerMap(triggerEnum, cardID)
↓ 写入
GameState.CardFunctionTriggers (map<triggerID, set<cardID>>)
↓ FetchAllCardsWithEventTrigger 查
Execute*Events → 逐个调响应卡的事件函数
「什么时候发哪个触发点 / 谁响应 / 什么顺序」这些信息游戏自己就有 ——
63 个 Execute*Events 函数把 triggerID 硬编码在里面。
引擎侧对应的是 Engine/GameEngine.Triggers.cs 里的 43 个语义化包装
(FireTurnStartTriggers / FireDestroyTriggers …),集中在一处免得散落漏掉。
注意:包装函数必须写成 if (x) Fire(Trigger.A, c); else Fire(Trigger.B, c);
这种显式分支,不能写 Fire(x ? A : B, c) —— 审计是静态扫字面量的,三元表达式它看不见。
cipher 值 = 打出时随机翻开对手手牌的张数,然后通知注册了 OnIntelTriggered 的卡。
两个容易搞错的地方:
- Intel 不是关键字位,也不是标签,是卡上的数值字段
cipher(AddIntelToCard会把它 clamp 到 0..9)。数据在cards.json的raw.cipher。 OnIntelTriggered的第二个形参是整数,不是卡对象 —— 而其他所有触发点的 第二个形参都是「相关卡」。混用不炸但结果全错,所以单开了CardDispatch.FireWith走显式实参。
翻牌只加 seenByCipher 标记,不动手牌顺序:顺序是对局的可见信息,
洗错地方会让同一 seed 跑出不同结果(--mode tests 有断言守这条)。
| 症状 | 根因 |
|---|---|
| 判断永远为假、整段效果被跳过 | 无 context 调用成员函数时接收者发成了 Val.Nothing |
整张卡抛 KeyNotFound |
局部变量直读 L["x"];Kismet 局部槽是零初始化的 |
| 条件判断走反 | 宿主规则回调只回 bool,函数返回值被丢掉 |
| 遍历型卡拿不到元素 | Array_Get 的结果走 out 形参,宿主没回写 |
| 卡面文本变成类型名 | EX_TextConst 的 Value.ToString() 不是字面量,要按 TextLiteralType 取字段 |
| 栈溢出 | 依赖蓝图只当签名表、没转译(见上) |
| 栈溢出(第二类) | 转发壳:A.Foo 调 B.Foo,B 没 Foo 就落回兜底分支调回 A.Foo |
| 编译报 CS0131 | SetArray/SetSet/SetMap 的属性是赋值目标,被当成右值发射 |
| 原语读到错的参数 | 调用约定是 {接收者, 实参...},a[0] 是接收者,实参从 a[1] 起 |
| 追加进数组的项全丢 | 局部 TArray 没初始化:UE 零初始化为空数组,直译产物读成 Nothing,Array_Add(Nothing, x) 是空操作 |
| 牌打出后「找不到自己」 | FindCard 只搜场上和手牌,而 order 打出后立刻进弃牌堆 → GetCardFromID(自己) 返回空,整条效果链断掉 |
| 所有战斗都不掉血 | OnCardDealDamage_ModifyDamageDealt 缺原生默认体(只 24/1638 张卡覆盖它)→ 「不修改伤害」变成「伤害归零」 |
| 每次 select 都取错分支 | SelectInt/SelectString/… 的实参顺序是 (A, B, 条件),不是 (条件, A, B) |
| 所有单位都能移动+攻击 | 客户端是「移动与攻击二选一」,只有坦克和带 CanMoveAndAttackInTheSameTurn 的单位例外 |
| 加指挥点槽被卡在 12 | getMaxPossibleKredits 应给绝对上限 24(12 只是每回合自然增长的上限) |
| 50 张反制指令从不触发 | 读的是一个谁都不写的 gotcha 布尔字段;真实字段是 gotchaActivated(int,>0 = 已激活) |
| 持续站场的光环完全不生效 | OnEnterPlay / OnLeaveBoardOrOwner 这些裸自事件不在 ERegisteredCardFunction 里,注册触发点那条路找不到它们,必须在真实时机由引擎直接补调 |
| 光环离场后撤销不掉、加成永久残留 | 撤销要在「牌还站在场上」时做(客户端是 ExecuteOnBeforeLeaveBoardOrOwnerEvents);在 FireDestroyTriggers 里调时牌已经标了 Destroyed、location 变成弃牌堆,GetCardsToTheLeft(self) 恒空 |
| 两个光环叠加时互删 | 攻击加成只记总额,撤销只能清零 → 改成客户端那样按 instigatorID 记账(buffsFromCards) |
| 「是否被本卡加成过」判断永远错 | isBuffedByCard 是卡上的原生方法,实参是 (instigatorID, out),接收者才是卡;按 a[1] 取卡等于拿一个整数当卡查 |
| 观测/动作空间里有两条前线 | 前线本来只有一条且同时只被一方占据,编码却按「己方前线 / 敌方前线」写了两份 → 恒有一份空着(白占 60 维 + 5 个目标槽) |
| 支援线上的射程 1 单位完全不能攻击 | CanAttack 里写死了 !OnFrontline && Range < 2 → false;客户端只在「双方都不在前线」时才要求射程 ≥ 2,打敌方前线本来是可以的 |
HTTP 的 actions 与 indices 按下标配对会错位 |
actions 取的是 LegalActions() 原始列表,indices 取自 Enc.Mask()(丢掉没有动作槽位的动作)→ 两者长度不同。现在都从同一份列表来,原始列表挪到 allActions |
| 「本回合 +N 攻击」变成永久 | 临时加成靠 AddBuffsToRemoveEndOfTurn 按来源登记、回合结束 RemoveBuffsEndOfTurn 撤销;这条链在无头模拟里没有调用者(登记用的映射也没 seed)→ 引擎自己登记并在 EndTurn 里按来源撤销 |
- 1671 个资产 / 6434 个函数,0 空体,1 处未支持节点(
BP_EntryPointActor,非战斗逻辑) - 47.5 万行直译代码 0 错误编译通过
- 自对弈 150 局:0 异常 / 0 卡死 / 0 非法动作 / 0 未实现调用
- 全卡 1638 张强制演练:0 异常 / 0 未实现调用
- 触发点覆盖 61/61(卡牌注册的触发点,引擎全部会发)
- 宿主 API:卡牌逻辑可达的缺口 0 个(另外 424 个仅 UI / 平台可达,无头模拟不会走到)
- 动作空间:出牌 = 手牌 × 目标 × 三选一分支,另有抉择区与 EndTurn,
棋盘编码成 1 条共享前线 + 2 条支援线,
stateSize=204、actionSize=299 - 卡组:卡组码解析已接入(
%%…/ 预设名 / id 列表三种写法),显式卡组按 39 张发牌; 22 套社区推荐卡组里 6 套能完整还原,其余缺 1~4 张(本仓库卡牌导出不全,见「卡组码」一节) - 已知未做:没有 UI 就无法复现的选择点 —— 部分卡(如
card_unit_hampshire_regiment) 的 Develop 结果在客户端是UI 直接做的,它们没有自己的OnHandTargetSelected, 无头模拟里选择能被发起和结算,但效果不落地 - 已修:by-ref 形参被当成纯 out(曾经 48 处 / 40 个函数,
--mode byref现在 0) - 已修:撤退(效果驱动的位移接回引擎,含「支援线满改回手牌」那一支)
直译产物曾经把 out 形参发射成「调用方不传值,退出时回调写回」:
var __out_cards = args.Length > 0 ? args[0].As<Action<Val>>() : null;
L["cards"] = Val.Nothing; // ← 读到的永远是空(旧形态)这在只写不读时是对的。但 UE 的 Kismet 里所有形参都是把求值结果送进被调帧的:
被调函数能读到调用方传进来的值,out 只表示「退出时还要写回」。
于是凡是「把 out 形参当输入读」的函数都是空操作 —— 不报错、不进 Unhandled、
smoke 与自对弈也全绿。实测 48 处 / 40 个函数,影响对局的包括:
| 函数 | 后果(修好前) |
|---|---|
GetRandomCard(cards) |
所有「随机一个敌方单位」类效果空转(返回空) |
ApplyMakeCardRetreat(cards) |
把单位「送回手牌」什么也不做 |
ApplySetCardsSeenByCipher(card) |
Intel 的翻牌链 |
AttackCard(defenderCardID) / ApplyDestroyMultipleCards |
攻击/批量摧毁的分派 |
ExecuteOnDestructionEffectTriggered |
摧毁效果分派 |
TriggerMultipleDeploymentEffects(inputCards) |
「触发多个部署效果」 |
GetRandomKreditCombo / SpawnUnitsWithKreditCombo / MoveCards(EnemyUnits) / findPairOfUnits |
随机/批量类效果 |
SortCardsByLocationNumber / GetNewLocationNumbers / CreateLocationNumberGapForCard |
撤退时的位置重排 |
CanAttack(cardsInAttackedLocation) |
规则库里「未明牌 Covert」那一段(读的是空数组 → 循环不执行) |
现在按 in-out 发射:
- 调用点:
Val.Out(当前值, 写回回调)——Val里新增OutSlot承载 「初值 + 回调」,Val.In读初值;旧形态Val.Out(回调)仍然兼容; - 被调帧:
L["x"] = args[i].In; - out 形参被当容器就地追加时(
GetNewLocationNumbers这类)额外补一句if (L["x"].IsNothing) L["x"] = H.MakeArray(new Val[] { });—— 调用方的局部在 UE 里是零初始化(空容器),直译产物读成Nothing而Array_Add(Nothing, x)是空操作;但也不能无条件清零,那会把调用方送进来的列表冲掉。
--sigdeps 需要 BP_GameState_Battle / Library/* 的 uasset,而仓库里只有
_input/Cards 与 _input/Logic。缺了它们,GameStateRef.<谓词>(out x) 这一族
(GetAllCardInBattle、IsThereGameplayRestriction…)会判不出 out,实参被当成 in。
对策是 KardsTranspiler/CommittedOutParams.cs:从已入库的那份产物反推签名 ——
那份产物是资产齐全时生成的,它的调用点就是权威证据(扫全仓带 Val.Out 的实参,
按 (函数, 形参个数) 归并,同一个键必须一致;本次 95 个键零冲突)。
它只作为资产签名查不到时的兜底一级(Emitter.ParamMods 的 2d),拿到完整资产后不再命中。
这次落地的范围:只覆盖能生成的部分(Cards + BP_CardFunctions + BP_Logic),
保留 _deps/ 与 _index.g.cs(后者照抄会丢掉依赖资产的索引);
_deps/ 里 out 形参的绑定行按同一规则手工改成 in-out 形态(501 行,机械替换)。
拿到完整资产后应当整棵重新生成一次,把这两处手工痕迹抹掉。
修好的正向证据是 --mode tests 的 ByRefFixMakesRandomTargetsWork:
GetRandomCard 从候选里取到了牌(修好前恒为空),空候选返回空而不崩。
顺带暴露并补上的宿主缺口:SetStaticCampaignName(静态元数据,no-op)、
OnBeforeRetreat(原生事件默认体:不阻止)、Set_IsNotEmpty/Set_IsEmpty(集合谓词,走 out 槽)。
KARDS 的 Retreat(MakeCardRetreat)= 前线退到支援线;支援线满时改回手牌。 这条链整段是蓝图自己走的:
MakeCardRetreat → ApplyMakeCardRetreat
├─ 支援线有空位 → CardLocationMoved(newLocation=5/6) → InjectCardIntoLocation
└─ 支援线满 → MoveCardFromBoardToOwnersHand → CardLocationMoved(newLocation=3/4)
两条最后都落到 SetCardLocationAndLocNumber
SetCardLocationAndLocNumber 只写镜像的位置,引擎自己的列表不会变 ——
于是「撤退」在无头模拟里等于没发生。宿主现在在这一步把位移接回引擎
(GameEngine.ApplyEffectDrivenLocationChange):
- 前线 → 支援线:摘出前线、放进支援线、重排位置号、重算掩护;前线清空时归属回无主;
- 场上 → 场外(手牌 3/4、牌库 1/2、弃牌堆 8):摘出占位、归档,落点用引擎自己的
Owner(蓝图的originalSide宿主没 seed,读出来是空,靠它选边会把牌塞到对面去); - 不补发任何事件:这两条路上蓝图自己会发(
ExecuteOnMoveFromFrontlineCardEffects、ExecuteOnBefore/AfterLeaveBoardOrOwnerEvents),引擎再发就是双份; 引擎只负责把场面搬对。 - 范围只到撤退用得到的两支:场外 → 场上(出牌 / 生成)仍由引擎自己的放置流程负责, 遇到就直接不动。
顺带修掉两个让这条链跑不通的宿主缺口:
| 缺口 | 后果 |
|---|---|
FetchCardsByLocation 的 isLocationFull 恒返回 false |
蓝图永远以为支援线有空位 → 所有撤退都往支援线塞、突破行容量;现在按真实容量答(手牌 9、行 5) |
GetHandLocationBySide 只有 out 槽、没有返回值 |
有一处调用点读的是返回值(ApplyMakeCardRetreat 决定去支援线还是手牌那句),拿到的恒为空 → 回手牌那一支落点算成 none 而整段不动。现在 out 槽与返回值都给(这不是本次翻新引入的:入库产物同样如此) |
originalSide 没 seed |
卡的「原属阵营」读成空,回手牌 / 洗回牌库的落点算到对面 |
回归哨兵是 --mode tests 的 RetreatToHandMovesTheCardAndRetractsAura:
三张一起从「必须撤退」退到支援线(引擎列表真的变了、位置号重排、归属回无主、
光环跟着换行)、支援线满时改回手牌(两张都进手牌、前线清空)。
已知怪相(与客户端同源,不当作正确行为):一批牌一起撤时是一张一张搬的 ——
先搬走的那张已经不在场上,等后搬的光环卡用「我左边有谁」去找自己加过 buff 的牌时
已经找不到它,+2 会留在那张已经在手牌里的牌上。要彻底修得让「撤掉某个来源的加成」
按来源查全场(而不是按左右邻),那是另一件事;测试里用 已知怪相:… 那条断言记录着。
调试开关(默认关闭,读一次环境变量存静态字段):KARDS_TRACE_CA=1 打印每次
ChangeAttack 的 (目标, 来源, 增量, changeType);KARDS_TRACE_LOC=1 打印每次
效果驱动的位移请求。查加成/位移时序时省事。
需要游戏资产(.../Content/Blueprints/Cards 全量导出 + m.usmap):
dotnet run --project KardsTranspiler -c Release -- \
--in <Cards 目录> \
--usmap <m.usmap> \
--out KardsSim/KardsSim/Generated \
--sigdeps <依赖蓝图目录>--sigdeps 指向 BP_GameState_Battle + Library/* 等卡牌目录之外的蓝图。
它们不只是签名来源,也要一起转译——否则宿主按名字找不到 GetXxx,
会退回 BP_CardFunctions 里的同名转发壳,而壳又调回 GameStateRef.GetXxx,
名字一样、接收者没落地,就无限递归到栈溢出。
--sigdeps 还必须带 BP_CardFunctions.uasset(卡牌 API 的 out/in 只能从它的
FunctionExport.LoadedProperties 拿,头文件覆盖不全)。
这是最容易错的地方,做成了四级回退:
- 声明资产自己的
FunctionExport.LoadedProperties(有CPF_OutParm/CPF_ConstParm原生标志)——最权威 - 跨资产蓝图扫描,按函数名索引(
EX_LocalVirtualFunction/EX_VirtualFunction只带名字、不带FPackageIndex) - UHT 头文件里
T&vsconst T& EngineLibraryParams里给引擎库手写的表
同理,接收者按 import 链第二层(声明类)判定:
Function → Class → Package。/Script/kards 下的是成员函数(接收者 self),
KismetArrayLibrary 之类的是静态库(接收者 Val.Ref("<库名>"))。
- 1671 个资产 / 6434 个函数,0 空体,1 处未支持节点(
BP_EntryPointActor的 ubergraph,非战斗逻辑) - 47.5 万行直译代码 0 错误编译通过
- 自对弈 150 局:0 异常 / 0 卡死 / 0 非法动作,宿主未实现调用 0%
- 单卡机制验证(
--mode tests)153/153 通过:Intel、事件载荷、老兵升级、Blitz、 数据表、三选一、CDO 标志位、移动/攻击二选一、指挥点槽 24、反制指令、二段式抉择、 持续站场光环的加/撤与跟随移动(card_unit_flaming_matilda_anzac)、 「前线只有一条且要抢」「射程 1 上前线才够得到敌方支援线」、 「本回合 +N 攻击」到期撤销、by-ref 修复后随机目标真的取得到牌,撤退(引擎列表真的跟着变)、 HTTP 侧动作列表按下标对齐(用真路由跑),以及卡组码这一批: 组号即倍数 / 大小写敏感的码表 /~截断 / 老写法的 HQ 借位 / 一码多卡按阵营挑 / 缺牌如实报告 / 22 套预设都能解析
卡牌效果的主入口叫 OnPlayedFromHand(1638 张卡里 942 张有它),
但它不是 ERegisteredCardFunction 的成员,所以按 Trigger 枚举名找函数的触发分发
永远找不到它。客户端走的是 BP_CardFunctions.CardPlayedFromHand → OnPlayedFromHand;
无头模拟里出牌流程是引擎自己实现的,就必须自己补这一步。
以前写的是 RunCardEffect(c, Trigger.NotAvailable),而 CardDispatch.Fire 对
NotAvailable 是直接 return false —— 于是 691 张 order 与 241 张部署单位的效果
全都不执行,且不报错。现在由 EngineHost.PlayCardFromHand 显式调它。
同理,Intel 原本指望 CardPlayedFromHand 里的 SetCardsSeenByCipher 来发,
而那条链引擎不走,所以引擎现在自己发一次(EngineHost.ApplyIntel)。
另一条死路:蓝图侧的触发登记表 CardFunctionTriggers / AllCardsInBattle
从来没被填充过(实测恒为 0),所以 FetchAllCardsWithEventTrigger 那条路是死的 ——
所有触发点实际只走引擎自己的 FireTrigger → CardDispatch。改动时不要指望蓝图那条路。
「站在场上就一直生效」的效果(光环 / 位置型)走的是另一套入口:
BP_CardFunctions 的分派器直接调卡上的 OnEnterPlay、OnLeaveBoardOrOwner、
OnMoveToFrontline、OnMoveFromFrontline。这些名字不在 Trigger 枚举里,
按触发点找函数永远找不到,引擎必须在真实时机自己补调
(EngineHost.InvokeBareEvent)。没覆盖这个可选事件的卡在 FnIndex 里查不到,
是正常 no-op,不会污染 Unhandled。
放置时机有讲究,四个入口里有两个踩过坑:
| 入口 | 调用点 | 时机要点 |
|---|---|---|
OnEnterPlay(card, method=1) |
DoPlayCard |
落在场上、LocationNumber 排完之后 |
OnMoveToFrontline(card, forceMove, moveCost) |
DoMove 上前线(不含从手牌部署) |
先重排编号,再发事件 |
OnMoveFromFrontline(card) |
DoMove 退到支援线 |
先重排编号,再发事件 |
OnLeaveBoardOrOwner(card, goingTo, method) |
DestroyCard 最前面 |
必须在这张牌还站在场上时调 |
部署 ≠ 移动。客户端 OnMoveToFrontline 的分派器
(ExecuteOnMoveToFrontlineCardEffects)只有一个调用点,在
MoveUnitFromSupportToFrontLine 里;部署(含 Ambush 直接落前线)走 OnEnterPlay。
引擎的部署流程会把单位直接放到前线(没有客户端的「先落支援线再移动」两步),
所以这里必须显式区分:TryPlaceOnFrontline(c, isDeploy: true) 不发移动事件。
不区分的话同一件事会先后触发两个入口 —— 光环卡的两个入口是「进入时加」和
「移动后先全撤再按新位置重加」,先撤后加是幂等的,加完再进一次就变成双倍
(实测 flaming_matilda +2 变 +4)。
最后一条是这一轮的关键:客户端的销毁流程第一步是
ExecuteOnBeforeLeaveBoardOrOwnerEvents(先取旧位置,再 CardLocationMoved)。
放在 FireDestroyTriggers 里调就晚了 —— 那时牌已经标了 Destroyed、
location 也变成弃牌堆,而光环卡撤销时要先 GetCardsToTheLeft(self) 找回那些牌,
那道查询的第一条守卫正是「本卡 location ∈ 场上(5/6/7)」,于是恒空:
加成留在邻居身上再也撤不掉,比没有光环更糟。
加成必须按来源记账。客户端把这份账存在卡上的 buffsFromCards 映射里
(键 = instigatorID),ChangeBuffsFromCards 增减、isBuffedByCard 查询都读它。
只记总额(BuffAttack)的话,ChangeAttack(邻居, self.cardID, 0, 4) 这种
「撤销我这一个来源」只能退化成「把总额清零」——两个光环叠在同一张卡上时,
先离场的那张会把另一张的加成一起抹掉。现在:
Card.AttackBuffBySource(来源 InstanceId → 该来源加了多少)Card.BuffAttackFromNoSource(instigatorID ≤ 0的匿名加成);
ChangeAttack的changeType:0/1按来源累加、2= SetValue(清空后只留这一个)、4= 只撤销该来源那一份(全池 64 处调用点都带自己的cardID当instigatorID);- 总额与镜像
attackBuff由WriteAttackBuff重算,保证「总额 = 各来源之和」; isBuffedByCard/getCardsBuffedByThisCard改成精确查来源表。注意前者的实参布局是card.isBuffedByCard(instigatorID, out isBuffed)——a[0]才是卡,写成a[1]等于拿一个整数当卡查,恒 false,撤销光环的那道!isBuffedByCard(...)守卫永远不通过。
GetCardsToTheLeft / GetCardsToTheRight 本身是 BP_CardFunctions 的蓝图函数
(Card / unitsOnly / includeCovert / out cards,语义是「同一 location 上、
locationNumber 更小/更大」),宿主故意不实现,放行给蓝图;
引擎只负责把 location / locationNumber 喂对(RefreshLocationNumbers,
最左为 0,与 SetRightLeftMostWhenPlayed 的判定一致)。自己再写一份迟早漂移,
而且很容易漏掉「本卡在场上」那道守卫。
「本回合 +N 攻击」和「常驻光环」用的是同一个 changeType(0),区别不在参数上,
而在登记:
- 「本回合」走
AddAttackUntilEndOfTurn(card, instigatorID, n), 它给完加成再调AddBuffsToRemoveEndOfTurn(0, instigatorID)把来源记进GameStateRef.BuffsToRemoveEndOfTurn(buffType 0 = 攻击); - 回合结束时
RemoveBuffsEndOfTurn遍历登记过的来源,对每个来源取getCardsBuffedByThisCard(来源)再逐张撤销; - 常驻光环(
flaming_matilda等)不登记,自己在该撤的时候ChangeAttack(牌, self.cardID, 0, 4)—— 所以它不会随回合结束消失。
这条链在无头模拟里跑不到:ExecuteEndOfTurnEvents 没有任何调用者,
而且 BuffsToRemoveEndOfTurn 这个映射成员没被 seed、它往里存的
MakeStruct 节点转译产物里也从没被构造过(SetMember 打在 none 上)。
结果是临时加成变成永久的。现在宿主把 AddBuffsToRemoveEndOfTurn 接成引擎原语
(登记 instanceId),GameEngine.EndTurn 在 OnEndOfTurn 之后调 ExpireTempBuffs()
按来源精确撤销 —— 撤销会扫全场(含手牌/弃牌堆:被临时加攻的牌可能已经离场)。
按来源记账在这里再次是关键:撤销只扣那一个来源的那一份,同张卡上的常驻加成不受影响。
回归哨兵是 --mode tests 里的 TempAttackBuffExpiresAtEndOfTurn:
AddAttackUntilEndOfTurn 立刻生效 → 同时挂一个不登记的常驻加成 →
回合结束后只剩常驻的那份 → 再过两回合也不会把常驻的带下去。
光环本身的两组断言共 19 条:
AuraAppliesAndRetracts(11 条):左邻各 +2、右侧与自身不加、按来源记账、 镜像一致、两个光环叠加为 +4、先走的那张只撤掉自己的 +2、两张都走之后回到基础值、 没有未实现的宿主调用;AuraFollowsTheUnitWhenItMoves(8 条):支援线上的牌没有被入场事件加过攻、 真的移动到前线只加一次 +2(不是 +4)、退回支援线后撤销。
| 选择 | 机制 | 现状 |
|---|---|---|
| 出牌目标 | 卡自己的 CanPlayFromHand + CDO 的 selectTargetOnPlayedFromHand(401 张) |
是动作(GameAction.TargetId) |
| 三选一 | 卡上的 ChooseOne 成员决定分支(48 张,各 2 支) |
是动作(GameAction.ChoiceIndex) |
| Develop / 选手牌 | UI 往返:NotifySelectCardToDrawPending 通知 → OnHandTargetSelected 回调 |
是动作(二段式,见下) |
前两类是出牌前就能定的:卡牌逻辑同步执行,没有挂起/恢复机制
(直译产物里 AwaitInput 一次都没被调用),所以选择只能前置。
第三类相反 —— 效果跑到一半才需要选。客户端靠 UI 往返完成,引擎把它做成两段式:
- 效果体调
NotifySelectCardToDrawPending→ 宿主记下待决选择(GameEngine.Pending); - 有
Pending时LegalActions只返回候选项(ActionType.ChooseCard), 对局不会在你没选之前往下走; - 选定后回调发起卡自己的
OnHandTargetSelected(选中的卡ID, instigatorID)。
HTTP 侧有抉择预览:GET /games/{id}/state 会给出
pendingChoice.options[{index,id,label}],AI 后端据此决策
(响应形状与结算方式见上面 HTTP 接口 → 抉择预览)。
WhichChooseOne 以前恒返回第 0 支(确定但不是决策点),现在读卡的 ChooseOne 成员,
引擎在出牌前写入;没写时退回可插拔决策器,保证不接 AI 时仍可复现。
cards.json、deckcodes.json 与 KardsSim/Generated/ 是从游戏客户端资产派生的,仅供本地研究 /
AI 训练,请勿再分发。游戏资产本身(_input/)不入库。
deckcodes.json 的原始数据表来自私服参考实现(fyserver)自带的客户端数据导出,
decks.json 里的卡组码来自社区推荐列表 —— 都只作本地模拟用。