feedback1 integrate: WB follow-up - bounty lists, 机密商店 and 战术决策 from 66 official screenshots (22 matches)

Merges the 7 WB commits after the first WB merge (d1f8a49..2cb84bd): R3 one of 10 fixed
sets (9 seen), R9 one of 6 boss groups picked by the matches they came in, R11 one of the
7 seen lists of 7; R11 families 14 / 4 / 4; the 机密商店 and the 战术决策 can offer the
same card twice (R11 shop: two VI, at least one V and one 盟约之币). data/choices.json
rebuilt with tools/build-data.mjs --offline (identical to the branch's). The tactic bot
test uses a real seat (the merged bot scores tactic cards on the player's bonds).
DESIGN §21.2 / §7 / §21.20 and CHANGELOG describe the 22-match model.
This commit is contained in:
sganggs committed 2026-10-03 11:27:46 +08:00
commit f142012033
22 files changed
+1533 -324

No files matched your search

+3 -1
View File
@@ -44,7 +44,9 @@
### 对局与决策
- 悬赏决策按 11 局官方截图的结构出卡:第 3 回合是固定的「接下来两场作战」组合,第 9 回合是一组首领悬赏加便宜的悬赏,第 11 回合是特异巨兽组合加 1 资金卡;多轮悬赏不再出现。
- 悬赏决策按 22 局官方截图的结构出卡:第 3 回合是 10 套固定的「接下来两场作战」组合之一,第 9 回合是 6 组首领悬赏之一,第 11 回合从截图里见过的 7 组「下场战斗」悬赏中抽;每组里出 6 张不同的卡,多轮悬赏不再出现。
- 绝境 / 终极第 11 回合按官方比例出悬赏决策、机密商店或战术决策(22 局里 14 / 4 / 4),不再出道具补给。
- 机密商店和战术决策和官方一样可能出现两张相同的卡,各算一张,可以分别被选走。第 11 回合的机密商店按官方:2 件 VI 阶、至少 1 件 V 阶和 1 枚盟约之币;第 11 回合的战术决策只出增益卡。
- 隐秘核心的「伤害超过 X%」播报只算这一场领袖的伤害,上一回合的播报不会拖到下一回合。
- 播报按官方优先级排队,领袖伤害播报不会被升级调度中心等播报挤掉。
- 修复联防中隐德来希的心烛在超时时被算成漏怪、导致战斗结果被服务器拒绝的问题。
+1 -1
View File
@@ -1 +1 @@
{"events":{"artifact_paid_1":{"id":"artifact_paid_1","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_1_s":{"id":"artifact_paid_1_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_2":{"id":"artifact_paid_2","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_2_s":{"id":"artifact_paid_2_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_3":{"id":"artifact_paid_3","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_3_s":{"id":"artifact_paid_3_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_4":{"id":"artifact_paid_4","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_4_s":{"id":"artifact_paid_4_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_5":{"id":"artifact_paid_5","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_5_s":{"id":"artifact_paid_5_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"bossInitial_1":{"id":"bossInitial_1","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_1_s":{"id":"bossInitial_1_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_2":{"id":"bossInitial_2","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_2_s":{"id":"bossInitial_2_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_3":{"id":"bossInitial_3","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_3_s":{"id":"bossInitial_3_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_4":{"id":"bossInitial_4","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_4_s":{"id":"bossInitial_4_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"Line truncated
{"events":{"artifact_paid_1":{"id":"artifact_paid_1","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_1_s":{"id":"artifact_paid_1_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_2":{"id":"artifact_paid_2","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_2_s":{"id":"artifact_paid_2_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_3":{"id":"artifact_paid_3","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_3_s":{"id":"artifact_paid_3_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_4":{"id":"artifact_paid_4","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_4_s":{"id":"artifact_paid_4_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_5":{"id":"artifact_paid_5","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_5_s":{"id":"artifact_paid_5_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"bossInitial_1":{"id":"bossInitial_1","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_1_s":{"id":"bossInitial_1_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_2":{"id":"bossInitial_2","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_2_s":{"id":"bossInitial_2_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_3":{"id":"bossInitial_3","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_3_s":{"id":"bossInitial_3_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_4":{"id":"bossInitial_4","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_4_s":{"id":"bossInitial_4_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"Line truncated
+7 -5
View File
@@ -318,12 +318,14 @@ Types: `BAND_INITIAL` 41, `BOND` 23, `EQUIP` 115, `ENEMY_GAIN` 129 (悬赏), `BU
| Key | Example | Meaning |
|---|---|---|
| `events[id]` | `{"id":"enemy_initial_1","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"…","color":"#35d8b4","family":"bounty","solo":false,"training":false}` | 109 official choice events; `_s` = solo copies; `usedBy` on events opened by items (`hunter_band_1` ← 教鞭 / “神秘顾客”) |
| `families` | `{"bounty":{"name":"悬赏决策",…},"supply":{…},"shop":{…},"tactic":{…}}` | 悬赏决策 / 道具补给 / 机密商店 / 战术决策 |
| `families` | `{"bounty":{"name":"悬赏决策",…},"supply":{…},"shop":{…},"tactic":{…}}` | 悬赏决策 / 道具补给 / 机密商店 / 战术决策; `desc` = the overlay header after the name, the official event text (战术决策 "进行协同调整,做好迎战准备。", as on the official screens and in effectChoiceInfoDict `buff_select_*`) |
| `format` | `{"multi":{"cards":6,"pickOrder":"random","firstPickSec":30,"otherPickSec":16,"onTimeout":"autoPickRandom","eachPlayerPicks":1},"solo":{"cards":3,"timer":null},"opensAfterIncome":true}` | |
| `cards.bounty[]` | `{"effectId":"enemyeffect_10_4","name":"悬赏·飞行I","tier":1,"coin":1,"payout":"kill","rounds":2,"multiRound":false,"enemyKey":"enemy_1005_yokai","count":1,"adds":[…],"draft":true,"draftExcluded":null,"draftPool":"initial","series":10}` | `payout` `kill` (killer gets coins, 联防 helpers too) / `perfect` (chooser's own phase perfect); `rounds` = battles affected (99 = all remaining); `tier` from the I/II/III suffix (else coin value); `enemyKey` = the effect's `enemy_id` (fixed per card; the title only names category and tier); `draft` = offered by a 悬赏决策 draft (86 cards); else `draftExcluded` `perfect` (战术特训: "仅由法术教鞭生成" — the 教鞭 Art's cards; 20) / `hidden` (鸭爵 / 高普尼克 / 流泪小子 / 圆仔; 4) / `unseen` (in none of the 27 official bounty drafts of the user's 33 screenshots: the 7 multi-round cards, enemyeffect_3_*, the boss bounties of no seen R9 group; 19, [ASSUMED] not offered) — build-data `bountyDraftExclusion`, `server/match/choices.js draftBounty`, user playtest #6 item 4, player feedback after 0.1.0 report #2; `draftPool` = the kind of draft that offers it: `initial` (R3: the 42 "接下来两场作战" cards), `boss` (R9: 19 boss bounties + 源石虫·特训), `hunter` (R11: the faction _7 / _8 and 特异 series 16 "下场战斗" cards, 24), null when not drafted; `series` = N of `enemyeffect_N_M` (null otherwise) |
| `cards.tactic[]` | `{"effectId":"map_m01_1","name":"模拟战场演变·模式一","kind":"terrain","stageId":"act1autochess_m01","team":false}` | `kind` `ally` / `enemyDebuff` / `terrain` (only for the match stage); `team` = also given to teammates |
| `bountyDrafts[kind]` | `{"initial":{"events":[…],"slots":10,"sets":[{"cards":[…6],"seen":[1,3,5,9]},…],"mixed":{"series":[10,11,12,13,14,15,20],"count":6,"tiers":[1,1,1,2,2,3]},"count":6},"boss":{"events":[…],"templates":[{"featured":[…],"fill":[…],"seen":[1,5]},…],"count":6},"hunter":{"events":[…],"slots":15,"templates":[{"featured":[…],"fill":[…],"seen":[1,6]},…],"rule":{"giants":[…],"groupCards":[…],"groupSizes":[1,2,3,4],"fill":[…]},"onePerSeries":[10,…,15],"count":6}}` | the official 悬赏决策 structures, read from 33 screenshots of 11 official co-op 绝境 / 终极 matches (player feedback after 0.1.0 report #2; build-data `BOUNTY_INITIAL_SETS`, readings in `test/fixtures/official-bounty-drafts.json`, `seen` = its match numbers): `initial` = one of `slots` fixed sets, the seen `sets` or for an unseen slot `mixed` (one card from each of `count` of `series`, with `tiers`) [ASSUMED]; `boss` = a `templates` entry: its `featured` bosses + `fill` drawn to 6 (uniform among the seen groups [ASSUMED]); `hunter` = one of `slots` events: a seen `templates` group — its `featured` 特异III giant + tier-II cards, always together (matches 1 and 6 share the whole group, only the tier-I card differs) — or for an unseen slot a `rule` group (one of `giants` + `groupSizes` of `groupCards`), then tier-I `fill` to 6, at most one card per `onePerSeries` series (the group / fill split by tier, the unseen slots and the uniform pick among the events [ASSUMED]) |
| `schedule[modeId]` | `{"spRounds":[3,6,9],"rounds":{"3":{"families":[{"family":"bounty","weight":50},…],"cards":6,"supplyTiers":[1,4],"bountyDraft":"initial","events":{"bounty":[…],"supply":[…],"shop":[…],"tactic":[…]},"assumed":true}}}` | research 01 A4 defaults: pick a family by weight; `supplyTiers` = item tier window for 道具补给; 机密商店 draws any tier; `bountyDraft` = the `bountyDrafts` kind of the round's 悬赏决策 (R3 / 险境 R6 `initial`, R9 `boss`, R11 `hunter`); 绝境 / 终极 R11 = 悬赏决策 45 / 机密商店 35 / 战术决策 20 (the official co-op R11s: 5 / 4 / 2 of 11, no 道具补给; weights [ASSUMED]; solo takes the same weights [ASSUMED: no solo screenshot — extrapolated from co-op, the data has solo R11 悬赏决策 events `bounty_hunter_*_s`]); `events` = the official event ids of that kind (`enemy_initial_*`, `bossInitial_*`, `bounty_hunter_*`), usable as the phase header |
| `cards.bounty[]` | `{"effectId":"enemyeffect_10_4","name":"悬赏·飞行I","tier":1,"coin":1,"payout":"kill","rounds":2,"multiRound":false,"enemyKey":"enemy_1005_yokai","count":1,"adds":[…],"draft":true,"draftExcluded":null,"draftPool":"initial","series":10}` | `payout` `kill` (killer gets coins, 联防 helpers too) / `perfect` (chooser's own phase perfect); `rounds` = battles affected (99 = all remaining); `tier` from the I/II/III suffix (else coin value); `enemyKey` = the effect's `enemy_id` (fixed per card; the title only names category and tier); `draft` = offered by a 悬赏决策 draft (86 cards); else `draftExcluded` `perfect` (战术特训: "仅由法术教鞭生成" — the 教鞭 Art's cards; 20) / `hidden` (鸭爵 / 高普尼克 / 流泪小子 / 圆仔; 4) / `unseen` (in none of the 59 official bounty drafts of the user's 66 screenshots: the 7 multi-round cards, enemyeffect_3_*, the boss bounties of no seen R9 group; 19, [ASSUMED] not offered) — build-data `bountyDraftExclusion`, `server/match/choices.js draftBounty`, user playtest #6 item 4, player feedback after 0.1.0 report #2; `draftPool` = the kind of draft that offers it: `initial` (R3: the 42 "接下来两场作战" cards), `boss` (R9: 19 boss bounties + 源石虫·特训), `hunter` (R11: the faction _7 / _8 and 特异 series 16 "下场战斗" cards, 24), null when not drafted; `series` = N of `enemyeffect_N_M` (null otherwise) |
| `cards.tactic[]` | `{"effectId":"map_m01_1","name":"模拟战场演变·模式一","kind":"terrain","stageId":"act1autochess_m01","team":false}` | `kind` `ally` / `enemyDebuff` / `terrain` (only for the match stage); `team` = also given to teammates. A 战术决策 draws each card on its own, with replacement, so the same card can be offered twice (official match 7 R11: 补给 ×2; `tacticDraft`) |
| `bountyDrafts[kind]` | `{"initial":{"events":[…],"slots":10,"pick":"slot","groups":[{"cards":[…6],"seen":[1,3,5,9,17]},…],"rule":{"series":[10,…,15,20],"tiers":[1,1,1,2,2,3],"perSeries":2,"prefer":["enemyeffect_10_6","enemyeffect_20_6"]},"count":6},"boss":{"events":[…],"slots":6,"pick":"seen","groups":[{"cards":[…9],"seen":[7,…,21],"weights":[12,13,…]},…],"count":6},"hunter":{"events":[…],"slots":7,"pick":"slot","groups":[{"cards":[…7],"seen":[1,6,21,22],"weights":[…]},{"cards":[…6],"open":1,…},…],"rule":{"size":7,"onePerSeries":[10,…,15],"maxSeries16":2,"giants":[…],"cards":[…]},"count":6}}` | the official 悬赏决策 card lists, read from 66 screenshots of 22 official co-op 绝境 / 终极 matches (player feedback after 0.1.0 report #2; build-data `BOUNTY_INITIAL_SETS` / `BOUNTY_BOSS_GROUPS` / `BOUNTY_HUNTER_GROUPS`, readings in `test/fixtures/official-bounty-drafts.json`, `seen` = its match numbers). A draft = one group (`pick` `slot`: one of `slots` events, uniform — a seen group or, for an unseen event, a list built by `rule`; `seen`: a group by the matches it came in), then `count` different cards of its `cards` drawn one by one by `weights` (1 + the drafts of the group the card showed in; a rule-built card 1). `initial`: 9 seen sets of 6 (all six shown), the 10th by `rule` — tiers I I I II II III, ≤ `perSeries` a series, the III one of `prefer` (the two-battle cards no draft showed) [ASSUMED]; `boss`: the 6 groups of 9, 9, 9, 9, 8 and 6 cards (one per bossInitial event; 9 where three named bosses came; the three single-draft groups completed only with the base cards they lack [ASSUMED] — match 4's base-only group may instead be a draft of the 泥岩 group), the 鼠王 group in 14 of 22 matches — every one on the dark grey board — so picked by `seen` [ASSUMED: the per-match cause is open]; `hunter`: the 7 seen lists of 7, `slots` 7 so one of them uniform, so no list is built from nothing [ASSUMED] (which of the 15 events R11 fires is open: by the blocks of effectChoiceInfoDict it would be bounty_hunter_8..15, beside artifact_paid_4 / 5 and hardbuff_select; with 8 events an unseen list would come in about 1 R11 bounty draft in 8); `open` = cards still unseen, drawn by `rule`: a giant for a list without one, else tier I / II cards, one per `onePerSeries` series, ≤ `maxSeries16` of 16_9..12 [ASSUMED] — the only way 12_8 / 16_2 / 16_6 (in no draft) come, about 1 R11 bounty draft in 15 |
| `schedule[modeId]` | `{"spRounds":[3,6,9],"rounds":{"3":{"families":[{"family":"bounty","weight":50},…],"cards":6,"supplyTiers":[1,4],"bountyDraft":"initial","events":{"bounty":[…],"supply":[…],"shop":[…],"tactic":[…]},"assumed":true}}}` | research 01 A4 defaults: pick a family by weight; `supplyTiers` = item tier window for 道具补给; 机密商店 draws `shopDraft` at its `rounds` (R11), else any normal shop item of tiers I–VI per card (duplicates allowed); `bountyDraft` = the `bountyDrafts` kind of the round's 悬赏决策 (R3 / 险境 R6 `initial`, R9 `boss`, R11 `hunter`); 绝境 / 终极 R11 = 悬赏决策 14 / 机密商店 4 / 战术决策 4 (the official co-op R11s of 22 matches, no 道具补给; weights = those counts [ASSUMED]; solo takes the same weights [ASSUMED: no solo screenshot — extrapolated from co-op, the data has solo R11 悬赏决策 events `bounty_hunter_*_s`]); `events` = the official event ids of that kind (`enemy_initial_*`, `bossInitial_*`, `bounty_hunter_*`), usable as the phase header |
| `shopDraft` | `{"rounds":[11],"slots":[{"6":1},{"6":1},{"5":1},{"coin":1},{"3":1,"4":2,"5":3,"coin":2},{"3":1,"4":2,"5":3,"coin":2}],"coin":"chess_item_1_03_e_a","itemWeights":{"chess_item_6_09_e_a":5,…},"seen":[2,4,5,8],"count":6}` | the official 机密商店 (R11 of matches 2, 4, 5, 8; build-data `SHOP_DRAFT`; the user: "机密商店按官方改成可以重复吧"): six slots, each drawn on its own — with replacement, so an item can come twice (official: 盟约之币 ×2, 变形同构体 ×2) — a tier (or `coin` = 盟约之币) by the slot's weights, then an item of that tier by `itemWeights` (1 + the official cards it showed on; unlisted 1). Every official shop: exactly two tier VI, ≥ 1 V, ≥ 1 盟约之币, no other tier I / II; the slot split and the weights [ASSUMED]; solo 3 of the 6 [ASSUMED]; only at `rounds` (R11 = 绝境 / 终极, the rounds of the screenshots) — the earlier 机密商店 of 标准 R3 / R9 and 险境 R3 / R6 / R9 (no screenshot; the data's shop events sit in three blocks) keep the previous draw, any normal shop item of tiers I–VI per card, with replacement [ASSUMED]; free (price 0) |
| `tacticDraft` | `{"rounds":[11],"kinds":["ally"],"weights":{"allybuff_select_4":4,…},"seen":[7,9,18,20],"count":6}` | the official 战术决策 (R11 of matches 7, 9, 18, 20; build-data `TACTIC_DRAFT`; the user: "战术决策也按官方改成可以重复吧"): six cards, each drawn on its own — with replacement, so a card can come twice (official: 补给 ×2 in match 7; the other three drafts show six different cards). All 24 official cards are `ally` cards (no 排斥 / 责罚 / 裁决, no 模拟战场演变; four drafts without a debuff: about 0.04 % for the previous draw, 6 different cards of the 26 ally + 9 debuff cards), and 列装 / 财富 / 补给 / 整备 / 升华 made 12 of them (uniform: about 4.6), so at `rounds` (R11 = 绝境 / 终极, the round of the screenshots) only the cards of `kinds`, each by `weights` (1 + the official cards it showed on; unlisted 1) [ASSUMED: the weights; no terrain card at R11 — the maps of the 4 matches are unknown]; each card independent, no slots like `shopDraft` [ASSUMED, open: all 4 drafts hold a 驰援, a 盟誓 and two of 列装 / 财富 / 补给 / 整备 / 升华 — about 3 % under independent draws, a pattern spotted after the fact]; solo 3 [ASSUMED]; other rounds (标准 R3 / R9, 险境 R3 / R6 / R9, 险境 solo R9; no screenshot) every card uniform, terrain cards only for the match stage, with replacement too [ASSUMED]. A card acting only on dead bonds is never offered (`Match.bondLive`). `assumed` lists the assumed parts |
| `pools[poolId]` | `{"kind":"equip","items":[…]}` / `{"kind":"chess","tier":2,"rule":"shopEligible"}` | server-side reward pools referenced by effects (`pool_equip_*`, `pool_chess_shop_N_reward`, `pool_char_later`, …), all [ASSUMED]; `rule:"shopEligible"` = visible/non-hidden entries, `maxTier:"shopLevel"` = ≤ current shop level, `weighted` = `[[id, weight]]`. `pool_equip_vict` (维多利亚, every 25 layers: "获得一件带有随机特殊效果的维式重锤") and `pool_equip_rockr` (洛洛's 定制品; user playtest #4 — the research 04 §8 guess 有限加速器 / 激光发射器 / 护盾无人机 / 双模机械臂 / 蜂鸣器 is superseded) = the 4 special 维式重锤, uniform |
## 9. `enemies.json` — `{ [enemyKey]: Enemy }` (249 keys that can appear)
+15 -13
View File
@@ -397,7 +397,7 @@ Each domain file `server/sim/content/{tokens,bonds,garrisons,items,bands,enemies
- items: 56 equipment (+golden) + 3 Arts; 变形同构体 bond grant; VI-tier set/combos.
- bands: 40 strategies (meta + battle). 鸭爵 “神秘顾客” swaps 0–2 ground enemies (not the leader, its parts or bounties; on a pair boss field only the player's half — the boss / hidden `onBattleStart` event carries `routes` and `side`) in every teammate's normal / Final Assault / Hidden Core battle from R5 into the act2 鸭爵 / 高普尼克 / 流泪小子 / 圆仔 (lpr 1, 1 fund to the killer, 联防 included) (§21.5).
- enemies: special-type behaviours (FLY, TIMES, ELEMENT, DOT, INVISIBLE, REFLECTION, SPECIAL) and notable individual abilities from `data/enemies.json`; bosses 1–10 scripted — the huge leaders (假想敌:胄 ×2, 假想敌:管 ×2, 盐风主教昆图斯, 阿利斯泰尔, “萨米的意志”) are 自缚 + 无法被阻挡 (PRTS 天赋; `content/bosses.js SELF_BOUND`: a persistent noMove + unblockable buff from spawn) with their hit area (§19.4); 圆仔 and 鸭爵 are 无法被阻挡 (PRTS 天赋, §21.5); the knock-out forms follow PRTS — 深池逐火 余烬 (5 / 10 hits, 隐匿, blockable, back after 10 s), 转译基底·α (damage cancelled until its 2 s change into 寻仇者 / 特战术师 / 幽灵), the leaders' 重生, 守墓石像's statue and flight — and every 重生 clears what operators put on the enemy and restarts its skills from their initial cooldown (§21.4, §21.19); the mode's air units are 静态刚体, never moved (§21.9); leader parts (斩胄之剑 / 破胄之锤 / 碎铳之簧) pass every damage they take to their leader 1:1 as 无来源 HP loss (`PART_TRANSFER` 1, PRTS "等量"), a 假想敌:胄 drone that dies costs it 2 % of the pool (`DRONE_LINK_BASE 'pool'`) (§20.10, §20.13); devices/terrain.
- choices: 机变 families (悬赏决策 bounties, 道具补给, 机密商店, 战术决策 team/personal buffs). 悬赏决策 = the official structure of its round (choices.json `bountyDrafts`, 86 drafted cards: R3 a fixed set of six 两场 cards, R9 a boss group + cheap fill, R11 a 特异III giant group + tier-I fill; §21.2); bounty cards carry `descRaw`; no draft offers a multi-round card, and 教鞭's lasts two battles (`choices.js MULTI_ROUND_BOUNTY_BATTLES`, §20.6). 教鞭 = a random 战术特训 card (3 drawn, 1 taken).
- choices: 机变 families (悬赏决策 bounties, 道具补给, 机密商店, 战术决策 team/personal buffs). 悬赏决策 = the official structure of its round (choices.json `bountyDrafts`, 86 drafted cards: R3 a fixed set of six 两场 cards, R9 6 of a boss group's list, R11 6 of a seen 7-card list; §21.2); 机密商店 (R11 the official composition) and 战术决策 can offer the same card twice (`shopDraft` / `tacticDraft`); bounty cards carry `descRaw`; no draft offers a multi-round card, and 教鞭's lasts two battles (`choices.js MULTI_ROUND_BOUNTY_BATTLES`, §20.6). 教鞭 = a random 战术特训 card (3 drawn, 1 taken).
---
@@ -1116,7 +1116,7 @@ In SETTLE it is the field the last battle left on screen. In prep without watchi
Twenty-seven reports after the public 0.1.0 release (2026-10-02), relayed by the user in five batches; most were not tested by the user. Verdicts: **real** (fixed), **partly** (the rule was right, what a player sees was wrong or a related part was wrong — fixed), **not real** (not reproduced; tests lock the rule). Where each is handled: batch 1 — #1 拉普兰德's "获得该干员后该回合的首次刷新" should stack 叙拉古 → §21.1 (real); #2 late bounty enemies offered in early bounty drafts → §21.2 (real); #3 the water pool map: operators deployable in the water, 突袭 operators land in it → §21.3 (real); #4 昆图斯's 突变细胞 gone after one use → §21.1 (real: not consumed); #5 转译基底 does not transform → §21.4 (real); #6 凯瑟琳's three upgrade items shown wrong → §21.2 (real); #7 鸭爵's 圆仔 blockable, audit the other 鸭爵 enemies → §21.5 (real); #8 深池逐火战士's 怨恨的余烬 cannot be killed → §21.4 (real); #9 tacticians (伺夜) can place the tactical point outside their attack range → §21.3 (real); #10 bots too dumb, smarter without more CPU → §21.6 (real). Batch 2 — B1 奥术 not working → §21.7 (partly: the bond works, 标准 switches it off); B2 源石溶剂 white + white → gold, then an extra white → §21.7 (not real); B3 维娜·维多利亚 S3 summons one 黄金盟誓 → §21.8 (real); B4 玛恩纳's skill deals no damage → §21.8 (real, on flying waves); B5 薄绿 can pull flying enemies → §21.9 (real). Batch 3 — C1 the hidden leader's "造成50%伤害" broadcast before the fight → §21.10 (real); C2 变形同构体 + class item: the bond member count excludes the wearer → §21.11 (partly: display only). Batch 4 — D1 信仰搅拌机: 源石溶剂's HP drain should trigger S3 → §21.12 (real); D2 野鬃's skill sometimes pushes the wrong way → §21.13 (real); D3 special 源石虫 all drawn as the plain one → §21.14 (partly); D4 暴鸰's bombs do not drop properly → §21.14 (partly); D5 the originium-floor map's lower route should cut diagonally from row 4 to row 3 → §21.15 (real). Batch 5 — E1 烛煌 S3's attack range does not change → §21.16 (partly: the card); E2 medics that block a roaming leader stop healing and attack it → §21.17 (not real); E3 卡涅利安's attack is not a true AoE → §21.18 (real).
Eighteen workstreams (WA–WR, each on its own branch from master 7f13473) were merged into `feedback1` in the order WB, WA, WE, WD, WC, WI, WM, WN, WO, WL, WG, WH, WJ, WK, WP, WR, WQ, WF; data/*.json was rebuilt by `tools/build-data.mjs --offline` after every builder merge, never hand-merged. The integration fixed a bug already in 0.1.0 (a 联防 battle timed out during 隐德来希 S3 reported its 心烛 as leaks, so the server refused an honest client result — candles carry `mem.noLeak`), the last review of the transform workstream (form fx were still dropped by the render engine), every cross-workstream note worth doing with a primary source, and reconciled the bot with the merged rules — §21.19. §21.20 lists the settled decisions and what stays [ASSUMED]. Normative lines rewritten with it: §2 (`grid.js`), §3 (the effective `buildable`, the 深水区, range-bound summons, 攻击距离 vs skill ranges), §5.2 (`liveRangeGrid`, `form`), §5.3 (stealth vs area damage, the 奥术 refresh), §5.4 (`kill` / `death`, `foesInRadius`, `loseHp` vs damage, 静态刚体), §5.5 (healers that block, `rangeAoe`, armed enemies, results), §5.6 (TAKE_DAMAGE, `noRangeExtend` / `showOwnRange`, the profession splash radii), §6.2 (rewardOffer, piece `meta.round`, summons in range, returned equipment), §6.3 (变形同构体), §6.6 (the bot), §7 (bounty drafts, 鸭爵, knock-out forms), §8.2 / §8.3 (fx `form`, `m.ticker`, `range`, UnitInfo `items` / `form`, the stealth bit, rewardOffer), §9 (forms, alias tint), §10 (offers, ticker, 本局禁用), §13 (`spine/enemy`), §14 (ground pathing, boss tickers, the runner's form fx), §16, §17.3, §18.5, §18.6 #10, §19.1, §19.7, §20.1, §20.3, §20.6, §20.9.
Eighteen workstreams (WA–WR, each on its own branch from master 7f13473) were merged into `feedback1` in the order WB, WA, WE, WD, WC, WI, WM, WN, WO, WL, WG, WH, WJ, WK, WP, WR, WQ, WF, then WB's follow-up from 66 screenshots of 22 matches (bounty lists, the 机密商店 and 战术决策 with repeats) after QA; data/*.json was rebuilt by `tools/build-data.mjs --offline` after every builder merge, never hand-merged. The integration fixed a bug already in 0.1.0 (a 联防 battle timed out during 隐德来希 S3 reported its 心烛 as leaks, so the server refused an honest client result — candles carry `mem.noLeak`), the last review of the transform workstream (form fx were still dropped by the render engine), every cross-workstream note worth doing with a primary source, and reconciled the bot with the merged rules — §21.19. §21.20 lists the settled decisions and what stays [ASSUMED]. Normative lines rewritten with it: §2 (`grid.js`), §3 (the effective `buildable`, the 深水区, range-bound summons, 攻击距离 vs skill ranges), §5.2 (`liveRangeGrid`, `form`), §5.3 (stealth vs area damage, the 奥术 refresh), §5.4 (`kill` / `death`, `foesInRadius`, `loseHp` vs damage, 静态刚体), §5.5 (healers that block, `rangeAoe`, armed enemies, results), §5.6 (TAKE_DAMAGE, `noRangeExtend` / `showOwnRange`, the profession splash radii), §6.2 (rewardOffer, piece `meta.round`, summons in range, returned equipment), §6.3 (变形同构体), §6.6 (the bot), §7 (bounty drafts, 鸭爵, knock-out forms), §8.2 / §8.3 (fx `form`, `m.ticker`, `range`, UnitInfo `items` / `form`, the stealth bit, rewardOffer), §9 (forms, alias tint), §10 (offers, ticker, 本局禁用), §13 (`spine/enemy`), §14 (ground pathing, boss tickers, the runner's form fx), §16, §17.3, §18.5, §18.6 #10, §19.1, §19.7, §20.1, §20.3, §20.6, §20.9.
### 21.1 拉普兰德's first refresh; 突变细胞 is not consumed (#1, #4) — `sim/content/garrisons/meta.js SERVER_GAIN_BOND_LAYER_BY_REFRESH_CNT`, `match/PlayerState.js pieceRoundCount / bumpPieceRoundCount / _mergeChess / transformChess`, `match/effectsMeta.js ctx.pieceCounter / incPieceCounter / transform`, `match/builtinMeta.js char_chess_transformation_equip`, `match/bot.js cellTarget / makeHandRoom`, `tools/build-data.mjs ITEM_RULES`, `ui/detailPanel.js ItemDetail`
@@ -1129,18 +1129,19 @@ Eighteen workstreams (WA–WR, each on its own branch from master 7f13473) were
- [ASSUMED]: an elite merged this round keeps its copies' highest count (no second firing that round); a copy re-bought after a sale fires on its own first refresh ("获得该干员后"; each extra +4 costs 3 + 1 − 1 = 3 funds); the carrier transforms wherever it stands; a 6阶 carrier becomes another 6阶.
- Tests: `test/match/feedback1-meta.test.js`, `test/ui/feedback1-cell.test.js`; `items.test.js`, `merge.test.js` updated.
### 21.2 Bounty drafts by the official round structures; special offers shown as what they hold (#2, #6) — `tools/build-data.mjs BOUNTY_INITIAL_SETS / BOUNTY_BOSS_TEMPLATES / BOUNTY_HUNTER_TEMPLATES / BOUNTY_HUNTER`, `match/choices.js bountyDraftCards`, choices.json `bountyDrafts` / `draftPool` / schedule `bountyDraft`, `test/fixtures/official-bounty-drafts.json`; `match/PlayerState.js pushRewardOffer / pushItemOffer`, `match/effectsMeta.js offerLabel`, `ui/gameLogic.js offerHeader`, `ui/shopBar.js RewardCards`, `ui/rewardOverlay.js`
### 21.2 Bounty drafts by the official round structures; special offers shown as what they hold (#2, #6) — `tools/build-data.mjs BOUNTY_INITIAL_SETS / BOUNTY_BOSS_GROUPS / BOUNTY_HUNTER_GROUPS / SHOP_DRAFT / TACTIC_DRAFT`, `match/choices.js bountyDraftCards / shopDraftCards / tacticDraftCards`, choices.json `bountyDrafts` / `shopDraft` / `tacticDraft` / `draftPool` / schedule `bountyDraft`, `test/fixtures/official-bounty-drafts.json`; `match/PlayerState.js pushRewardOffer / pushItemOffer`, `match/effectsMeta.js offerLabel`, `ui/gameLogic.js offerHeader`, `ui/shopBar.js RewardCards`, `ui/rewardOverlay.js`
- **#2 seen**: "本来应该后期出的悬赏的怪物在前期的悬赏就出现了,导致选了打不过". **Cause**: every draft drew uniformly from 105 cards (§20.6), so R3 could offer the R9 bosses and the R11 giants.
- **#2 official** (33 screenshots of 11 co-op 绝境 / 终极 matches at R3 / R9 / R11 collected by the user 2026-10-03; readings in the fixture, no player names): every card is an act2autochess (下半) effect whose enemy is the effect's `enemy_id` (the title only names category and tier: 悬赏·损伤I = 底海滑动者 e12_4 / 临时收音师 e18_1 / 心虚设计师 e16_11); a draft holds 6 different cards in varying positions.
- **R3 (11 / 11)**: six 接下来两场作战 cards, I I I II II III (10 coins), one of 10 fixed sets (enemy_initial_1..10); seen: series 18 ×4, 19, 17 and 4 mixed sets of one card from each of 6 of the series 10–15 / 20.
- **R9 (12 / 12)**: boss bounties X·悬赏 + 源石虫·特训, 下场作战 — a group of 0–3 named bosses that always come together, plus cheap fill (W / 碎骨 / 弑君者, 大鲍勃, 庞贝 or 鼠王, 源石虫·特训); 7 groups seen for 6 events (two game versions, or a group set per match — open).
- **R11**: 悬赏决策 5, 机密商店 4, 战术决策 2, 道具补给 0 of 11; a bounty draft = a group (one 特异III giant + 1–4 tier-II cards, always together; matches 1 and 6 share the whole group) filled with tier-I cards, at most one per faction series 10–15; 4 groups seen for 15 events.
- **#2 official** (66 screenshots of 22 co-op 绝境 / 终极 matches of this season at R3 / R9 / R11, collected by the user 2026-10-03; readings in the fixture, no player names): every card is an act2autochess (下半) effect whose enemy is the effect's `enemy_id` (the title only names category and tier: 悬赏·损伤I = 底海滑动者 e12_4 / 临时收音师 e18_1 / 心虚设计师 e16_11); each 悬赏决策 event is a fixed card list and a draft shows 6 different cards of it in varying positions.
- **R3 (22 / 22)**: six 接下来两场作战 cards, I I I II II III (10 coins), one of 10 fixed sets (enemy_initial_1..10), all six shown; 9 sets seen — series 17 / 18 / 19 whole, and hand-made mixed sets (one holds two series-20 cards, one holds 自制投石机 e17_6), so no simple rule describes them.
- **R9 (22 / 22)**: boss bounties X·悬赏 + 源石虫·特训, 下场作战 — one of 6 groups (one per bossInitial event) of 9, 9, 9, 9, 8 and 6 cards; 庞贝 and 鼠王 never share a draft; named bosses do not always come together (match 19 = 自在 + 杰斯顿 + 陷落雪祀, match 12 = 杰斯顿 alone). The 鼠王 group came in 14 of 22 matches, every dark-grey-board match among them.
- **R11**: 悬赏决策 14, 机密商店 4, 战术决策 4, 道具补给 0 of 22. A bounty draft is a list of 7 下场战斗 cards — one 特异III giant, one card per faction series — of which the draft leaves out one (match 15 leaves out the giant; matches 1 / 6 / 21 / 22 leave out four different cards of one list); 7 lists seen.
- **机密商店** (matches 2, 4, 5, 8): six free items with repeats (盟约之币 ×2, 变形同构体 ×2); each shop exactly two tier VI, at least one V and one 盟约之币, no tier I / II. **战术决策** (matches 7, 9, 18, 20): six ally cards each (no 排斥 / 责罚 / 裁决, no 模拟战场演变), 补给 twice in match 7.
- Never in a draft: the 7 multi-round cards (the 假想敌 question is answered), enemyeffect_3_*, 战术特训, the 鸭爵 set.
- **#2 now**: cards.bounty `draftPool` initial 42, boss 20, hunter 24 (86 drafted); `draftExcluded` 'unseen' (19: multi-round, e3_*, 凋零骑士 / 遗弃者 / 锏 / 扎罗 / 迷路的巨像). R3 = a seen set, or the mixed rule for the 3 unseen slots; R9 = a seen group + its fill; R11 = a seen group (4 of 15 slots) or a rule group of the same shape, then tier-I fill; 绝境 / 终极 R11 families: bounty 45 / shop 35 / tactic 20 (was supply 50 / shop 50). `MULTI_ROUND_BOUNTY_BATTLES = 2` stays (it now only affects 教鞭's 法术大师A2·多轮战术特训). The mode's inactive enemy list no longer removes bounty cards (PRTS 11/18 note "不影响悬赏决策出场"). Flip: add a newly seen set or group to the build-data constants and rebuild.
- **#2 now**: cards.bounty `draftPool` initial 42, boss 20, hunter 24 (86 drafted); `draftExcluded` 'unseen' (19: multi-round, e3_*, 凋零骑士 / 遗弃者 / 锏 / 扎罗 / 迷路的巨像). A draft = one group, then 6 different cards of it, each drawn with weight 1 + the official drafts of its group it showed in. R3 = one of 10 slots: a seen set, or for the unseen 10th a rule set (tiers I I I II II III, ≤ 2 per series, the III 法术大师A1 e10_6 or 鼎沸 e20_6 — the two-battle cards no draft showed); R9 = a seen group, picked by the matches it came in; R11 = one of the 7 seen lists, uniform (the 4 lists seen with 6 cards completed by the list shape, so 12_8 / 16_2 / 16_6 come in about 1 R11 bounty draft in 15); 绝境 / 终极 R11 families: bounty 14 / shop 4 / tactic 4 (the official counts; was supply 50 / shop 50). The R11 机密商店 (`shopDraft`): six slots drawn with replacement — VI, VI, V, 盟约之币 and twice V / IV / III / 盟约之币 — items weighted by the official cards they showed on; the earlier 标准 / 险境 shops keep any tier I–VI per card, also with replacement. The 战术决策 (`tacticDraft`): every card drawn on its own with replacement; at R11 only the ally cards, weighted 1 + the official cards each showed on; other rounds every card, uniform. Identical cards are separate cards (picks go by index; two picks of a team card stack). `MULTI_ROUND_BOUNTY_BATTLES = 2` stays (it now only affects 教鞭's 法术大师A2·多轮战术特训). The mode's inactive enemy list no longer removes bounty cards (PRTS 11/18 note "不影响悬赏决策出场"). Flip: add a newly seen set, group or list to the build-data constants and rebuild. On the 22 official bounty drafts the generator reaches every one (60 000 seeds per round, `mode_multi_hard` and `mode_multi_abyss`).
- **#6 seen**: 凯瑟琳's three items appeared as blank operator cards with raw ids under 晋升奖励. **Now**: a special-refresh offer is drawn as what it holds — free item cards (icon, name, tier, description, FREE) or chess cards under its source's label (凯瑟琳 定向投放, 娜仁图亚 见者有份, 寻呼模块, 信标, 松果) + SPECIAL + 免费选择 1 件 / 名; m.private `shop.rewardOffer = { tier, source: 'merge'|'special', label, queued, slots }`; offers waiting behind the shown one show as "之后还有 N 项" and a +N on the reminder pill; a special offer plays the refresh sound.
- [ASSUMED]: the 3 unseen R3 sets (mixed rule) and 险境 R6 drafting like R3; uniform among the seen R9 groups, unseen bosses not offered; the R11 group / fill split by tier, the 11 unseen R11 slots, uniform among the events; the R11 weights; solo 绝境 / 终极 R11 = the co-op weights (no solo screenshot; the data has 15 solo `bounty_hunter_*_s` events); solo shows 3 of 6. Not changed: 机密商店 and 战术决策 can repeat a card officially; the remake's never do.
- Tests: `test/match/feedback1-bounty.test.js`, `test/ui/feedback1-offers.test.js`, `test/match/playtest6-matchflow.test.js`, `test/ui/playtest6-bounty.test.js`, `test/content/choices.test.js`; opt-in `test/ui/feedback1-offers.e2e.test.js` (`SP_E2E=1`).
- [ASSUMED]: the card weights inside a group; the unseen 10th R3 set (rule) and 险境 R6 drafting like R3; R9 groups picked by the matches they came in (what of the match decides — map, leader or difficulty — is open) and the three single-draft groups completed only with the base cards they lack; R11 = one of the 7 seen lists (which of the 15 events R11 fires is open: by the blocks of effectChoiceInfoDict it would be bounty_hunter_8..15, and with 8 events an unseen list would come in about 1 R11 bounty draft in 8); the R11 family weights = the official counts; the 机密商店 slot split and item weights, and the earlier 标准 / 险境 shops; the 战术决策 weights, R11 without terrain cards, independent draws (open: all 4 official drafts hold a 驰援, a 盟誓 and two of 列装 / 财富 / 补给 / 整备 / 升华), twin battle passives stacking; solo 绝境 / 终极 R11 = the co-op weights (no solo screenshot; the data has 15 solo `bounty_hunter_*_s` events); solo shows 3 of 6.
- Tests: `test/match/feedback1-bounty.test.js`, `test/match/feedback1-secret-shop.test.js`, `test/match/feedback1-tactic.test.js`, `test/ui/feedback1-offers.test.js`, `test/ui/feedback1-secret-shop.test.js`, `test/ui/feedback1-tactic.test.js`, `test/match/playtest6-matchflow.test.js`, `test/ui/playtest6-bounty.test.js`, `test/content/choices.test.js`; opt-in `test/ui/feedback1-offers.e2e.test.js`, `feedback1-secret-shop.e2e.test.js`, `feedback1-tactic.e2e.test.js` (`SP_E2E=1`).
### 21.3 No deployment in the water; tactical points inside the tactician's range (#3, #9) — `sim/grid.js DEPLOY_REFUSED_TILES / canStand`, `tools/build-data.mjs effectiveBuildable / ownerRange`, `match/board.js ownerRangeKeys`, `match/PlayerState.js _legal / summonRange / _liftOutOfRange / _reorient`, `ui/gameLogic.js summonRange`, `match/bot.js`, `match/invariants.js`, `content/kits/tier3.js tacticalPoint`
@@ -1323,7 +1324,8 @@ Settled by the user:
| Decision | Settled rule | One-line flip |
|---|---|---|
| A tactician re-oriented in place (§21.3) | a range-bound summon still inside the new range stays; one outside goes back to its stack (user-confirmed 2026-10-03; PRTS only says a MOVED owner resets every summon) | `PlayerState._liftOutOfRange` lifting every range-bound summon of a re-oriented owner |
| Bounty drafts (§21.2) | the official structure of each round from the user's 33 screenshots of 11 matches (R3 fixed sets, R9 boss groups + fill, R11 giant groups + tier-I fill; no multi-round card in a draft) | add a newly seen set / group to `tools/build-data.mjs BOUNTY_INITIAL_SETS / BOUNTY_BOSS_TEMPLATES / BOUNTY_HUNTER_TEMPLATES` and rebuild |
| Bounty drafts (§21.2) | the official structure of each round from the user's 66 screenshots of 22 matches (R3 fixed sets, R9 boss groups, R11 seen 7-card lists; no multi-round card in a draft) | add a newly seen set / group / list to `tools/build-data.mjs BOUNTY_INITIAL_SETS / BOUNTY_BOSS_GROUPS / BOUNTY_HUNTER_GROUPS` and rebuild |
| 机密商店 / 战术决策 repeats (§21.2) | the same card can be offered twice, as officially (the user: "机密商店按官方改成可以重复吧", "战术决策也按官方改成可以重复吧"); R11 机密商店 = two VI, ≥ 1 V, ≥ 1 盟约之币 | `choices.json shopDraft` / `tacticDraft` removed (choices.js falls back to the previous draws, still with replacement) |
| Multi-round bounties (§20.6, unchanged) | last two battles — now only 教鞭's card | `server/match/choices.js MULTI_ROUND_BOUNTY_BATTLES = null` |
| §20.9's decisions | unchanged (start-deploy summons, 凯瑟琳's hand card, every blocker hits, the collider reach, the elite to the board, the fixed leader pool, the watched bond strip) | as §20.9 |
@@ -1331,7 +1333,7 @@ Official since this release (each with its source in its section): 突变细胞
Still [ASSUMED] (each in its section):
- §21.1: an elite merged this round keeps its copies' refresh count; a 拉普兰德 re-bought after a sale fires again; a 6阶 突变细胞 carrier becomes another 6阶.
- §21.2: the 3 unseen R3 sets and 险境 R6 drafting like R3; uniform among the seen R9 groups; the R11 group / fill split, the 11 unseen R11 slots and the R11 weights (solo = co-op); solo shows 3 of 6. Open for the user: all six cards of an R11 悬赏决策, an R9 screenshot with the match's 确认本局信息 page and date, solo 绝境 / 终极 R3 / R9 / R11.
- §21.2: the weights inside a group; the unseen 10th R3 set and 险境 R6 drafting like R3; R9 groups by the matches they came in; R11 one of the 7 seen lists and the family weights (solo = co-op); the 机密商店 slots / weights and the earlier shops; the 战术决策 weights, independent draws and twin passives stacking; solo shows 3 of 6. Open for the user: each match's 确认本局信息 page (what decides the R9 group), more R11 悬赏决策 / 机密商店 / 战术决策, solo 绝境 / 终极 R3 / R9 / R11.
- §21.3: the range is the loadout grid (items and bonds do not widen it); the canoe in the deploy map only.
- §21.4: the translator immobile and damage-cancelled through its change; the 特战术师 form hits flyers (open for the user); the ember's 10 s after the 重生; 隐匿 back at once after a block; the 重生 whitelist; “余音” unmapped.
- §21.5: the 鸭爵 swap's 0–2 uniform count and pick, the 60–99 % window, ground only, elites eligible, per-half on pair fields, decided at battle start.
@@ -1348,4 +1350,4 @@ Still [ASSUMED] (each in its section):
- §21.18: no target cap; damage at the attack instant; the beam look.
- §21.19: the 奥术法阵 rider ending with its carriers; the 自行炮's immunity window = its bombardment.
Not done (noted, no primary source or out of scope): whether 华法琳 S2's 流失 can kill an ally (§21.17); 伊利昂的木驮兽's `Full` skin (§21.14); the render-demo recording `public/dev/recordings/normal-a1m04.json` still shows 战场#04's old L-shaped lower route (display only); 机密商店 / 战术决策 repeating a card as officially (§21.2).
Not done (noted, no primary source or out of scope): whether 华法琳 S2's 流失 can kill an ally (§21.17); 伊利昂的木驮兽's `Full` skin (§21.14); the render-demo recording `public/dev/recordings/normal-a1m04.json` still shows 战场#04's old L-shaped lower route (display only).
+40 -13
View File
@@ -107,24 +107,51 @@ untimed. The UI picks a card with two taps (select → 确认选择, DESIGN §18
the mode's static `inactiveBondIds` — 标准 has no 拉特兰 / 阿戈尔 / 卡西米尔 / 奥术 … — and with chess in the pool), so
玛恩纳的盟誓 / 莫斯提马的盟誓 / 卡西米尔驰援 never show up in 标准. Card generation and the
family defaults are documented in `choices.js` (bounty / supply / shop / tactic). 机密商店 cards are **free** (official
text "无需消耗资金"). 悬赏决策 offers only choices.json cards with `draft: true` (`choices.js draftBounty`): never 战术特训 (PRTS
text "无需消耗资金") and follow the 4 official 机密商店 (choices.json `shopDraft`, build-data `SHOP_DRAFT`; the user:
"机密商店按官方改成可以重复吧"): six slots drawn with replacement — VI, VI, V, 盟约之币 and twice V / IV / III / 盟约之币,
an item within its tier weighted by the official cards it showed on — so the same item can be offered twice (official:
盟约之币 ×2, 变形同构体 ×2; the slot split and the weights [ASSUMED]) — at R11 (绝境 / 终极), the round of the
screenshots; the earlier 机密商店 of 标准 / 险境 (no screenshot) keep the previous draw, any tier I–VI per card with
replacement [ASSUMED]; two identical cards are two cards (picks go by index). 战术决策 follows the 4 official 战术决策
(choices.json `tacticDraft`, build-data `TACTIC_DRAFT`, `choices.js tacticDraftCards`; the user: "战术决策也按官方改成可以
重复吧"): each card drawn on its own, with replacement, so the same card can be offered twice (official match 7 R11:
补给 ×2) — at R11 (绝境 / 终极) only the ally cards (the 24 official cards hold no 排斥 / 责罚 / 裁决 and no terrain card),
each weighted 1 + the official cards it showed on (列装 / 财富 / 补给 / 整备 / 升华 made half of them) [ASSUMED: the
weights]; the earlier 战术决策 of 标准 / 险境 (no screenshot) keep every card, uniform, terrain cards only for the match
stage, with replacement too [ASSUMED]. At R11 the cards are drawn independently rather than in slots like the 机密商店
[ASSUMED, open: all 4 drafts hold a 驰援, a 盟誓 and two of 列装 / 财富 / 补给 / 整备 / 升华 — about 3 % under
independent draws, but a pattern spotted after the fact]. Identical tactic cards are separate cards as well: each pick
applies its own card once, and two picks of one team card (two players taking the twins) both reach every teammate and
add up — settled for the prep cards (补给 "可叠加"; 列装 / 财富 / 盟誓 grant their own items, coin and layers each time),
[ASSUMED] for the battle passives 自愈 / 火力 / 征召 / 无瑕 / 锐利: one buff per pick, so 锐利 ×2 ignores 60 % of the DEF
(open: whether the official keeps one instance of a 同名 env buff; twin passives come in about 4–5 % of R11 drafts).
The header reads the official "进行协同调整,做好迎战准备。". 悬赏决策 offers only choices.json cards with `draft: true` (`choices.js draftBounty`): never 战术特训 (PRTS
"※以下悬赏任务仅由法术教鞭生成") nor the 鸭爵 / 高普尼克 / 流泪小子 / 圆仔 cards (user playtest #6 item 4). **Each draft is
built like the official one of its round** (`choices.js bountyDraftCards`, choices.json `bountyDrafts` / schedule
`bountyDraft`; player feedback after 0.1.0, report #2 — late bounty enemies in the early drafts — settled by 33
screenshots of 11 official co-op 绝境 / 终极 matches, readings in `test/fixtures/official-bounty-drafts.json`): **R3**
one of the 10 official sets of six "接下来两场作战" cards, 3 × I + 2 × II + 1 × III — 7 seen (a whole series 17 / 18 / 19,
or one card from each of 6 of the series 10–15 / 20), the 3 unseen slots built by that rule [ASSUMED]; **R9** boss
bounties + 源石虫·特训: a seen group of 0–3 named bosses that always come together, filled to 6 with the cheap ones (W /
碎骨 / 弑君者, 大鲍勃, 庞贝 or 鼠王, 源石虫·特训) — 7 groups for 6 events: two game versions or a group set per match
(leader, map), open; **R11** (绝境 / 终极 co-op: 悬赏决策, 机密商店 or 战术决策, never 道具补给; solo the same weights
[ASSUMED]) six "下场战斗" cards like R9: a group — one 特异III giant + 1–4 tier-II cards, always together (matches 1 and 6
share the whole group, only the tier-I card differs) — filled to 6 with tier-I cards, at most one per faction series;
4 groups seen for the 15 events, the unseen slots draw a group of that shape [ASSUMED]. 险境 R6 drafts like R3
`bountyDraft`; player feedback after 0.1.0, report #2 — late bounty enemies in the early drafts — settled by 66
screenshots of 22 official co-op 绝境 / 终极 matches of this season, readings in
`test/fixtures/official-bounty-drafts.json`): the event is a fixed card list and the draft shows 6 different cards of
it, each drawn with weight 1 + the official drafts of its group it showed in [ASSUMED]. **R3** one of the 10 official
sets of six "接下来两场作战" cards (all six), 3 × I + 2 × II + 1 × III in 22 of 22 — 9 seen, hand-made (one holds two
series-20 cards), the 10th built by a rule whose III is 法术大师A1 or 鼎沸 (the two-battle cards no draft showed)
[ASSUMED]; **R9** boss bounties + 源石虫·特训: one of 6 groups (one per bossInitial event; 庞贝 and 鼠王 never
together) of 9, 9, 9, 9, 8 and 6 cards — 9 where three named bosses came, the single-draft groups completed only with
the base cards they lack [ASSUMED; match 4's base-only group may instead be a draft of the 泥岩 group]; the 鼠王 group (杰斯顿 / “自在” / 陷落雪祀) came in 14 of 22 matches, every match on the dark grey board
among them, so a group is picked by the matches it came in [ASSUMED: something of the match — map, leader or
difficulty — decides, open]; **R11** (绝境 / 终极 co-op: 悬赏决策 14, 机密商店 4, 战术决策 4, never 道具补给, weights =
those counts; solo the same [ASSUMED]) a list of 7 "下场战斗" cards — one 特异III giant, one card per faction series —
of which the draft leaves out one (matches 1 / 6 / 21 / 22 leave out four different cards of one list); one of the 7
lists seen, uniform, so no list is invented [ASSUMED] (the data does not say which of the 15 events bounty_hunter_*
R11 fires: by its blocks it would be the 8 events 8..15, beside the 绝境 / 终极-only hardbuff_select; 7, 8 or 9 events
fit 14 drafts showing 7 lists about equally, 15 do not — with 8, an unseen list would come in about 1 R11 bounty draft
in 8, open), the 4 lists seen with 6 cards completed by that shape — so the R11 cards no draft showed (12_8 / 16_2 /
16_6) come in about 1 draft in 15 [ASSUMED]. 险境 R6 drafts like R3
[ASSUMED]. A card's enemy is fixed by its effect (`enemy_id`): the title only names category and tier (悬赏·损伤I =
底海滑动者 or 临时收音师).
底海滑动者 or 临时收音师). The players pick in turn from one shared draft (a taken card stays greyed with the taker's
avatar, as in the official matches 17 and 21).
No official draft showed a multi-round card, a pre-series card (enemyeffect_3_*) or the boss bounties 凋零骑士 / “遗弃者”
/ 锏 / 扎罗 / 迷路的巨像: `draftExcluded: 'unseen'` [ASSUMED], flipped by adding them to build-data
`BOUNTY_INITIAL_SETS` / `BOUNTY_BOSS_TEMPLATES` / `BOUNTY_HUNTER_TEMPLATES` once a screenshot shows one. The card positions are shuffled; solo
`BOUNTY_INITIAL_SETS` / `BOUNTY_BOSS_GROUPS` / `BOUNTY_HUNTER_GROUPS` once a screenshot shows one. The card positions are shuffled; solo
shows 3 of the 6 [ASSUMED]. The mode's inactive enemy list does not thin the draft (PRTS
11/18 note "不影响悬赏决策出场"). Every bounty card carries the effect's official rich text `descRaw` (the battles in blue
"下场作战" / "两场作战"; the overlay and the effects column render it; the effects column also says "还剩 N 场作战").
+1 -1
View File
@@ -16,7 +16,7 @@
|---|---|
| 确认本局信息 | 本局地图(标准模拟固定为「战场#01」;险境从 8 张地图中随机,绝境 / 终极从不含战场#01 的 7 张中随机)、敌方派系、**被禁用的盟约及其干员**、领袖预告。所有人确认后继续;同盟模拟最多 25 秒;只有你一名玩家时(独立模拟,或同盟模拟只带 AI 队友)不限时。 |
| 选择策略 | 40 名策略(分队长)决定**初始目标生命值(20–45)**和一条永久规则。同盟:随机顺序轮流选,**每人 30 秒**,右上角和「决策中」显示同一个倒计时;AI 队友立即选完;队友已选的不能重复,每人可**跳过一次**(排到最后);超时自动选你**当前选中**的策略(已被队友选走则选华法琳)。独立:自由选择、不限时;同盟模拟只有你一名玩家时也不限时。 |
| 机变 | 部分回合开始前出现:同盟共 6 张卡轮流各取 1 张(首位 30 秒,其余 16 秒;只有你一名玩家时不限时),独立 3 选 1。卡片包括装备、资金、干员、盟约层数、悬赏等;**悬赏决策**为你的作战添加悬赏敌人,卡上用蓝字标出持续多久:「下场作战」/「接下来两场作战」的只出现一场或两场,之后不再出现;官方的「多轮悬赏」(原文写「之后的每场作战」)在这里也按「接下来两场作战」处理,只出现两场(如教鞭的法术大师A2·多轮战术特训)。各回合的悬赏决策按官方对局截图来出:第 3 回合是 6 张「接下来两场作战」的悬赏(3 张 1 资金、2 张 2 资金、1 张 3 资金);第 9 回合是各首领的「下场作战」悬赏和「源石虫·特训」;绝境 / 终极的第 11 回合是悬赏决策、机密商店或战术决策之一,其中的悬赏是 1 只特异巨兽加 5 张「下场战斗」悬赏。击倒它的人(联防中的队友也算)获得资金;右侧效果栏会写「还剩 N 场作战」。每张卡左上是图标和名称,下面写着完整效果(与详情卡相同的官方描述,手机上也显示);已被选走的卡变暗,右上角显示选走它的玩家头像(悬停可看名字)。选卡要**点两次**:第一次只选中(金框 +「确认选择 · 再次点击」),再点同一张或点右上的「确认选择」才生效;点别的卡换选,按 Esc 或点空白处取消。 |
| 机变 | 部分回合开始前出现:同盟共 6 张卡轮流各取 1 张(首位 30 秒,其余 16 秒;只有你一名玩家时不限时),独立 3 选 1。卡片包括装备、资金、干员、盟约层数、悬赏等;**悬赏决策**为你的作战添加悬赏敌人,卡上用蓝字标出持续多久:「下场作战」/「接下来两场作战」的只出现一场或两场,之后不再出现;官方的「多轮悬赏」(原文写「之后的每场作战」)在这里也按「接下来两场作战」处理,只出现两场(如教鞭的法术大师A2·多轮战术特训)。各回合的悬赏决策按官方对局截图来出:第 3 回合是 6 张「接下来两场作战」的悬赏(3 张 1 资金、2 张 2 资金、1 张 3 资金);第 9 回合是各首领的「下场作战」悬赏和「源石虫·特训」;绝境 / 终极的第 11 回合多半是悬赏决策(也可能是机密商店或战术决策),其中的悬赏通常有 1 只特异巨兽,其余是各阵营的「下场战斗」悬赏。击倒悬赏敌人的人(联防中的队友也算)获得资金;右侧效果栏会写「还剩 N 场作战」。**机密商店**的 6 件装备都免费,和官方一样可能出现两件相同的装备(各算一张卡,可以分别被选走);第 11 回合(绝境 / 终极)的机密商店按官方:2 件 VI 阶、至少 1 件 V 阶和 1 枚盟约之币,标准 / 险境较早回合的机密商店各阶装备都可能出现。**战术决策**也和官方一样可能出现两张相同的卡(官方截图里出现过两张「补给」;各算一张卡,可以分别被选走,每张只生效一次,两人各选一张全队卡则效果叠加);第 11 回合(绝境 / 终极)的战术决策按官方截图只出盟誓、驰援、列装 / 财富 / 补给 / 整备 / 升华等增益卡,不出削弱敌人的卡和战场演变卡。每张卡左上是图标和名称,下面写着完整效果(与详情卡相同的官方描述,手机上也显示);已被选走的卡变暗,右上角显示选走它的玩家头像(悬停可看名字)。选卡要**点两次**:第一次只选中(金框 +「确认选择 · 再次点击」),再点同一张或点右上的「确认选择」才生效;点别的卡换选,按 Esc 或点空白处取消。 |
| 休整期 | 购买、摆阵、配装备、冻结 / 刷新、升级调度中心;同盟有倒计时(65–215 秒,随回合增加);独立模拟、以及只有你一名玩家(只带 AI 队友)的同盟模拟不限时。所有人「准备就绪」即提前开战,全员就绪前可取消。 |
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**从上到下、从左到右**的顺序自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在原地**(倒地姿势、略微变灰),头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够,红色「!」表示原位被占。血条下方的一行是**元素损伤**(与原版一致:元素图标 + 白条):干员为带图标的彩色圆盘,敌人为小号纯色圆盘,右侧白条是剩余元素值,像血条一样从右往左减少,白条耗尽的那一下就是爆条(被大额元素损伤一次打掉一截时白条也会一次少一截,例如掠海漂移体每击约为 25%–50%);爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),这时白条变成**元素颜色**(神经青绿、侵蚀蓝、灼燃橙红、凋亡灰紫)并在冷却期间慢慢回满、图标闪烁——彩色条表示已经爆条、正在承受爆发效果,不是还剩的元素值;期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 |
| 联防 | 见第 5 节。 |
+1 -1
View File
@@ -4,7 +4,7 @@
fabricated m.public / m.private / m.field / b.snap / b.ev streams and a tiny mock server behind
net.request, so every phase can be developed and screenshot without the game server.
URL params: ?phase=PREP|INFO_CHECK|BAND_DRAFT|BATTLE_CHECK|SP_DRAFT|COMBAT|UNITE|SETTLE|FINAL_ASSAULT|HIDDEN_CORE|RESULT
&variant=reward|bounty|supply|tactic|temp|frozen|dead|defeat|solo|popup|detail|drawer|info|pen|emote|settings|collapsed|bond|drag
&variant=reward|bounty|supply|shop|tactic|temp|frozen|dead|defeat|solo|popup|detail|drawer|info|pen|emote|settings|collapsed|bond|drag
|leaker (联防: you let enemies through — the ×N counter)
|overtime|drain (boss rounds: level time over / overtime drain) |paused (solo battles)
&shot=1 (hide the switcher) &render=engine|fallback (default: the Pixi engine, DOM fallback if it fails)
+6 -3
View File
@@ -282,11 +282,14 @@ function setPhase(phase, variant) {
break;
case PHASE.SP_DRAFT: {
pub.round = 9; pub.deadline = Date.now() + 14000;
const fam = VARIANTS.has('supply') ? 'supply' : VARIANTS.has('tactic') ? 'tactic' : 'bounty';
const fam = VARIANTS.has('supply') ? 'supply' : VARIANTS.has('shop') ? 'shop' : VARIANTS.has('tactic') ? 'tactic' : 'bounty';
const ch = data.get('choices');
let cards;
if (fam === 'supply') cards = shuffle(shopItems().filter((i) => i.tier >= 3)).slice(0, 6).map((i) => ({ itemId: i.id }));
else if (fam === 'tactic') cards = shuffle(ch.cards.tactic.filter((t) => t.kind !== 'terrain')).slice(0, 6).map((t) => ({ effectId: t.effectId }));
// the official 机密商店 of match 8 R11 (test/fixtures/official-bounty-drafts.json): the same item twice
else if (fam === 'shop') cards = ['变形同构体', '盟约之币', '商业包装方案', '变形同构体', '天马之盔', '双模机械臂'].map((n) => ({ itemId: shopItems().find((i) => i.name === n).id }));
// the official 战术决策 of match 7 R11 (test/fixtures/official-bounty-drafts.json): the same card twice
else if (fam === 'tactic') cards = ['补给', '补给', '谢拉格驰援', '列装', '莫斯提马的盟誓', '升华'].map((n) => ({ effectId: ch.cards.tactic.find((t) => t.name === n).effectId }));
else cards = shuffle(ch.cards.bounty.filter((b) => b.draft !== false)).slice(0, 6).map((b) => ({ effectId: b.effectId }));
if (solo) { cards = cards.slice(0, 3); pub.deadline = 0; }
pub.sp = { family: fam, cards, order: solo ? ['p1'] : ['p4', 'p1', 'ai_2', 'p3'], turn: solo ? 'p1' : 'p1', picks: solo ? {} : { p4: 2 }, untimed: solo };
@@ -698,7 +701,7 @@ const SWITCH = [
['BATTLE_CHECK', PHASE.BATTLE_CHECK, ''], ['PREP', PHASE.PREP, ''], ['PREP + reward', PHASE.PREP, 'reward'],
['PREP + temp', PHASE.PREP, 'temp'], ['PREP frozen', PHASE.PREP, 'frozen'], ['PREP dead', PHASE.PREP, 'dead'],
['PREP boss (L)', PHASE.PREP, 'boss'], ['PREP boss (R)', PHASE.PREP, 'bossR'],
['SP bounty', PHASE.SP_DRAFT, 'bounty'], ['SP supply', PHASE.SP_DRAFT, 'supply'], ['SP tactic', PHASE.SP_DRAFT, 'tactic'], ['SP solo', PHASE.SP_DRAFT, 'solo'],
['SP bounty', PHASE.SP_DRAFT, 'bounty'], ['SP supply', PHASE.SP_DRAFT, 'supply'], ['SP shop', PHASE.SP_DRAFT, 'shop'], ['SP tactic', PHASE.SP_DRAFT, 'tactic'], ['SP solo', PHASE.SP_DRAFT, 'solo'],
['COMBAT', PHASE.COMBAT, ''], ['COMBAT done', PHASE.COMBAT, 'done'], ['UNITE', PHASE.UNITE, ''], ['UNITE leaker', PHASE.UNITE, 'leaker'], ['SETTLE', PHASE.SETTLE, ''],
['FINAL_ASSAULT', PHASE.FINAL_ASSAULT, ''], ['FA overtime soon', PHASE.FINAL_ASSAULT, 'overtime'], ['FA draining', PHASE.FINAL_ASSAULT, 'drain'],
['HIDDEN_CORE', PHASE.HIDDEN_CORE, ''], ['COMBAT solo (pause)', PHASE.COMBAT, 'solo'], ['FA solo paused', PHASE.FINAL_ASSAULT, 'solo,paused'],
+182 -82
View File
@@ -5,15 +5,18 @@
// count is `cards` (co-op 6, solo 3):
// bounty 悬赏决策 six distinct cards.bounty entries (solo: 3 of them) built like the official draft of the round
// (`bountyDraftCards`; player feedback after 0.1.0, report #2 — late bounty enemies in the early
// drafts; 33 official screenshots, tools/build-data.mjs BOUNTY_INITIAL_SETS): schedule `bountyDraft`
// names the kind and choices.json `bountyDrafts[kind]` its structure —
// initial (R3, 险境 R6 [ASSUMED]): one of the 10 official sets of "接下来两场作战" cards — a seen
// set, or for an unseen slot one card from each of 6 of the series 10–15 / 20 with the
// tiers I I I II II III;
// boss (R9): a seen group of named bosses + the group's cheap boss bounties / 源石虫·特训 to 6;
// hunter (R11): one of the 15 official events — a seen group (a 特异III giant + its tier-II
// "下场战斗" cards, always together) or for an unseen slot a group of that shape
// [ASSUMED] — filled to 6 with tier-I cards, at most one card per faction series;
// drafts; 66 official screenshots of 22 matches, tools/build-data.mjs BOUNTY_INITIAL_SETS): schedule
// `bountyDraft` names the kind and choices.json `bountyDrafts[kind]` its card lists — the event is a
// fixed list and the draft shows 6 different cards of it, each drawn with its weight (1 + the
// official drafts of the group it showed in):
// initial (R3, 险境 R6 [ASSUMED]): one of the 10 official sets of six "接下来两场作战" cards (all
// six) — 9 seen, the 10th built by `rule` (I I I II II III) [ASSUMED];
// boss (R9): one of the 6 groups (9, 9, 9, 9, 8 and 6 cards) of boss bounties / 源石虫·特训,
// picked by the matches it came in [ASSUMED];
// hunter (R11): one of the 7 seen lists of 7 "下场战斗" cards, uniform, so no list is invented
// [ASSUMED: which of the 15 bounty_hunter events R11 fires is open — by the data's blocks
// 8..15]; a list seen with 6 cards gets its 7th (`open`) by `rule`
// (one 特异III giant, else a tier I / II card of a free faction series) [ASSUMED];
// only `draft` cards of that `draftPool` (never 战术特训 — 法术教鞭 only —, the 鸭爵 set, the 7
// multi-round cards or the pre-series cards: no official draft shows them); positions shuffled; the
// mode's inactive enemy list does not apply (PRTS 卫戍协议:盟约 11/18 note "以上调整仅针对战术特训敌人,
@@ -22,13 +25,26 @@
// MULTI_ROUND_BOUNTY_BATTLES battles and says so (`bountyBattles` / `bountyText`: the user's call
// after playtest #6 — "我不记得有过多轮悬赏")
// supply 道具补给 random normal EQUIP shop items with tier in supplyTiers [lo, hi] (duplicates allowed)
// shop 机密商店 random normal EQUIP shop items of any tier I–VI (duplicates allowed); FREE — the official card
// text is "无需消耗资金,获得装备补给" (research 01 A4/04 addendum), so the price is 0
// tactic 战术决策 distinct cards.tactic entries; terrain cards only for the match stage; a 驰援 card
// (single_special_choice_gain_bond_chess) or a 盟誓 card (global_special_choice_bond_addlayer)
// only while at least one of its bonds is live in this match (opts.bondAvailable = Match.bondLive:
// not in the mode's static inactive list — 标准 turns off 拉特兰 / 阿戈尔 / 卡西米尔 / 奥术 … —
// and still with chess in the pool); a card acting only on dead bonds would do nothing
// shop 机密商店 at the rounds of choices.json `shopDraft` (R11: the 4 official 机密商店; the user: "机密商店按官方改
// 成可以重复吧"): six slots, each drawn on its own — with replacement, so the same item can be
// offered twice (official: 盟约之币 ×2, 变形同构体 ×2): VI, VI, V, 盟约之币 and twice V / IV / III /
// 盟约之币, an item within its tier by `itemWeights` (solo: 3 of the 6 slots [ASSUMED]); other rounds
// (标准 / 险境, no screenshot) and without `shopDraft`: random normal EQUIP shop items of any tier I–VI,
// each card on its own (duplicates allowed) [ASSUMED]. FREE — the official card text is "无需消耗资金,获得装备
// 补给" (research 01 A4/04 addendum), so the price is 0. Two identical cards are two cards: picks,
// `taken` and the client go by the card's `idx`
// tactic 战术决策 cards.tactic entries, each card drawn on its own — with replacement, so the same card can be
// offered twice (the 4 official 战术决策, R11 of matches 7 / 9 / 18 / 20: 补给 ×2 in match 7; the
// user: "战术决策也按官方改成可以重复吧"; `tacticDraftCards`): at the rounds of choices.json
// `tacticDraft` (R11) only its `kinds` (the ally cards — the official 24 cards hold no debuff and
// no terrain card), a card by `weights` (1 + the official cards it showed on) [ASSUMED: the
// weights]; other rounds (标准 / 险境, no screenshot) every card uniform, terrain cards only for
// the match stage [ASSUMED]; a 驰援 card (single_special_choice_gain_bond_chess) or a 盟誓 card
// (global_special_choice_bond_addlayer) only while at least one of its bonds is live in this match
// (opts.bondAvailable = Match.bondLive: not in the mode's static inactive list — 标准 turns off
// 拉特兰 / 阿戈尔 / 卡西米尔 / 奥术 … — and still with chess in the pool); a card acting only on dead
// bonds would do nothing. Two identical cards are two cards: picks, `taken` and the client go by
// the card's `idx`, and each pick applies its own card once (two picks of one team card stack)
// Application (applyCard): a registered `choice:<effectId>` handler (content) wins; otherwise the family default:
// bounty → the picker's bounty list (waves.js adds the enemies to the next `rounds` battles; kill payout to the
// killer via the Battle, perfect payout at settlement when the picker's own battle was perfect)
@@ -185,76 +201,108 @@ export function bountyCard(gd, c) {
};
}
/** Up to `k` cards of `list` not in `taken`, at most one per series of `onePer` (a Set), in a shuffled order. */
function drawDistinct(rng, list, k, taken, onePer = null) {
/** Up to `k` cards of `list` not in `taken`, in a shuffled order. */
function drawDistinct(rng, list, k, taken) {
const out = [];
const used = new Set([...taken].map((c) => c.series));
for (const c of rng.shuffle(list.slice())) {
if (out.length >= k) break;
if (taken.has(c)) continue;
if (onePer && onePer.has(c.series) && used.has(c.series)) continue;
out.push(c);
taken.add(c);
used.add(c.series);
}
return out;
}
/** `k` different entries of `list` drawn one by one with weights `weights` (missing / bad weights count 1). */
function drawWeighted(rng, list, weights, k) {
const pool = list.map((c, i) => [c, Math.max(0, Number(weights && weights[i]) || 1)]);
const out = [];
while (out.length < k && pool.length) {
const c = weightedPick(rng, pool);
out.push(c);
pool.splice(pool.findIndex(([x]) => x === c), 1);
}
return out;
}
/**
* The cards of a 悬赏决策 of kind `kind` after its official structure (choices.json `bountyDrafts[kind]`, see the module
* The cards of an unseen R3 set (`spec.rule`, [ASSUMED]): one card per tier of `tiers` from the two-battle cards of
* `series`, at most `perSeries` of one series; a tier the `prefer` cards have is filled first, by one of them.
*/
function initialRuleSet(rng, rule, all) {
const out = [];
const perSeries = Number.isInteger(rule.perSeries) ? rule.perSeries : 1;
const ofSeries = (s) => out.filter((c) => c.series === s).length;
const prefer = new Set(Array.isArray(rule.prefer) ? rule.prefer : []);
const series = new Set(Array.isArray(rule.series) ? rule.series : []);
// the tiers a preferred card has go first, so that it still fits its series
const prefTiers = new Set(all.filter((c) => prefer.has(c.effectId)).map((c) => c.tier));
const tiers = rng.shuffle((Array.isArray(rule.tiers) ? rule.tiers : []).slice());
for (const tier of [...tiers.filter((t) => prefTiers.has(t)), ...tiers.filter((t) => !prefTiers.has(t))]) {
const ok = (c) => !out.includes(c) && c.tier === tier && series.has(c.series) && ofSeries(c.series) < perSeries;
const pref = all.filter((c) => prefer.has(c.effectId) && ok(c));
const c = rng.pick(pref.length ? pref : all.filter(ok));
if (c) out.push(c);
}
return out;
}
/**
* Fill an R11 list up to `rule.size` (an unseen event: from nothing; a seen group: its `open` cards) [ASSUMED]: a list
* without a 特异III giant gets one, the rest are tier I / II cards, at most one per `onePerSeries` series and
* `maxSeries16` of series 16.
*/
function hunterFill(rng, rule, list, byId) {
const out = list.slice();
const size = Number.isInteger(rule.size) ? rule.size : 7;
const giants = (Array.isArray(rule.giants) ? rule.giants : []).map((id) => byId.get(id)).filter(Boolean);
const onePer = new Set(Array.isArray(rule.onePerSeries) ? rule.onePerSeries : []);
const max16 = Number.isInteger(rule.maxSeries16) ? rule.maxSeries16 : 2;
if (out.length < size && !out.some((c) => giants.includes(c))) { const g = rng.pick(giants.filter((c) => !out.includes(c))); if (g) out.push(g); }
const fits = (c) => !out.includes(c) && (onePer.has(c.series) ? !out.some((x) => x.series === c.series) : c.series !== 16 || out.filter((x) => x.series === 16 && !giants.includes(x)).length < max16);
for (const c of rng.shuffle((Array.isArray(rule.cards) ? rule.cards : []).map((id) => byId.get(id)).filter(Boolean))) {
if (out.length >= size) break;
if (fits(c)) out.push(c);
}
return out;
}
/**
* The cards of a 悬赏决策 of kind `kind` after its official card lists (choices.json `bountyDrafts[kind]`, see the module
* header), over the eligible cards `byId` (effectId → cards.bounty entry). Ids the eligible list lacks are skipped.
* pick 'slot': one of `slots` events, uniform — a seen group, or for an unseen event a list built by `rule` (R3's
* 10th set; R11 has `slots` = its 7 seen lists, so none is built);
* pick 'seen': a group by the number of official matches it came in.
* The draft is `count` different cards of the list drawn by the group's `weights` (a rule-built card weighs 1).
*/
function structuredBounty(rng, kind, spec, byId) {
const get = (ids) => (Array.isArray(ids) ? ids : []).map((id) => byId.get(id)).filter(Boolean);
const taken = new Set();
const count = Number.isInteger(spec.count) ? spec.count : 6;
if (kind === 'initial') {
const sets = Array.isArray(spec.sets) ? spec.sets : [];
const slot = rng.int(Math.max(sets.length, Number.isInteger(spec.slots) ? spec.slots : 0));
if (slot < sets.length) return get(sets[slot].cards).slice(0, count);
// an unseen official set: one card from each of `count` series, with the seen sets' tier mix
const mx = spec.mixed || {};
const series = rng.shuffle((mx.series || []).slice()).slice(0, mx.count || count);
const tiers = rng.shuffle((mx.tiers || []).slice());
const all = [...byId.values()];
const out = [];
series.forEach((s, i) => {
const ofSeries = all.filter((c) => c.series === s);
const want = ofSeries.filter((c) => c.tier === tiers[i]);
const c = rng.pick(want.length ? want : ofSeries);
if (c) out.push(c);
});
return out;
}
if (kind === 'boss') {
const t = rng.pick(Array.isArray(spec.templates) ? spec.templates : []);
if (!t) return [];
const featured = get(t.featured);
featured.forEach((c) => taken.add(c));
return [...featured, ...drawDistinct(rng, get(t.fill), count - featured.length, taken)].slice(0, count);
}
if (kind === 'hunter') {
// one of the official events: a seen group, or for an unseen slot a group of the same shape; then tier-I fill to 6
const onePer = new Set(Array.isArray(spec.onePerSeries) ? spec.onePerSeries : []);
const templates = Array.isArray(spec.templates) ? spec.templates : [];
const slots = Math.max(templates.length, Number.isInteger(spec.slots) ? spec.slots : 0);
const groups = Array.isArray(spec.groups) ? spec.groups : [];
let group = null;
if (spec.pick === 'seen') {
group = weightedPick(rng, groups.map((g) => [g, (Array.isArray(g.seen) ? g.seen.filter((x) => Number.isInteger(x)).length : 0) || 1]));
} else {
const slots = Math.max(groups.length, Number.isInteger(spec.slots) ? spec.slots : 0);
const slot = slots > 0 ? rng.int(slots) : 0;
let group;
let fill;
if (slot < templates.length) {
group = get(templates[slot].featured);
group.forEach((c) => taken.add(c));
fill = get(templates[slot].fill);
} else {
const r = spec.rule || {};
const sizes = Array.isArray(r.groupSizes) && r.groupSizes.length ? r.groupSizes : [1];
const giant = drawDistinct(rng, get(r.giants), 1, taken);
group = [...giant, ...drawDistinct(rng, get(r.groupCards), rng.pick(sizes), taken, onePer)];
fill = get(r.fill);
}
return [...group, ...drawDistinct(rng, fill, count - group.length, taken, onePer)];
group = slot < groups.length ? groups[slot] : null;
}
return [];
const rule = spec.rule || {};
let list;
let weights;
if (group) {
const ids = Array.isArray(group.cards) ? group.cards : [];
list = [];
weights = [];
ids.forEach((id, i) => { const c = byId.get(id); if (c) { list.push(c); weights.push(Array.isArray(group.weights) ? group.weights[i] : 1); } });
if (kind === 'hunter' && Number(group.open) > 0) list = hunterFill(rng, { ...rule, size: list.length + Number(group.open) }, list, byId);
} else if (kind === 'initial') {
list = initialRuleSet(rng, rule, [...byId.values()]);
} else if (kind === 'hunter') {
list = hunterFill(rng, rule, [], byId);
} else {
list = [];
}
return drawWeighted(rng, list, weights, count);
}
/**
@@ -274,9 +322,72 @@ function bountyDraftCards(gd, rng, n, sch, round) {
return rng.shuffle(out).slice(0, n).map((c) => bountyCard(gd, c));
}
/**
* The 机密商店 cards (choices.json `shopDraft`, module header): every slot drawn on its own — a tier (or `coin`, the
* 盟约之币) by the slot's weights, then an item of that tier by `itemWeights` (1 when unlisted; an empty tier falls back to
* the nearest lower one, then any) — so one item can fill two slots. Positions shuffled; `n` < the slots (solo) keeps
* `n` of them. Null without a usable `shopDraft` or at a round its `rounds` (when given) does not list.
*/
export function shopDraftCards(gd, rng, n, round = null) {
const spec = gd.choices.shopDraft;
const slots = spec && Array.isArray(spec.slots) ? spec.slots.filter((x) => x && typeof x === 'object') : [];
if (!slots.length) return null;
if (Array.isArray(spec.rounds) && !spec.rounds.includes(round)) return null;
const w = spec.itemWeights && typeof spec.itemWeights === 'object' ? spec.itemWeights : {};
const coin = typeof spec.coin === 'string' && gd.item(spec.coin) ? spec.coin : null;
const ofTier = (t) => {
for (let k = t; k >= 1; k--) if ((gd.shopItemsByTier[k] || []).length) return gd.shopItemsByTier[k];
return eligibleItems(gd, 1, 6);
};
const out = [];
for (const slot of slots) {
const kinds = Object.entries(slot).filter(([k]) => k === 'coin' ? !!coin : Number.isInteger(Number(k)));
const kind = weightedPick(rng, kinds);
if (kind == null) continue;
const list = kind === 'coin' ? [coin] : ofTier(Number(kind));
const id = weightedPick(rng, list.map((x) => [x, Object.hasOwn(w, x) ? w[x] : 1]));
if (id) out.push(itemCard(gd, id));
}
return rng.shuffle(out).slice(0, Math.max(0, n));
}
/** The draft card of a cards.tactic entry. */
export function tacticCard(c) {
return { kind: 'tactic', id: c.effectId, name: c.name, desc: c.desc || '', tier: null, team: !!c.team, tacticKind: c.kind };
}
/**
* The 战术决策 cards (module header): `n` cards, each drawn on its own — with replacement, so one card can fill two places
* (official R11 of match 7: 补给 ×2). At the rounds of choices.json `tacticDraft` (`rounds`; R11) only the cards of its
* `kinds` (ally), each by `weights` (1 when unlisted); elsewhere — or when those kinds leave nothing — every card, uniform.
* Terrain cards only for the match stage; a card whose every target bond is dead in this match (mode-inactive, or no
* chess left in the pool: `bondAvailable`) is never offered.
*/
export function tacticDraftCards(gd, rng, n, { stageId = null, bondAvailable = null, round = null } = {}) {
const bondOk = (c) => {
if (typeof bondAvailable !== 'function') return true;
const bonds = cardTargetBonds(gd, c.effectId);
return !bonds || bonds.some((b) => !!bondAvailable(b));
};
const list = Array.isArray(gd.choices.cards && gd.choices.cards.tactic) ? gd.choices.cards.tactic : [];
const pool = list.filter((c) => c && (c.kind !== 'terrain' || (stageId && c.stageId === stageId)) && gd.effect(c.effectId) && bondOk(c));
const spec = gd.choices.tacticDraft && typeof gd.choices.tacticDraft === 'object' ? gd.choices.tacticDraft : null;
const official = !!spec && (!Array.isArray(spec.rounds) || spec.rounds.includes(round));
const kinds = official && Array.isArray(spec.kinds) && spec.kinds.length ? spec.kinds : null;
const w = official && spec.weights && typeof spec.weights === 'object' ? spec.weights : {};
const ofKinds = kinds ? pool.filter((c) => kinds.includes(c.kind)) : pool;
const pairs = (ofKinds.length ? ofKinds : pool).map((c) => [c, Object.hasOwn(w, c.effectId) ? w[c.effectId] : 1]);
const out = [];
for (let i = 0; i < n && pairs.length; i++) out.push(tacticCard(weightedPick(rng, pairs)));
return out;
}
function buildCards(gd, rng, family, n, sch, { stageId = null, bondAvailable = null, round = 1 } = {}) {
const cardsData = gd.choices.cards || {};
if (family === 'bounty') return bountyDraftCards(gd, rng, n, sch, round);
if (family === 'shop') {
const cards = shopDraftCards(gd, rng, n, round);
if (cards) return cards;
}
if (family === 'supply' || family === 'shop') {
let lo = 1;
let hi = 6;
@@ -287,18 +398,7 @@ function buildCards(gd, rng, family, n, sch, { stageId = null, bondAvailable = n
for (let i = 0; i < n && list.length; i++) out.push(itemCard(gd, list[Math.floor(rng() * list.length)]));
return out;
}
if (family === 'tactic') {
// a card whose every target bond is dead in this match (mode-inactive, or no chess left in the pool) does nothing
const bondOk = (c) => {
if (typeof bondAvailable !== 'function') return true;
const bonds = cardTargetBonds(gd, c.effectId);
return !bonds || bonds.some((b) => !!bondAvailable(b));
};
const pool = (Array.isArray(cardsData.tactic) ? cardsData.tactic : []).filter((c) => c && (c.kind !== 'terrain' || (stageId && c.stageId === stageId)) && gd.effect(c.effectId) && bondOk(c));
const pick = pool.slice();
rng.shuffle(pick);
return pick.slice(0, n).map((c) => ({ kind: 'tactic', id: c.effectId, name: c.name, desc: c.desc || '', tier: null, team: !!c.team, tacticKind: c.kind }));
}
if (family === 'tactic') return tacticDraftCards(gd, rng, n, { stageId, bondAvailable, round });
return [];
}
+123 -5
View File
@@ -8,7 +8,7 @@ import { makeMatch, give, giveItem, chessOfTier, legalTileFor, checkInvariants a
import { FakeBattle } from '../match/fakeBattle.js';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { createRegistry } from '../../server/match/effectsMeta.js';
import { applyCard, generateDraft } from '../../server/match/choices.js';
import { applyCard, generateDraft, tacticCard } from '../../server/match/choices.js';
import { withBounties } from '../../server/match/waves.js';
import { Battle } from '../../server/sim/Battle.js';
import { bountyOf, battlePlanOf, choiceCardIds, gateOf, hasBattlePart, mapAliases, BUILTIN_REFS } from '../../server/sim/content/choices.js';
@@ -41,6 +41,17 @@ function toDraft(h, r) {
return m.sp;
}
/**
* Lay out the open 战术决策 as exactly the tactic cards `ids` (each its own index). The draft draws every card on its own,
* with replacement (the official 补给 ×2), so a small test pool can repeat one card and miss another: the tests that
* drive a card's effect through g.choice offer their cards this way, after checking what the generator drew.
*/
function offerTactic(m, ids) {
m.sp.cards = ids.map((id, idx) => ({ ...tacticCard(DATA.choices.cards.tactic.find((c) => c.effectId === id)), idx, family: 'tactic' }));
m.markPublic();
return m.sp;
}
/** Pick cards in the draft's (random) order: plan = { playerId: cardId }; bots pick by themselves. */
function draftPicks(h, plan) {
const m = h.m;
@@ -350,7 +361,7 @@ test('道具补给 E2E (co-op): 6 free normal items within the round window; the
m.dispose();
});
test('机密商店 E2E (solo HARD R11): 3 free normal items of any tier I–VI; a second copy merges into the 进阶 item', () => {
test('机密商店 E2E (solo HARD R11): 3 free normal items of the official composition; a second copy merges into the 进阶 item', () => {
const data = dataWith('mode_single_hard', 11, 'shop');
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', humans: 1, seed: 42, data, registry: REG, fake: true });
const m = h.m;
@@ -372,22 +383,122 @@ test('机密商店 E2E (solo HARD R11): 3 free normal items of any tier I–VI;
m.dispose();
});
test('机密商店 draws every tier I–VI with duplicates allowed (generation, 30 drafts)', () => {
test('机密商店 draws the official composition with duplicates allowed (generation, 30 drafts; the evidence is test/match/feedback1-secret-shop.test.js)', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 1, seed: 5, data: dataWith('mode_multi_hard', 11, 'shop'), registry: REG, fake: true });
const coin = DATA.choices.shopDraft.coin;
const tiers = new Set();
let dupes = 0;
for (let i = 0; i < 30; i++) {
const d = generateDraft(h.m.gd, h.m.rngDraft, 11, { stageId: h.m.stageId });
assert.equal(d.family, 'shop');
assert.equal(d.cards.length, 6);
assert.equal(d.cards.filter((c) => DATA.items[c.id].tier === 6).length, 2, 'two tier-VI items');
assert.ok(d.cards.some((c) => c.id === coin), 'a 盟约之币');
for (const c of d.cards) tiers.add(DATA.items[c.id].tier);
if (new Set(d.cards.map((c) => c.id)).size < d.cards.length) dupes++;
}
h.m.dispose();
assert.deepEqual([...tiers].sort(), [1, 2, 3, 4, 5, 6]);
assert.deepEqual([...tiers].sort(), [1, 3, 4, 5, 6], 'tiers III–VI and the tier-I 盟约之币');
assert.ok(dupes > 0, 'duplicates allowed');
});
test('机密商店 E2E (co-op): two identical cards are two cards — both can be taken, by index, and each picker gets the item', () => {
// six 变形同构体: every slot draws the one item `coin` names
const one = Object.values(DATA.items).find((i) => i.name === '变形同构体' && !i.isGolden).id;
const data = dataWith('mode_multi_hard', 3, 'shop');
data.choices = { ...data.choices, shopDraft: { ...data.choices.shopDraft, rounds: [3], slots: Array.from({ length: 6 }, () => ({ coin: 1 })), coin: one } };
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, bots: 2, seed: 43, data, registry: REG, fake: true });
const m = h.m;
h.start();
const sp = toDraft(h, 3);
assert.equal(sp.family, 'shop');
const pub = m.publicView().sp;
assert.deepEqual(pub.cards.map((c) => c.id), Array(6).fill(one), 'six identical offers');
assert.deepEqual(pub.cards.map((c) => c.idx), [0, 1, 2, 3, 4, 5], 'each its own index');
const funds = { p_0: h.ps('p_0').funds, p_1: h.ps('p_1').funds };
const picked = {};
for (let guard = 0; guard < 1000 && m.phase === 'SP_DRAFT'; guard++) {
const pid = m.spTurn();
if (pid !== 'p_0' && pid !== 'p_1') { h.sched.runNext(); continue; }
// a taken card is refused even though an identical one is still free
const taken = Object.keys(m.sp.taken).map(Number);
if (taken.length) assert.deepEqual(m.handle(pid, { t: 'g.choice', idx: taken[0] }), { error: 'SOLD_OUT' }, 'a taken index stays taken');
const idx = m.sp.cards.find((c) => m.sp.taken[c.idx] == null).idx;
assert.deepEqual(m.handle(pid, { t: 'g.choice', idx }), { ok: true });
picked[pid] = idx;
}
assert.equal(m.phase, 'PREP');
assert.notEqual(picked.p_0, picked.p_1, 'two different cards of the same item');
assert.equal(Object.keys(sp.taken).length, 4, 'every player took one of the identical cards');
for (const pid of ['p_0', 'p_1']) {
const ps = h.ps(pid);
assert.ok([...ps.hand, ...ps.temp].some((p) => p && (p.id === one || p.id === DATA.items[one].goldenId)), `${pid} holds the item`);
assert.equal(ps.funds, funds[pid], 'free');
}
matchInvariants(m);
m.dispose();
});
test('战术决策 E2E (co-op): identical cards are separate cards — one pick applies its card exactly once, the twin stays pickable, picks stack', () => {
// a pool of one card: every place draws 补给 (with replacement; the official match 7 R11 offered 补给 twice)
const one = 'allybuff_select_4';
const data = dataWith('mode_multi_normal', 3, 'tactic', { tactic: [one] });
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, bots: 2, seed: 44, data, registry: REG, fake: true });
const m = h.m;
h.start();
const sp = toDraft(h, 3);
assert.equal(sp.family, 'tactic');
const pub = m.publicView().sp;
assert.deepEqual(pub.cards.map((c) => c.id), Array(6).fill(one), 'six 补给');
assert.deepEqual(pub.cards.map((c) => c.idx), [0, 1, 2, 3, 4, 5], 'each its own index');
assert.equal(new Set(sp.cards).size, 6, 'six card objects, not one shared');
const all = [...m.players.values()];
const free0 = new Map(all.map((ps) => [ps.playerId, ps.shop.freeRefreshes]));
const freeNow = () => all.map((ps) => ps.shop.freeRefreshes - free0.get(ps.playerId));
const picked = {};
for (let guard = 0; guard < 1000 && m.phase === 'SP_DRAFT'; guard++) {
const pid = m.spTurn();
if (pid !== 'p_0' && pid !== 'p_1') { h.sched.runNext(); continue; }
const taken = Object.keys(m.sp.taken).map(Number);
if (taken.length) assert.deepEqual(m.handle(pid, { t: 'g.choice', idx: taken[0] }), { error: 'SOLD_OUT' }, 'a taken index stays taken while a twin is free');
const before = freeNow();
const idx = m.sp.cards.find((c) => m.sp.taken[c.idx] == null).idx;
assert.deepEqual(m.handle(pid, { t: 'g.choice', idx }), { ok: true });
picked[pid] = idx;
assert.deepEqual(m.sp.taken[idx], pid, 'that index alone is taken');
assert.equal(Object.values(m.sp.taken).filter((x) => x === pid).length, 1, 'one card per pick');
// 补给 is a team card: +2 free refreshes for every alive player, once — not once per identical card
assert.deepEqual(freeNow().map((x, i) => x - before[i]), all.map(() => 2), `${pid}: one 补给 applied once`);
}
assert.equal(m.phase, 'PREP');
assert.notEqual(picked.p_0, picked.p_1, 'two different cards of the same tactic');
assert.equal(Object.keys(sp.taken).length, 4, 'every player took one of the identical cards');
assert.deepEqual(freeNow(), all.map(() => 8), 'four picks of 补给 stack (+2 each)');
matchInvariants(m);
m.dispose();
});
test('战术决策 E2E (solo): of three identical personal cards the one picked applies once; the other two stay untaken', () => {
const one = 'allybuff_select_6'; // 升华: the next purchased operator becomes elite
const data = dataWith('mode_single_normal', 9, 'tactic', { tactic: [one] });
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', humans: 1, seed: 45, data, registry: REG, fake: true });
const m = h.m;
h.start();
const sp = toDraft(h, 9);
assert.deepEqual(sp.cards.map((c) => [c.idx, c.id]), [[0, one], [1, one], [2, one]]);
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 1 }), { ok: true });
assert.deepEqual(sp.taken, { 1: 'p_0' });
assert.deepEqual(sp.picks, { p_0: 1 });
const refs = h.ps('p_0').effects.filter((e) => e.key === BUILTIN_REFS.eliteChess);
assert.equal(refs.length, 1, 'one 升华');
assert.equal(refs[0].counter, 1);
for (let guard = 0; guard < 50 && m.phase === 'SP_DRAFT'; guard++) h.sched.runNext();
assert.equal(m.phase, 'PREP');
assert.equal(h.ps('p_0').effects.filter((e) => e.key === BUILTIN_REFS.eliteChess).length, 1, 'still one after the draft closed');
matchInvariants(m);
m.dispose();
});
// =====================================================================================================================
// 战术决策 — prep-side cards
@@ -401,7 +512,10 @@ test('战术决策 E2E (co-op 3): team cards reach every teammate (AI included),
const sp = toDraft(h, 3);
assert.equal(sp.family, 'tactic');
assert.equal(m.publicView().sp.name, '战术决策');
assert.deepEqual(sp.cards.map((c) => c.id).sort(), [...TEAM_PREP].sort());
assert.equal(m.publicView().sp.desc, '进行协同调整,做好迎战准备。', 'the official header');
assert.equal(sp.cards.length, 6);
assert.ok(sp.cards.every((c) => TEAM_PREP.includes(c.id)), 'drawn from the pool (with replacement)');
offerTactic(m, TEAM_PREP);
const view = m.publicView().sp.cards;
for (const c of view) assert.equal(c.team, DATA.choices.cards.tactic.find((x) => x.effectId === c.id).team, `${c.id} team flag`);
const all = [...m.players.values()];
@@ -492,6 +606,7 @@ test('战术决策 升华 / 整备: the next purchased operator becomes elite, t
const m = h.m;
h.start();
toDraft(h, 9);
offerTactic(m, ['allybuff_select_5', 'allybuff_select_6', 'allybuff_select_3']);
draftPicks(h, { p_0: 'allybuff_select_6' });
const ps = h.ps('p_0');
const ref = ps.effects.find((e) => e.key === BUILTIN_REFS.eliteChess);
@@ -535,6 +650,7 @@ test('战术决策 E2E: 火力 / 锐利 evaluate the 手牌区 at every prep end
const m = h.m;
h.start();
const sp = toDraft(h, 3);
offerTactic(m, ['allybuff_select_13', 'allybuff_select_19']);
const [a, b] = sp.order;
draftPicks(h, { [a]: 'allybuff_select_13', [b]: 'allybuff_select_19' });
const A = h.ps(a);
@@ -597,6 +713,7 @@ test('战术决策 E2E (real battles): 排斥 debuffs every enemy of the picker\
};
h.start();
const sp = toDraft(h, 3);
offerTactic(m, [id, 'allybuff_select_3']);
const [a, b] = sp.order;
draftPicks(h, { [a]: id, [b]: 'allybuff_select_3' });
assert.ok(h.ps(a).effects.some((e) => e.key === `choice:${id}` && e.iconId === 'icon_enemy_debuff'));
@@ -747,6 +864,7 @@ test('模拟战场演变 E2E (act1 m01): the card clears the picker\'s crates fo
h.setStage('act1autochess_m01');
const sp = toDraft(h, 3);
assert.ok(!sp.cards.some((c) => c.id === 'map_m02_1'), 'terrain cards only for the match stage');
offerTactic(m, ['map_m01_1', 'allybuff_select_3']);
const [a, b] = sp.order;
draftPicks(h, { [a]: 'map_m01_1', [b]: 'allybuff_select_3' });
const A = h.ps(a);
+3 -1
View File
@@ -625,7 +625,9 @@ test('player feedback after 0.1.0 (DESIGN §21, v0.1.1): every report mapped, th
// settled: the tactician re-orientation (user-confirmed) and the screenshot-based bounty structures
const s2120 = sub(20);
assert.match(s2120, /A tactician re-oriented in place \(§21\.3\) \| a range-bound summon still inside the new range stays[^\n]*user-confirmed 2026-10-03/);
assert.match(s2120, /Bounty drafts \(§21\.2\) \| the official structure of each round from the user's 33 screenshots/);
assert.match(s2120, /Bounty drafts \(§21\.2\) \| the official structure of each round from the user's 66 screenshots of 22 matches/);
assert.match(s2120, /机密商店 \/ 战术决策 repeats \(§21\.2\) \| the same card can be offered twice/);
assert.ok(!/the remake's never do/.test(sub(2)), '§21.2: the 机密商店 and the 战术决策 repeat');
assert.match(s2120, /MULTI_ROUND_BOUNTY_BATTLES = null/);
// §21.2 = §7 = §20.6 superseded = the data
assert.match(sub(2), /initial 42, boss 20, hunter 24 \(86 drafted\)/);
+57 -1
View File
@@ -1,7 +1,8 @@
{
"_doc": "Official 机变 drafts at the start of rounds 3, 9 and 11, read from screenshots of 11 official co-op matches collected by the user (2026-10-03; player names left out). The cards are in screen order (left to right, top to bottom). A bounty card is [title, enemy as printed, coins, battles] (battles 2 = 接下来两场作战, 1 = 下场作战 / 下场战斗; 源石虫·特训 pays 0); a 机密商店 card is [item, tier]; a 战术决策 card is [name]. Names are converted to Simplified Chinese. Matches 1 and 2 come from the Traditional-Chinese client, 3-11 from Simplified-Chinese video captures. Every match is a 4-player co-op match of 绝境 or 终极 (the modes whose SP rounds are 3, 9 and 11; the HUD does not say which). Used by test/match/feedback1-bounty.test.js.",
"_doc": "Official 机变 drafts at the start of rounds 3, 9 and 11, read from screenshots of 22 official co-op matches collected by the user (33 on 2026-10-03, 33 more the same day; player names left out). The cards are in screen order (left to right, top to bottom). A bounty card is [title, enemy as printed, coins, battles] (battles 2 = 接下来两场作战, 1 = 下场作战 / 下场战斗; 源石虫·特训 pays 0); a 机密商店 card is [item, tier]; a 战术决策 card is [name]. Names are converted to Simplified Chinese. Matches 1 and 2 come from the Traditional-Chinese client, 3-22 from Simplified-Chinese video captures of one uploader; the user dates the videos to the end of March 2026, all of one season (卫戍协议:盟约 下半, act2autochess). Every match is a 4-player co-op match of 绝境 or 终极 (the modes whose SP rounds are 3, 9 and 11; the HUD does not say which). A card another player has taken stays in its slot, greyed with the taker's avatar (match 17 R3: 迷茫的修理小助手; match 21 R9: 大鲍勃 and 杰斯顿·威廉姆斯): one shared draft, one pick each in turn. `boards` = the colour of the battlefield behind the overlay (the same in all three rounds of a match). Used by test/match/feedback1-bounty.test.js.",
"season": "act2autochess",
"aliases": { "深池重甲卫队队长": "深池重甲卫士队长" },
"boards": { "_doc": "grey = dark grey metal tiles with blue / red frames; tan = sandy / beige tiles (possibly more than one map)", "grey": [8, 9, 11, 13, 16, 18, 19], "tan": [1, 2, 3, 4, 5, 6, 7, 10, 12, 14, 15, 17, 20, 21, 22] },
"matches": {
"1": {
"3": { "family": "bounty", "cards": [["悬赏·损伤I", "临时收音师", 1, 2], ["悬赏·损伤III", "主角阵营角色", 3, 2], ["悬赏·特异I", "迷茫的修理小助手", 1, 2], ["悬赏·特异II", "寻仇者", 2, 2], ["悬赏·特异II", "圣堂剑士", 2, 2], ["悬赏·特异I", "高能源石虫", 1, 2]] },
@@ -57,6 +58,61 @@
"3": { "family": "bounty", "cards": [["悬赏·持续I", "萨卡兹枯朽战士组长", 1, 2], ["悬赏·损伤I", "底海滑动者", 1, 2], ["悬赏·频次III", "清明", 3, 2], ["悬赏·折射II", "深池暗影术师", 2, 2], ["悬赏·飞行I", "妖怪", 1, 2], ["悬赏·隐匿II", "隐形术师", 2, 2]] },
"9": { "family": "bounty", "cards": [["杰斯顿·威廉姆斯·悬赏", "杰斯顿·威廉姆斯", 4, 1], ["弑君者·悬赏", "弑君者", 1, 1], ["源石虫·特训", "源石虫", 0, 1], ["W·悬赏", "W", 1, 1], ["碎骨·悬赏", "碎骨", 1, 1], ["陷落雪祀·悬赏", "陷落雪祀", 3, 1]] },
"11": { "family": "bounty", "cards": [["悬赏·特异II", "拥霜羽兽", 2, 1], ["悬赏·隐匿I", "重弩突袭者", 1, 1], ["悬赏·损伤I", "心虚设计师", 1, 1], ["悬赏·特异III", "乌顶巨角卢鲁", 3, 1], ["悬赏·频次I", "沏虹", 1, 1], ["悬赏·持续I", "假想敌:蚀裂", 1, 1]] }
},
"12": {
"3": { "family": "bounty", "cards": [["悬赏·损伤II", "控潮术师", 2, 2], ["悬赏·频次I", "磨砻", 1, 2], ["悬赏·损伤II", "无胄盟清扫小队", 2, 2], ["悬赏·持续I", "萨卡兹枯朽战士", 1, 2], ["悬赏·飞行I", "妖怪", 1, 2], ["悬赏·折射III", "深池伙友卫队", 3, 2]] },
"9": { "family": "bounty", "cards": [["碎骨·悬赏", "碎骨", 1, 1], ["大鲍勃·悬赏", "大鲍勃", 2, 1], ["弑君者·悬赏", "弑君者", 1, 1], ["W·悬赏", "W", 1, 1], ["源石虫·特训", "源石虫", 0, 1], ["杰斯顿·威廉姆斯·悬赏", "杰斯顿·威廉姆斯", 4, 1]] },
"11": { "family": "bounty", "cards": [["悬赏·折射II", "愤怒的守墓石像", 2, 1], ["悬赏·特异III", "萨卡兹骸骨拷打者", 3, 1], ["悬赏·飞行I", "法术大师A2", 1, 1], ["悬赏·损伤I", "心虚设计师", 1, 1], ["悬赏·损伤II", "灼藤", 2, 1], ["悬赏·频次I", "沏虹", 1, 1]] }
},
"13": {
"3": { "family": "bounty", "cards": [["悬赏·损伤I", "底海滑动者", 1, 2], ["悬赏·频次I", "磨砻", 1, 2], ["悬赏·持续II", "逐腐兽", 2, 2], ["悬赏·折射II", "深池暗影术师", 2, 2], ["悬赏·特异III", "自制投石机", 3, 2], ["悬赏·隐匿I", "隐形弩手", 1, 2]] },
"9": { "family": "bounty", "cards": [["陷落雪祀·悬赏", "陷落雪祀", 3, 1], ["源石虫·特训", "源石虫", 0, 1], ["碎骨·悬赏", "碎骨", 1, 1], ["弑君者·悬赏", "弑君者", 1, 1], ["W·悬赏", "W", 1, 1], ["大鲍勃·悬赏", "大鲍勃", 2, 1]] },
"11": { "family": "bounty", "cards": [["悬赏·频次I", "沏虹", 1, 1], ["悬赏·折射II", "深池重甲卫队队长", 2, 1], ["悬赏·特异III", "狂暴宿主组长", 3, 1], ["悬赏·特异II", "拥霜羽兽", 2, 1], ["悬赏·持续II", "尖端萨卡兹枯朽战车", 2, 1], ["悬赏·隐匿I", "重弩突袭者", 1, 1]] }
},
"14": {
"3": { "family": "bounty", "cards": [["悬赏·特异I", "新手祭司学徒", 1, 2], ["悬赏·特异I", "冒失的小弟", 1, 2], ["悬赏·特异II", "骇笑看客", 2, 2], ["悬赏·特异I", "雪孩子", 1, 2], ["悬赏·特异II", "水遁忍者", 2, 2], ["悬赏·特异III", "自制投石机", 3, 2]] },
"9": { "family": "bounty", "cards": [["大鲍勃·悬赏", "大鲍勃", 2, 1], ["杰斯顿·威廉姆斯·悬赏", "杰斯顿·威廉姆斯", 4, 1], ["源石虫·特训", "源石虫", 0, 1], ["弑君者·悬赏", "弑君者", 1, 1], ["碎骨·悬赏", "碎骨", 1, 1], ["“自在”·悬赏", "“自在”", 4, 1]] },
"11": { "family": "bounty", "cards": [["悬赏·飞行I", "法术大师A2", 1, 1], ["悬赏·特异II", "异质裂兽·α", 2, 1], ["悬赏·特异III", "伊利昂的木驮兽", 3, 1], ["悬赏·隐匿II", "家族暗影灭迹人", 2, 1], ["悬赏·特异I", "转译基底·α", 1, 1], ["悬赏·持续I", "假想敌:蚀裂", 1, 1]] }
},
"15": {
"3": { "family": "bounty", "cards": [["悬赏·特异II", "咸鳞汁推荐者", 2, 2], ["悬赏·折射I", "深池侦察犬pro", 1, 2], ["悬赏·损伤III", "反派阵营角色", 3, 2], ["悬赏·飞行II", "吉兆飞鳞", 2, 2], ["悬赏·特异I", "节日气球", 1, 2], ["悬赏·特异I", "简饲源石虫", 1, 2]] },
"9": { "family": "bounty", "cards": [["W·悬赏", "W", 1, 1], ["碎骨·悬赏", "碎骨", 1, 1], ["大鲍勃·悬赏", "大鲍勃", 2, 1], ["弑君者·悬赏", "弑君者", 1, 1], ["源石虫·特训", "源石虫", 0, 1], ["杰斯顿·威廉姆斯·悬赏", "杰斯顿·威廉姆斯", 4, 1]] },
"11": { "family": "bounty", "cards": [["悬赏·持续I", "假想敌:蚀裂", 1, 1], ["悬赏·隐匿I", "重弩突袭者", 1, 1], ["悬赏·频次I", "沏虹", 1, 1], ["悬赏·特异I", "转译基底·α", 1, 1], ["悬赏·飞行II", "枯朽萃聚使徒", 2, 1], ["悬赏·损伤II", "灼藤", 2, 1]] }
},
"16": {
"3": { "family": "bounty", "cards": [["悬赏·持续III", "萨卡兹枯朽战车", 3, 2], ["悬赏·特异I", "潜伏者", 1, 2], ["悬赏·频次I", "磨砻", 1, 2], ["悬赏·折射I", "深池方阵战士", 1, 2], ["悬赏·飞行II", "暴鸰", 2, 2], ["悬赏·损伤II", "控潮术师", 2, 2]] },
"9": { "family": "bounty", "cards": [["杰斯顿·威廉姆斯·悬赏", "杰斯顿·威廉姆斯", 4, 1], ["陷落雪祀·悬赏", "陷落雪祀", 3, 1], ["源石虫·特训", "源石虫", 0, 1], ["弑君者·悬赏", "弑君者", 1, 1], ["W·悬赏", "W", 1, 1], ["碎骨·悬赏", "碎骨", 1, 1]] },
"11": { "family": "bounty", "cards": [["悬赏·损伤I", "心虚设计师", 1, 1], ["悬赏·损伤II", "灼藤", 2, 1], ["悬赏·隐匿II", "家族暗影灭迹人", 2, 1], ["悬赏·折射II", "愤怒的守墓石像", 2, 1], ["悬赏·飞行I", "法术大师A2", 1, 1], ["悬赏·特异III", "萨卡兹骸骨拷打者", 3, 1]] }
},
"17": {
"3": { "family": "bounty", "cards": [["悬赏·特异II", "圣堂剑士", 2, 2], ["悬赏·特异I", "迷茫的修理小助手", 1, 2], ["悬赏·特异II", "寻仇者", 2, 2], ["悬赏·损伤I", "临时收音师", 1, 2], ["悬赏·损伤III", "主角阵营角色", 3, 2], ["悬赏·特异I", "高能源石虫", 1, 2]] },
"9": { "family": "bounty", "cards": [["碎骨·悬赏", "碎骨", 1, 1], ["“自在”·悬赏", "“自在”", 4, 1], ["陷落雪祀·悬赏", "陷落雪祀", 3, 1], ["弑君者·悬赏", "弑君者", 1, 1], ["源石虫·特训", "源石虫", 0, 1], ["大鲍勃·悬赏", "大鲍勃", 2, 1]] },
"11": { "family": "bounty", "cards": [["悬赏·飞行II", "枯朽萃聚使徒", 2, 1], ["悬赏·特异III", "泥岩巨像", 3, 1], ["悬赏·持续I", "假想敌:蚀裂", 1, 1], ["悬赏·隐匿I", "重弩突袭者", 1, 1], ["悬赏·频次II", "新硎", 2, 1], ["悬赏·折射II", "深池重甲卫队队长", 2, 1]] }
},
"18": {
"3": { "family": "bounty", "cards": [["悬赏·损伤III", "术师快艇", 3, 2], ["悬赏·隐匿I", "隐形弩手", 1, 2], ["悬赏·频次I", "俗心", 1, 2], ["悬赏·飞行I", "妖怪", 1, 2], ["悬赏·折射II", "深池暗影术师", 2, 2], ["悬赏·频次II", "烹泉", 2, 2]] },
"9": { "family": "bounty", "cards": [["杰斯顿·威廉姆斯·悬赏", "杰斯顿·威廉姆斯", 4, 1], ["“自在”·悬赏", "“自在”", 4, 1], ["源石虫·特训", "源石虫", 0, 1], ["大鲍勃·悬赏", "大鲍勃", 2, 1], ["“鼠王”·悬赏", "“鼠王”", 2, 1], ["弑君者·悬赏", "弑君者", 1, 1]] },
"11": { "family": "tactic", "cards": [["整备"], ["玛恩纳的盟誓"], ["斯卡蒂的盟誓"], ["萨尔贡驰援"], ["财富"], ["升华"]] }
},
"19": {
"3": { "family": "bounty", "cards": [["悬赏·隐匿I", "隐形弩手", 1, 2], ["悬赏·持续II", "逐腐兽", 2, 2], ["悬赏·频次I", "磨砻", 1, 2], ["悬赏·损伤I", "底海滑动者", 1, 2], ["悬赏·特异III", "自制投石机", 3, 2], ["悬赏·折射II", "深池暗影术师", 2, 2]] },
"9": { "family": "bounty", "cards": [["“自在”·悬赏", "“自在”", 4, 1], ["大鲍勃·悬赏", "大鲍勃", 2, 1], ["陷落雪祀·悬赏", "陷落雪祀", 3, 1], ["W·悬赏", "W", 1, 1], ["碎骨·悬赏", "碎骨", 1, 1], ["杰斯顿·威廉姆斯·悬赏", "杰斯顿·威廉姆斯", 4, 1]] },
"11": { "family": "bounty", "cards": [["悬赏·特异III", "伊利昂的木驮兽", 3, 1], ["悬赏·隐匿II", "家族暗影灭迹人", 2, 1], ["悬赏·特异I", "转译基底·α", 1, 1], ["悬赏·持续I", "假想敌:蚀裂", 1, 1], ["悬赏·飞行I", "法术大师A2", 1, 1], ["悬赏·特异II", "异质裂兽·α", 2, 1]] }
},
"20": {
"3": { "family": "bounty", "cards": [["悬赏·隐匿II", "隐形术师", 2, 2], ["悬赏·损伤I", "底海滑动者", 1, 2], ["悬赏·频次III", "清明", 3, 2], ["悬赏·持续I", "萨卡兹枯朽战士组长", 1, 2], ["悬赏·飞行I", "妖怪", 1, 2], ["悬赏·折射II", "深池暗影术师", 2, 2]] },
"9": { "family": "bounty", "cards": [["“庞贝”·悬赏", "“庞贝”", 2, 1], ["“复仇者”·悬赏", "“复仇者”", 6, 1], ["源石虫·特训", "源石虫", 0, 1], ["萨卡兹百夫长·悬赏", "萨卡兹百夫长", 2, 1], ["澪·悬赏", "澪", 3, 1], ["W·悬赏", "W", 1, 1]] },
"11": { "family": "tactic", "cards": [["阿戈尔驰援"], ["补给"], ["列装"], ["叙拉古驰援"], ["银灰的盟誓"], ["莫斯提马的盟誓"]] }
},
"21": {
"3": { "family": "bounty", "cards": [["悬赏·持续II", "逐腐兽", 2, 2], ["悬赏·特异III", "自制投石机", 3, 2], ["悬赏·折射II", "深池暗影术师", 2, 2], ["悬赏·隐匿I", "隐形弩手", 1, 2], ["悬赏·频次I", "磨砻", 1, 2], ["悬赏·损伤I", "底海滑动者", 1, 2]] },
"9": { "family": "bounty", "cards": [["弑君者·悬赏", "弑君者", 1, 1], ["碎骨·悬赏", "碎骨", 1, 1], ["大鲍勃·悬赏", "大鲍勃", 2, 1], ["“鼠王”·悬赏", "“鼠王”", 2, 1], ["杰斯顿·威廉姆斯·悬赏", "杰斯顿·威廉姆斯", 4, 1], ["W·悬赏", "W", 1, 1]] },
"11": { "family": "bounty", "cards": [["悬赏·特异III", "温顺的武装大驮兽", 3, 1], ["悬赏·折射II", "深池重甲卫队队长", 2, 1], ["悬赏·持续II", "尖端萨卡兹枯朽战车", 2, 1], ["悬赏·特异II", "异质裂兽·α", 2, 1], ["悬赏·频次I", "沏虹", 1, 1], ["悬赏·飞行I", "法术大师A2", 1, 1]] }
},
"22": {
"3": { "family": "bounty", "cards": [["悬赏·特异II", "咸鳞汁推荐者", 2, 2], ["悬赏·飞行II", "吉兆飞鳞", 2, 2], ["悬赏·特异I", "简饲源石虫", 1, 2], ["悬赏·特异I", "节日气球", 1, 2], ["悬赏·折射I", "深池侦察犬pro", 1, 2], ["悬赏·损伤III", "反派阵营角色", 3, 2]] },
"9": { "family": "bounty", "cards": [["澪·悬赏", "澪", 3, 1], ["弑君者·悬赏", "弑君者", 1, 1], ["喷气人·悬赏", "喷气人", 3, 1], ["W·悬赏", "W", 1, 1], ["纠缠藤蔓·悬赏", "纠缠藤蔓", 3, 1], ["大鲍勃·悬赏", "大鲍勃", 2, 1]] },
"11": { "family": "bounty", "cards": [["悬赏·特异III", "温顺的武装大驮兽", 3, 1], ["悬赏·频次I", "沏虹", 1, 1], ["悬赏·飞行I", "法术大师A2", 1, 1], ["悬赏·特异II", "异质裂兽·α", 2, 1], ["悬赏·持续II", "尖端萨卡兹枯朽战车", 2, 1], ["悬赏·损伤II", "灼藤", 2, 1]] }
}
},
"extra": {
+13 -5
View File
@@ -116,16 +116,24 @@ test('绝境 Final Assault: both pair fields drain the one pool, every hit exact
m.dispose();
});
test('终极 Final Assault vs 假想敌:胄 (seed 12): both players\' 奥术 never multiply on the leader; a drone costs it 2 % of the pool', () => {
test('终极 Final Assault vs 假想敌:胄 (seeded bot match): both players\' 奥术 never multiply on the leader; a drone costs it 2 % of the pool', () => {
// DESIGN §20.10: one 奥术 instance per target (the strongest — PRTS 作战机制 同名buff, 巴哈姆特 12316 "共享型buff會跟對面搶");
// 死亡集群's "最大生命值2%" = the leader's shown max HP, the pool (DRONE_LINK_BASE 'pool' [ASSUMED]): 72 000 at 终极
// seed 12: since the elite-to-board merge (DESIGN §20.11) the bots' boards differ; seed 7 no longer pairs two 奥术 players
const h = toFinalAssault({ difficulty: 'ABYSS', seed: 12, bossId: 'boss_1' });
// the bots' boards follow every draw of the match (the elite-to-board merge, DESIGN §20.11, moved seed 7 to 12; the
// 战术决策 drawn with replacement moved 12 on): the first of these seeds whose bots pair two 奥术 players
const pairOf = (m) => m.fields.filter((f) => f.battle).find((f) => f.players.length === 2 && f.players.every((pid) => f.battle.getPlayer(pid).bonds.arcaneShip?.active));
let h = null;
for (const seed of [13, 7, 11, 21, 23]) {
h = toFinalAssault({ difficulty: 'ABYSS', seed, bossId: 'boss_1' });
if (pairOf(h.m)) break;
h.m.dispose();
h = null;
}
assert.ok(h, 'precondition: a pair field where both players run 奥术 (bot lineups of one of the seeds)');
const m = h.m;
const fields = m.fields.filter((f) => f.battle);
const bb = bondBb('arcaneShip');
const both = fields.find((f) => f.players.length === 2 && f.players.every((pid) => f.battle.getPlayer(pid).bonds.arcaneShip?.active));
assert.ok(both, 'precondition: a pair field where both players run 奥术 (bot lineups of seed 12)');
const both = pairOf(m);
const allowed = [];
for (const pid of both.players) {
const b = both.battle.getPlayer(pid).bonds.arcaneShip;
+176 -103
View File
@@ -1,15 +1,18 @@
// Player feedback after 0.1.0, report #2 (workstream WB): "本来应该后期出的悬赏的怪物在前期的悬赏就出现了,导致选了打不过".
// The user then collected the official 悬赏决策 of rounds 3, 9 and 11 in 11 official co-op matches (33 screenshots, 绝境 /
// 终极; the readings are test/fixtures/official-bounty-drafts.json, player names left out). They settle the draft rules
// (tools/build-data.mjs BOUNTY_INITIAL_SETS, server/match/choices.js bountyDraftCards):
// R3 six "接下来两场作战" cards, 3 × I + 2 × II + 1 × III, one of the official fixed sets (a whole series 17 / 18 / 19,
// or one card from each of 6 of the series 10–15 / 20) — 7 of the 10 events seen;
// R9 boss bounties + 源石虫·特训: a group of named bosses that come together + cheap ones to 6;
// R11 悬赏决策 / 机密商店 / 战术决策 (never 道具补给); its bounty: a group (one 特异III giant + 1–4 tier-II "下场战斗" cards,
// always together — matches 1 and 6 share the whole group) + tier-I cards to 6, at most one per faction series;
// The user then collected the official 机变 of rounds 3, 9 and 11 in 22 official co-op matches (66 screenshots, 绝境 /
// 终极, one season — videos of the end of March 2026; the readings are test/fixtures/official-bounty-drafts.json, player
// names left out). They settle the draft rules (tools/build-data.mjs BOUNTY_INITIAL_SETS, server/match/choices.js
// bountyDraftCards): a 悬赏决策 event is a fixed card list and the draft shows 6 different cards of it —
// R3 a set of six "接下来两场作战" cards (all six), 3 × I + 2 × II + 1 × III: 9 of the 10 events seen;
// R9 a group of up to 9 boss bounties / 源石虫·特训: 6 groups (9, 9, 9, 9, 8, 6 cards) for the 6 events, the 鼠王
// group in 14 of 22 matches;
// R11 悬赏决策 14 / 机密商店 4 / 战术决策 4 / 道具补给 0; its bounty: a list of 7 "下场战斗" cards (one 特异III giant),
// one per faction series — 7 lists seen, three of them whole (each draft leaves out one card), picked uniformly
// (no list built from nothing; which of the 15 bounty_hunter events R11 fires is open);
// no draft shows a multi-round card, a pre-series card (enemyeffect_3_*), 战术特训 or the 鸭爵 set; no card twice.
// Each card's enemy is fixed by its effect; the title only names category and tier (悬赏·损伤I = 底海滑动者 in
// enemyeffect_12_4, 临时收音师 in enemyeffect_18_1). Real data, real draft code, the real match path for the players' case.
// The 机密商店 is in test/match/feedback1-secret-shop.test.js.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
@@ -27,57 +30,39 @@ const SPEC = DATA.choices.bountyDrafts;
const COOP_BOUNTY_MODES = ['mode_multi_normal', 'mode_multi_hard', 'mode_multi_abyss'];
const isBoss = (key) => DATA.enemies[key] && DATA.enemies[key].rank === 'BOSS';
const sameSet = (a, b) => a.length === b.length && [...a].sort().join() === [...b].sort().join();
const subset = (a, b) => a.every((x) => b.includes(x));
const EE = (s) => `enemyeffect_${s}`;
/** The data card of a screenshot reading [title, enemy, coins, battles] (by title, the enemy in the text, coins, battles). */
function resolve([title, enemy, coin, battles]) {
const name = OFFICIAL.aliases[enemy] || enemy;
const hits = CARDS.filter((c) => c.name === title && c.coin === coin && (c.desc.includes(`1只${name}`) || c.desc.includes(`1个${name}`))
// "1只萨卡兹枯朽战士," — the enemy's whole name (萨卡兹枯朽战士 is not 萨卡兹枯朽战士组长)
const hits = CARDS.filter((c) => c.name === title && c.coin === coin && (c.desc.includes(`1只${name},`) || c.desc.includes(`1个${name},`))
&& (battles === 2 ? c.rounds === 2 : c.rounds === 1));
assert.equal(hits.length, 1, `${title} ${enemy} ${coin}: ${hits.map((c) => c.effectId).join(', ') || 'no card'}`);
return hits[0].effectId;
}
/** Every official draft of the fixture: [{ where, round, family, ids? }] (ids for 悬赏决策). */
/** Every official draft of the fixture: [{ where, match, round, family, ids? }] (ids for 悬赏决策). */
function officialDrafts() {
const out = [];
for (const [match, rounds] of Object.entries(OFFICIAL.matches)) {
for (const [round, d] of Object.entries(rounds)) out.push({ where: `match ${match} R${round}`, round: Number(round), family: d.family, ids: d.family === 'bounty' ? d.cards.map(resolve) : null });
for (const [round, d] of Object.entries(rounds)) out.push({ where: `match ${match} R${round}`, match: Number(match), round: Number(round), family: d.family, ids: d.family === 'bounty' ? d.cards.map(resolve) : null });
}
for (const [key, d] of Object.entries(OFFICIAL.extra)) out.push({ where: key, round: 9, family: d.family, ids: d.cards.map(resolve) });
for (const [key, d] of Object.entries(OFFICIAL.extra)) out.push({ where: key, match: key, round: 9, family: d.family, ids: d.cards.map(resolve) });
return out;
}
const tiersOf = (ids) => ids.map((id) => CARD.get(id).tier).sort().join('');
/** The rule of the unseen R3 slots (and of the seen mixed sets): one card from each of 6 of the series, I I I II II III. */
function mixedRule(ids) {
const mx = SPEC.initial.mixed;
const series = ids.map((id) => CARD.get(id).series);
return ids.length === mx.count && ids.every((id) => CARD.get(id).draftPool === 'initial') && new Set(series).size === series.length
&& series.every((s) => mx.series.includes(s)) && tiersOf(ids) === mx.tiers.slice().sort().join('');
}
/** Which official R3 structure a set of ids is: 'set:<i>' (a seen set), 'mixed' (the unseen-slot rule) or null. */
function initialFit(ids) {
const i = SPEC.initial.sets.findIndex((s) => sameSet(s.cards, ids));
if (i >= 0) return `set:${i}`;
return mixedRule(ids) ? 'mixed' : null;
}
/** The R9 groups a set of ids fits (its featured bosses all there, the rest from the group's fill). */
const bossFits = (ids) => SPEC.boss.templates.map((t, i) => [t, i]).filter(([t]) => ids.length === SPEC.boss.count
&& t.featured.every((id) => ids.includes(id)) && ids.filter((id) => !t.featured.includes(id)).every((id) => t.fill.includes(id))).map(([, i]) => i);
/** The group of `spec` whose `seen` lists the draft's match. */
const groupOf = (spec, d) => spec.groups.filter((g) => g.seen.includes(d.match));
/** At most one card per faction series 10–15 (every official R11 draft). */
function onePerSeries(ids) {
const series = ids.map((id) => CARD.get(id).series).filter((s) => SPEC.hunter.onePerSeries.includes(s));
const series = ids.map((id) => CARD.get(id).series).filter((s) => SPEC.hunter.rule.onePerSeries.includes(s));
return new Set(series).size === series.length;
}
/** The seen R11 groups a set of ids fits (the group's giant + tier-II cards all there, the rest from its fill). */
const hunterFits = (ids) => SPEC.hunter.templates.map((t, i) => [t, i]).filter(([t]) => ids.length === SPEC.hunter.count && onePerSeries(ids)
&& t.featured.every((id) => ids.includes(id)) && ids.filter((id) => !t.featured.includes(id)).every((id) => t.fill.includes(id))).map(([, i]) => i);
/** The R11 shape an unseen slot is built by: one tier-III giant, 1–4 tier-II group cards, tier-I fill, one per faction series. */
function hunterShape(ids) {
const r = SPEC.hunter.rule;
const giants = ids.filter((id) => r.giants.includes(id)).length;
const group = ids.filter((id) => r.groupCards.includes(id)).length;
const fill = ids.filter((id) => r.fill.includes(id)).length;
return ids.length === SPEC.hunter.count && giants === 1 && r.groupSizes.includes(group) && giants + group + fill === ids.length && onePerSeries(ids);
const giantsIn = (ids) => ids.filter((id) => SPEC.hunter.rule.giants.includes(id)).length;
/** The weights the data gives a group's cards: 1 + the official drafts of that group the card appeared in. */
function expectedWeights(g, drafts) {
return g.cards.map((id) => 1 + drafts.filter((d) => d.ids.includes(id)).length);
}
/** Bounty drafts generated for `modeId` at `round` over `seeds` seeds. */
@@ -91,13 +76,23 @@ function drafts(modeId, round, seeds, data = DATA) {
return out;
}
/** An unseen-slot R3 set (`rule`): two-battle cards of its series, I I I II II III, ≤ perSeries a series, the III preferred. */
function initialRule(ids) {
const r = SPEC.initial.rule;
const series = ids.map((id) => CARD.get(id).series);
return ids.length === 6 && ids.every((id) => CARD.get(id).draftPool === 'initial' && r.series.includes(CARD.get(id).series))
&& series.every((s) => series.filter((x) => x === s).length <= r.perSeries) && tiersOf(ids) === r.tiers.slice().sort().join('')
&& ids.some((id) => r.prefer.includes(id));
}
test('#2 screenshots → data: every card read is one act2autochess (下半) effect, whose enemy_id is the enemy the card brings', (t) => {
const seen = new Set();
for (const d of officialDrafts()) for (const id of d.ids || []) seen.add(id);
assert.equal(seen.size, 72, 'distinct cards in the 27 bounty drafts: 36 at R3, 20 at R9, 16 at R11');
assert.equal(seen.size, 81, 'distinct cards in the 59 bounty drafts: 40 at R3, 20 at R9, 21 at R11');
// the R3(1) question: the title names category and tier only — two different 悬赏·损伤I cards, each with its own enemy
assert.equal(resolve(['悬赏·损伤I', '临时收音师', 1, 2]), 'enemyeffect_18_1');
assert.equal(resolve(['悬赏·损伤I', '底海滑动者', 1, 2]), 'enemyeffect_12_4');
assert.equal(resolve(['悬赏·持续I', '萨卡兹枯朽战士', 1, 2]), 'enemyeffect_20_1', 'not 萨卡兹枯朽战士组长 (13_4)');
assert.equal(CARD.get('enemyeffect_18_1').enemyKey, 'enemy_10094_crstf');
assert.equal(CARD.get('enemyeffect_12_4').enemyKey, 'enemy_1148_dssbr');
const p = new URL('../../.cache/gamedata/excel/activity_table.json', import.meta.url);
@@ -112,40 +107,47 @@ test('#2 screenshots → data: every card read is one act2autochess (下半) eff
assert.ok(seen.has(id) && !act.act1autochess.effectInfoDataDict[id], `${id} is 下半-only`);
}
assert.match(act.act1autochess.effectInfoDataDict.enemyeffect_11_6.effectDesc, /沉沙/);
// all 22 matches are one season: 终极 (mode_multi_abyss) opened on 2026-03-27 16:00 (UTC+8), before the end-of-March videos
assert.equal(act.act2autochess.modeDataDict.mode_multi_abyss.startTime, Date.UTC(2026, 2, 27, 8) / 1000);
});
test('#2 R3 (11 of 11 official drafts): one of the official sets of six "接下来两场作战" cards, 3 × I + 2 × II + 1 × III', () => {
test('#2 R3 (22 of 22 official drafts): one of the official sets of six "接下来两场作战" cards, 3 × I + 2 × II + 1 × III — 9 of the 10 seen', () => {
const r3 = officialDrafts().filter((d) => d.round === 3);
assert.equal(r3.length, 11);
const hits = SPEC.initial.sets.map(() => 0);
assert.equal(r3.length, 22);
const hits = SPEC.initial.groups.map(() => []);
for (const d of r3) {
assert.equal(d.family, 'bounty', `${d.where}: R3 is a 悬赏决策`);
assert.ok(d.ids.every((id) => CARD.get(id).rounds === 2 && CARD.get(id).draftPool === 'initial'), `${d.where}: two-battle cards`);
assert.equal(tiersOf(d.ids), '111223', `${d.where}: tiers`);
assert.ok(d.ids.every((id) => !isBoss(CARD.get(id).enemyKey)), `${d.where}: no boss`);
const fit = initialFit(d.ids);
assert.match(fit || '', /^set:/, `${d.where}: a seen set`);
hits[Number(fit.slice(4))]++;
const i = SPEC.initial.groups.findIndex((s) => sameSet(s.cards, d.ids));
assert.ok(i >= 0, `${d.where}: a seen set`);
hits[i].push(d.match);
}
assert.deepEqual(hits, [4, 1, 1, 2, 1, 1, 1], 'series 18 ×4, 19, 17, the 10_5… set ×2, three more mixed sets');
// each seen set is a whole series 17 / 18 / 19 or follows the rule the unseen slots are built by
for (const s of SPEC.initial.sets) {
const series = new Set(s.cards.map((id) => CARD.get(id).series));
assert.ok(series.size === 1 ? [17, 18, 19].includes([...series][0]) && s.cards.length === 6 : mixedRule(s.cards), s.cards.join());
assert.equal(tiersOf(s.cards), '111223');
}
assert.equal(SPEC.initial.slots, 10, 'enemy_initial_1..10');
assert.deepEqual(hits, SPEC.initial.groups.map((s) => s.seen), 'each set\'s `seen` lists the matches it came in');
assert.deepEqual(hits.map((x) => x.length), [5, 3, 2, 3, 2, 1, 2, 1, 3]);
// hand-made sets, not one card per series: two series-20 cards (match 12), a series-17 card among faction cards (13 / 19 / 21)
assert.deepEqual(SPEC.initial.groups[7].cards.filter((id) => CARD.get(id).series === 20), [EE('20_1'), EE('20_5')]);
assert.ok(SPEC.initial.groups[8].cards.includes(EE('17_6')));
for (const s of SPEC.initial.groups) assert.equal(tiersOf(s.cards), '111223', s.cards.join());
assert.equal(SPEC.initial.slots, 10, 'enemy_initial_1..10: one unseen');
assert.equal(SPEC.initial.pick, 'slot');
// the only two-battle cards no draft showed are the unseen set's preferred tier-III cards
const shown = new Set(r3.flatMap((d) => d.ids));
assert.deepEqual(CARDS.filter((c) => c.draftPool === 'initial' && !shown.has(c.effectId)).map((c) => c.effectId), SPEC.initial.rule.prefer);
assert.ok(SPEC.initial.rule.prefer.every((id) => CARD.get(id).tier === 3));
});
test('#2 R3 generated: a seen set or an unseen-slot set (one card from each of 6 of the series 10–15 / 20, I I I II II III) — every seen set comes up, the unseen slots about 3 in 10', () => {
test('#2 R3 generated: a seen set (all six cards) or the unseen 10th set — every seen set comes up, the unseen one about 1 in 10', () => {
const counts = new Map();
let n = 0;
for (const modeId of COOP_BOUNTY_MODES) {
for (const round of DATA.choices.schedule[modeId].spRounds.filter((r) => bountyDraftKind(r) === 'initial')) {
for (const d of drafts(modeId, round, modeId === 'mode_multi_hard' ? 400 : 60)) {
for (const d of drafts(modeId, round, modeId === 'mode_multi_hard' ? 600 : 60)) {
const ids = d.cards.map((c) => c.id);
assert.equal(new Set(ids).size, 6, 'six different cards');
const fit = initialFit(ids);
const i = SPEC.initial.groups.findIndex((s) => sameSet(s.cards, ids));
const fit = i >= 0 ? `set:${i}` : initialRule(ids) ? 'rule' : null;
assert.ok(fit, `${modeId} R${round}: ${d.cards.map((c) => c.name).join(', ')}`);
assert.ok(d.cards.every((c) => c.rounds === 2 && !isBoss(c.enemyKey)), 'two-battle cards, no boss');
assert.match(d.eventId, /^enemy_initial_\d+$/);
@@ -153,9 +155,9 @@ test('#2 R3 generated: a seen set or an unseen-slot set (one card from each of 6
}
}
}
for (let i = 0; i < SPEC.initial.sets.length; i++) assert.ok(counts.get(`set:${i}`) > 0, `set ${i} offered`);
const mixed = (counts.get('mixed') || 0) / n;
assert.ok(mixed > 0.2 && mixed < 0.4, `unseen-slot sets ${(mixed * 100).toFixed(0)} %`);
for (let i = 0; i < SPEC.initial.groups.length; i++) assert.ok(counts.get(`set:${i}`) > 0, `set ${i} offered`);
const rule = (counts.get('rule') || 0) / n;
assert.ok(rule > 0.05 && rule < 0.16, `unseen-set drafts ${(rule * 100).toFixed(0)} %`);
// solo: 3 cards of such a set
for (const d of drafts('mode_single_hard', 3, 40)) {
assert.equal(d.cards.length, 3);
@@ -163,88 +165,158 @@ test('#2 R3 generated: a seen set or an unseen-slot set (one card from each of 6
}
});
test('#2 R9 (12 of 12 official drafts): boss bounties + 源石虫·特训 — a group of named bosses always together, filled to 6 with the cheap ones', () => {
test('#2 R9 (23 official drafts): six groups (9, 9, 9, 9, 8, 6 cards) of boss bounties / 源石虫·特训, 6 cards a draft — 庞贝 and 鼠王 never together, the 鼠王 group in 14 of 22 matches', () => {
const r9 = officialDrafts().filter((d) => d.round === 9);
assert.equal(r9.length, 12, '11 matches + the Bahamut co-op screenshot');
const hits = SPEC.boss.templates.map(() => 0);
assert.equal(r9.length, 23, '22 matches + the Bahamut co-op screenshot');
const B = SPEC.boss;
assert.equal(B.groups.length, B.events.length, 'one group per bossInitial event');
// the groups as they are: 9 cards where three named bosses came; the single-draft groups completed only with the
// base cards they lack — 腐败骑士 9, 泥岩 + 澪 8, match 4 (the base alone) 6 [ASSUMED]
assert.deepEqual(B.groups.map((g) => g.cards.length), [9, 9, 9, 9, 8, 6]);
const base = B.groups.find((g) => g.seen.includes(4));
assert.deepEqual(base.seen, [4]);
assert.ok(sameSet(base.cards, ['b_10', 'b_1', 'b_3', 'b_13', 'b_12', '5_1'].map(EE)), 'match 4: W 碎骨 弑君者 大鲍勃 庞贝 源石虫');
// …which is also six cards of the 喷气人 and 泥岩 groups: match 4 may be a draft of one of them (open question)
for (const boss of ['b_8', 'b_4']) assert.ok(subset(base.cards, B.groups.find((g) => g.cards.includes(EE(boss))).cards));
for (const d of r9) {
assert.equal(d.family, 'bounty', `${d.where}: R9 is a 悬赏决策`);
assert.ok(d.ids.every((id) => CARD.get(id).draftPool === 'boss' && CARD.get(id).rounds === 1), `${d.where}: boss bounties / 源石虫·特训, 下场作战`);
const fits = bossFits(d.ids);
assert.equal(fits.length, 1, `${d.where}: fits exactly one seen group (${fits.join(', ')})`);
hits[fits[0]]++;
const g = groupOf(B, d);
assert.equal(g.length, 1, `${d.where}: listed by one group`);
assert.ok(subset(d.ids, g[0].cards), `${d.where}: six cards of its group`);
assert.ok(!(d.ids.includes(EE('b_12')) && d.ids.includes(EE('b_11'))), `${d.where}: 庞贝 and 鼠王 never together`);
}
assert.ok(hits.every((x) => x > 0), `every group seen (${hits.join(' ')})`);
for (const d of drafts('mode_multi_abyss', 9, 300)) {
for (const g of B.groups) {
const ds = r9.filter((d) => g.seen.includes(d.match));
assert.equal(ds.length, g.seen.length, `${g.cards.join(' ')}: every match it lists`);
assert.deepEqual(g.weights, expectedWeights(g, ds), `${g.cards.join(' ')}: weights = 1 + the drafts each card showed in`);
// every group seen in 3 or more drafts shows exactly 9 different cards — the list
if (ds.length >= 3) assert.ok(sameSet([...new Set(ds.flatMap((d) => d.ids))], g.cards) && g.cards.length === 9, `${g.cards.join(' ')}: the 9 cards its drafts show`);
}
// a named boss does not always come with its partners: 杰斯顿 alone (12, 15, 21); 复仇者 + 百夫长 without 邪魔的利刃 (20)
const named = (d) => d.ids.filter((id) => ['b_9', 'b_14', 'b_23'].map(EE).includes(id));
for (const m of [12, 15, 21]) assert.deepEqual(named(r9.find((d) => d.match === m)), [EE('b_9')], `match ${m}`);
const m20 = r9.find((d) => d.match === 20).ids;
assert.ok(m20.includes(EE('b_21')) && m20.includes(EE('b_2')) && !m20.includes(EE('b_22')));
// the 鼠王 group: 14 of 22 matches, every match on the dark grey board (7 of 7) — something of the match picks the group
const rat = B.groups.find((g) => g.cards.includes(EE('b_11')));
assert.equal(rat.seen.length, 14);
for (const m of OFFICIAL.boards.grey) assert.ok(rat.seen.includes(m), `match ${m} (grey board): the 鼠王 group`);
assert.equal(B.pick, 'seen');
// generated: six different cards of one group; the 鼠王 group about 14 / 22; every card of every group offered
const offered = new Set();
let ratN = 0;
const gen = drafts('mode_multi_abyss', 9, 1500);
for (const d of gen) {
const ids = d.cards.map((c) => c.id);
assert.equal(new Set(ids).size, 6);
assert.ok(bossFits(ids).length > 0, ids.join(', '));
assert.ok(B.groups.some((g) => subset(ids, g.cards)), ids.join(', '));
assert.ok(!(ids.includes(EE('b_12')) && ids.includes(EE('b_11'))), '庞贝 and 鼠王 never together');
assert.match(d.eventId, /^bossInitial_\d+$/);
if (ids.some((id) => ['b_11', 'b_9', 'b_14', 'b_23'].map(EE).includes(id)) && subset(ids, rat.cards)) ratN++;
ids.forEach((id) => offered.add(id));
}
const offered = new Set(drafts('mode_multi_hard', 9, 300).flatMap((d) => d.cards.map((c) => c.id)));
for (const t of SPEC.boss.templates) for (const id of t.featured) assert.ok(offered.has(id), `${CARD.get(id).name} offered`);
assert.ok(ratN / gen.length > 0.55 && ratN / gen.length < 0.73, `the 鼠王 group ${(100 * ratN / gen.length).toFixed(0)} %`);
for (const g of B.groups) for (const id of g.cards) assert.ok(offered.has(id), `${CARD.get(id).name} offered`);
for (const id of ['enemyeffect_b_6', 'enemyeffect_b_7', 'enemyeffect_b_15', 'enemyeffect_b_16', 'enemyeffect_b_18']) {
assert.equal(CARD.get(id).draftExcluded, 'unseen', `${CARD.get(id).name}: in no official R9 draft`);
assert.ok(!offered.has(id));
}
});
test('#2 R11 (11 matches: 悬赏决策 5, 机密商店 4, 战术决策 2, 道具补给 0): the families, and the bounty — a group (one 特异III giant + its tier-II cards, always together) + tier-I fill, one card per faction series', () => {
test('#2 R11 (22 matches: 悬赏决策 14, 机密商店 4, 战术决策 4, 道具补给 0): the families, and the bounty — 6 of a list of 7 "下场战斗" cards, one per faction series', () => {
const r11 = officialDrafts().filter((d) => d.round === 11);
const fam = {};
for (const d of r11) fam[d.family] = (fam[d.family] || 0) + 1;
assert.deepEqual(fam, { bounty: 5, shop: 4, tactic: 2 });
const hits = SPEC.hunter.templates.map(() => 0);
const byMatch = {};
for (const d of r11.filter((x) => x.family === 'bounty')) {
assert.deepEqual(fam, { bounty: 14, shop: 4, tactic: 4 });
const H = SPEC.hunter;
const bounty = r11.filter((x) => x.family === 'bounty');
for (const d of bounty) {
assert.ok(d.ids.every((id) => CARD.get(id).draftPool === 'hunter' && CARD.get(id).rounds === 1), `${d.where}: R11 cards, 下场战斗`);
assert.ok(hunterShape(d.ids), `${d.where}: the shape the unseen slots are built by (${tiersOf(d.ids)})`);
const fits = hunterFits(d.ids);
assert.equal(fits.length, 1, `${d.where}: fits exactly one seen group (${fits.join(', ')})`);
hits[fits[0]]++;
byMatch[d.where.split(' ')[1]] = d.ids;
assert.ok(onePerSeries(d.ids), `${d.where}: one card per faction series`);
assert.ok(giantsIn(d.ids) <= 1, `${d.where}: at most one giant`);
const g = groupOf(H, d);
assert.equal(g.length, 1, `${d.where}: listed by one group`);
assert.ok(subset(d.ids, g[0].cards), `${d.where}: six cards of its group`);
}
assert.deepEqual(hits, [2, 1, 1, 1], 'the 16_4 group in matches 1 and 6, three more groups once');
// the evidence against a free draw: matches 1 and 6 share the giant and all four tier-II cards, only the tier-I card differs
const shared = byMatch['1'].filter((id) => byMatch['6'].includes(id));
assert.equal(shared.length, 5);
assert.deepEqual(byMatch['1'].filter((id) => !shared.includes(id)).concat(byMatch['6'].filter((id) => !shared.includes(id))).map((id) => CARD.get(id).tier), [1, 1]);
for (const id of ['enemyeffect_10_8', 'enemyeffect_11_8', 'enemyeffect_12_8']) assert.ok(!Object.values(byMatch).flat().includes(id), `${id} in no official R11 draft`);
assert.equal(bounty.filter((d) => giantsIn(d.ids) === 1).length, 13, 'a giant in 13 of 14 (not match 15)');
for (const g of H.groups) {
const ds = bounty.filter((d) => g.seen.includes(d.match));
assert.equal(ds.length, g.seen.length);
assert.equal(g.cards.length + (g.open || 0), H.rule.size, `${g.cards.join(' ')}: a list of 7`);
assert.deepEqual(g.weights, expectedWeights(g, ds), `${g.cards.join(' ')}: weights = 1 + hits`);
assert.ok(sameSet([...new Set(ds.flatMap((d) => d.ids))], g.cards), `${g.cards.join(' ')}: the cards its drafts show`);
assert.ok(onePerSeries(g.cards) && giantsIn(g.cards) <= 1);
if (!giantsIn(g.cards)) assert.equal(g.open, 1, 'a list showing no giant gets one by the rule');
}
// the evidence for lists of 7: the 16_4 group's four drafts each leave out a different one of the same 7 cards
const big = H.groups.find((g) => g.cards.includes(EE('16_4')));
const left = bounty.filter((d) => big.seen.includes(d.match)).map((d) => big.cards.find((id) => !d.ids.includes(id)));
assert.equal(new Set(left).size, 4, left.join(' '));
assert.equal(H.groups.filter((g) => !g.open).length, 3, 'three groups seen whole');
assert.deepEqual(CARDS.filter((c) => c.draftPool === 'hunter' && !bounty.some((d) => d.ids.includes(c.effectId))).map((c) => c.effectId),
['12_8', '16_2', '16_6'].map(EE), 'R11 cards no draft showed');
for (const modeId of ['mode_multi_hard', 'mode_multi_abyss', 'mode_single_hard', 'mode_single_abyss']) {
const sch = DATA.choices.schedule[modeId].rounds['11'];
assert.deepEqual(sch.families.map((f) => f.family).sort(), ['bounty', 'shop', 'tactic'], `${modeId} R11 (solo: extrapolated from co-op, [ASSUMED])`);
assert.deepEqual(sch.families, [{ family: 'bounty', weight: 14 }, { family: 'shop', weight: 4 }, { family: 'tactic', weight: 4 }], `${modeId} R11 (solo: extrapolated from co-op, [ASSUMED])`);
assert.equal(sch.bountyDraft, 'hunter');
assert.equal(sch.assumed, true);
}
assert.equal(SPEC.hunter.slots, 15, 'bounty_hunter_1..15');
// generated: a seen group (about 4 in 15) or a group of the same shape; every seen group and every R11 card comes up
// the list is one of the 7 seen, uniform — no list built from nothing [ASSUMED]: 15 equally likely events would show 7
// lists or fewer in 14 drafts about 6 % of the time; 7, 8 or 9 events show exactly 7 about equally often (37 / 45 / 39 %)
assert.equal(H.events.length, 15, 'bounty_hunter_1..15 in the data');
assert.equal(H.slots, H.groups.length);
assert.equal(H.slots, 7);
assert.equal(H.pick, 'slot');
// the data's block order, which does NOT settle which events R11 fires: bounty_hunter_1..7 after bossInitial_1..6;
// 8..15 after artifact_paid_4 / 5 and right before the 绝境 / 终极-only hardbuff_select (read by blocks, R11 = 8..15)
const p = new URL('../../.cache/gamedata/excel/activity_table.json', import.meta.url);
if (existsSync(p)) {
const keys = Object.keys(JSON.parse(readFileSync(p, 'utf8')).activity.AUTOCHESS_SEASON.act2autochess.effectChoiceInfoDict);
const at = (k) => keys.indexOf(k);
assert.equal(at('bounty_hunter_1'), at('bossInitial_6') + 1, 'bounty_hunter_1..7 right after the R9 events');
for (let i = 2; i <= 7; i++) assert.equal(at(`bounty_hunter_${i}`), at('bounty_hunter_1') + i - 1);
assert.ok(at('artifact_paid_5') > at('bounty_hunter_7') + 1, 'bounty_hunter_8..15 not with 1..7');
for (let i = 8; i <= 15; i++) assert.equal(at(`bounty_hunter_${i}`), at('artifact_paid_5') + i - 7, 'bounty_hunter_8..15 right after artifact_paid_4 / 5');
assert.equal(at('hardbuff_select_1'), at('bounty_hunter_15') + 1);
}
// generated: six different cards, one per faction series, at most one giant, at most the `open` card outside one seen
// list; a whole seen list about 3 in 7; every R11 card comes up (12_8 / 16_2 / 16_6 only as an `open` card, rarely)
const gd = new GameData(DATA, 'mode_multi_hard');
const seen = {};
const groups = SPEC.hunter.templates.map(() => 0);
const offered = new Set();
let bounty = 0;
const never = ['12_8', '16_2', '16_6'].map(EE);
let n = 0;
let fitSeen = 0;
for (let seed = 1; seed <= 1500; seed++) {
let giants = 0;
let unseenCard = 0;
for (let seed = 1; seed <= 2000; seed++) {
const d = generateDraft(gd, createRng(seed * 13 + 11), 11, { stageId: 'act2autochess_m01' });
seen[d.family] = (seen[d.family] || 0) + 1;
if (d.family !== 'bounty') continue;
const ids = d.cards.map((c) => c.id);
assert.equal(new Set(ids).size, 6);
assert.ok(hunterShape(ids), d.cards.map((c) => c.name).join(', '));
assert.ok(ids.every((id) => CARD.get(id).draftPool === 'hunter') && onePerSeries(ids) && giantsIn(ids) <= 1, d.cards.map((c) => c.name).join(', '));
assert.match(d.eventId, /^bounty_hunter_\d+$/);
const fits = hunterFits(ids);
if (fits.length) { fitSeen++; groups[fits[0]]++; }
bounty++;
assert.ok(H.groups.some((g) => ids.filter((id) => !g.cards.includes(id)).length <= (g.open || 0)), `six cards of a seen list (+ its open card): ${d.cards.map((c) => c.name).join(', ')}`);
if (H.groups.some((g) => !g.open && subset(ids, g.cards))) fitSeen++;
if (ids.some((id) => never.includes(id))) unseenCard++;
giants += giantsIn(ids);
n++;
ids.forEach((id) => offered.add(id));
}
assert.deepEqual(Object.keys(seen).sort(), ['bounty', 'shop', 'tactic'], 'no 道具补给 at R11');
assert.ok(seen.bounty > seen.shop && seen.shop > seen.tactic, JSON.stringify(seen));
assert.ok(groups.every((x) => x > 0), `every seen group offered (${groups.join(' ')})`);
assert.ok(fitSeen / bounty > 0.17 && fitSeen / bounty < 0.4, `seen groups ${(100 * fitSeen / bounty).toFixed(0)} % of ${bounty}`);
assert.ok(seen.bounty / 2000 > 0.56 && seen.bounty / 2000 < 0.71, JSON.stringify(seen));
assert.ok(fitSeen / n > 0.36 && fitSeen / n < 0.5, `drafts of the three whole seen groups ${(100 * fitSeen / n).toFixed(0)} % (3 / 7)`);
// a card no official draft showed: rare enough that 14 drafts without one (the official sample) stays likely
const q = unseenCard / n;
assert.ok(q > 0.02 && q < 0.1, `12_8 / 16_2 / 16_6 in ${(100 * q).toFixed(1)} % of R11 bounty drafts`);
assert.ok((1 - q) ** 14 > 0.25, `14 drafts without one: ${(100 * (1 - q) ** 14).toFixed(0)} %`);
assert.ok(giants / n > 0.78 && giants / n < 0.93, `a giant in ${(100 * giants / n).toFixed(0)} % (official 13 / 14)`);
for (const c of CARDS.filter((x) => x.draftPool === 'hunter')) assert.ok(offered.has(c.effectId), `${c.effectId} ${c.name} offered at R11`);
});
test('#2 never offered by a draft (in none of the 27 official bounty drafts): multi-round cards, enemyeffect_3_*, 战术特训, the 鸭爵 set', () => {
test('#2 never offered by a draft (in none of the 59 official bounty drafts): multi-round cards, enemyeffect_3_*, 战术特训, the 鸭爵 set', () => {
const reasons = {};
for (const c of CARDS) {
if (!c.draft) reasons[c.draftExcluded] = (reasons[c.draftExcluded] || 0) + 1;
@@ -288,7 +360,8 @@ test('#2 E2E (co-op 绝境, the players\' case): the real R3 draft is an officia
assert.ok(h.drive(() => m.phase === PHASE.SP_DRAFT && m.round === 3, { ready: true }), 'the R3 draft');
assert.equal(m.sp.family, 'bounty', 'R3 of 绝境 is always a bounty draft');
const names = m.sp.cards.map((c) => c.name).join(', ');
assert.ok(initialFit(m.sp.cards.map((c) => c.id)), `seed ${seed}: ${names}`);
const ids = m.sp.cards.map((c) => c.id);
assert.ok(SPEC.initial.groups.some((g) => sameSet(g.cards, ids)) || initialRule(ids), `seed ${seed}: ${names}`);
for (const c of m.sp.cards) assert.ok(!isBoss(c.enemyKey), `seed ${seed}: ${c.name} is a boss bounty`);
assert.equal(m.publicView().sp.cards.length, m.sp.cards.length);
// the human takes a card when its turn comes; the bot picks by itself
+142
View File
@@ -0,0 +1,142 @@
// The 机密商店 after the official screenshots (the user, after the R3 / R9 / R11 screenshots of 22 official matches:
// "机密商店按官方改成可以重复吧"). The 4 official 机密商店 (R11 of matches 2, 4, 5 and 8 in
// test/fixtures/official-bounty-drafts.json) offer the same item twice (盟约之币 ×2 in 4 and 5, 变形同构体 ×2 in 8) and
// share one composition: exactly two tier-VI items, at least one tier V, at least one 盟约之币, no other tier-I / II item.
// choices.json `shopDraft` (tools/build-data.mjs SHOP_DRAFT) draws six slots on their own, with replacement, at R11 (the
// round of the screenshots; 绝境 / 终极 only); the earlier 机密商店 of 标准 / 险境 (no screenshot) keep the previous draw,
// any tier I–VI with replacement. Picking one of two identical cards is in test/content/choices.test.js (E2E), the
// overlay with two identical cards in test/ui/feedback1-secret-shop.test.js.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { DATA } from './harness.js';
import { GameData } from '../../server/match/gamedata.js';
import { generateDraft, cardView } from '../../server/match/choices.js';
import { createRng } from '../../server/sim/rng.js';
const OFFICIAL = JSON.parse(readFileSync(new URL('../fixtures/official-bounty-drafts.json', import.meta.url), 'utf8'));
const SHOP = DATA.choices.shopDraft;
const COIN = SHOP.coin;
const itemByName = (name) => Object.values(DATA.items).find((i) => i.name === name && !i.isGolden && i.itemType === 'EQUIP');
/** The official 机密商店: [{ match, cards: [[name, tier]] }]. */
const SHOPS = Object.entries(OFFICIAL.matches).flatMap(([m, rs]) => Object.values(rs).filter((d) => d.family === 'shop').map((d) => ({ match: Number(m), cards: d.cards })));
const count = (list, pred) => list.filter(pred).length;
test('机密商店 (4 official shops): six free items, two of them tier VI, a tier V and a 盟约之币 in each, the same item twice in three', () => {
assert.deepEqual(SHOPS.map((s) => s.match), [2, 4, 5, 8]);
assert.deepEqual(SHOP.seen, [2, 4, 5, 8]);
let dupShops = 0;
for (const { match, cards } of SHOPS) {
assert.equal(cards.length, 6, `match ${match}`);
for (const [name, tier] of cards) assert.equal(itemByName(name)?.tier, tier, `match ${match}: ${name} is tier ${tier} in the data`);
assert.equal(count(cards, ([, t]) => t === 6), 2, `match ${match}: exactly two tier-VI items`);
assert.ok(count(cards, ([, t]) => t === 5) >= 1, `match ${match}: a tier-V item`);
assert.ok(count(cards, ([n]) => n === '盟约之币') >= 1, `match ${match}: a 盟约之币`);
assert.ok(cards.every(([n, t]) => t >= 3 || n === '盟约之币'), `match ${match}: no other tier-I / II item`);
if (new Set(cards.map(([n]) => n)).size < cards.length) dupShops++;
}
assert.equal(dupShops, 3, '盟约之币 ×2 (matches 4, 5), 变形同构体 ×2 (match 8)');
// the data: four fixed slots (VI, VI, V, 盟约之币) + two drawn by what the other two cards of each shop were
assert.equal(DATA.items[COIN].name, '盟约之币');
assert.deepEqual(SHOP.slots.slice(0, 4), [{ 6: 1 }, { 6: 1 }, { 5: 1 }, { coin: 1 }]);
const rest = { 3: 0, 4: 0, 5: 0, coin: 0 };
for (const { cards } of SHOPS) {
const left = cards.map(([n, t]) => (n === '盟约之币' ? 'coin' : String(t)));
for (const k of ['6', '6', '5', 'coin']) left.splice(left.indexOf(k), 1);
for (const k of left) rest[k]++;
}
assert.deepEqual(rest, { 3: 1, 4: 2, 5: 3, coin: 2 });
for (const s of SHOP.slots.slice(4)) assert.deepEqual(s, rest);
assert.equal(SHOP.slots.length, 6);
assert.deepEqual(SHOP.rounds, [11], 'the composition where the screenshots are: R11 (绝境 / 终极)');
// an item's weight within its tier: 1 + the official cards it showed on
const seen = {};
for (const { cards } of SHOPS) for (const [n] of cards) if (n !== '盟约之币') seen[itemByName(n).id] = (seen[itemByName(n).id] || 0) + 1;
assert.deepEqual(SHOP.itemWeights, Object.fromEntries(Object.entries(seen).map(([id, k]) => [id, 1 + k]).sort()));
assert.equal(SHOP.itemWeights[itemByName('变形同构体').id], 5, '变形同构体 on 4 of the 8 tier-VI cards');
});
test('机密商店 generated (co-op 绝境 R11): the official composition, drawn with replacement — the same item can come twice', () => {
const gd = new GameData(DATA, 'mode_multi_hard');
let shops = 0;
let dup = 0;
let dupItem = 0;
let coins2 = 0;
const offered = new Set();
for (let seed = 1; shops < 2000 && seed < 20000; seed++) {
const d = generateDraft(gd, createRng(seed * 104729 + 7), 11, { stageId: 'act2autochess_m01' });
if (d.family !== 'shop') continue;
shops++;
assert.equal(d.name, '机密商店');
assert.equal(d.cards.length, 6);
const tiers = d.cards.map((c) => DATA.items[c.id].tier);
assert.equal(count(tiers, (t) => t === 6), 2, 'two tier-VI items');
assert.ok(count(tiers, (t) => t === 5) >= 1, 'a tier-V item');
assert.ok(count(d.cards, (c) => c.id === COIN) >= 1, 'a 盟约之币');
assert.ok(d.cards.every((c) => c.id === COIN || DATA.items[c.id].tier >= 3), 'no other tier-I / II item');
assert.deepEqual(d.cards.map((c) => c.idx), [0, 1, 2, 3, 4, 5], 'each card its own index');
for (const c of d.cards) {
assert.equal(c.kind, 'item');
assert.equal(cardView(c).price, 0, '无需消耗资金');
assert.ok(!DATA.items[c.id].isGolden && !DATA.items[c.id].shopExcluded);
offered.add(c.id);
}
const ids = d.cards.map((c) => c.id);
if (new Set(ids).size < ids.length) dup++;
if (new Set(ids.filter((id) => id !== COIN)).size < count(ids, (id) => id !== COIN)) dupItem++;
if (count(ids, (id) => id === COIN) >= 2) coins2++;
}
assert.equal(shops, 2000);
assert.ok(dup / shops > 0.45 && dup / shops < 0.75, `a repeated item in ${(100 * dup / shops).toFixed(0)} % (official 3 of 4)`);
assert.ok(coins2 / shops > 0.35 && coins2 / shops < 0.55, `盟约之币 ×2 in ${(100 * coins2 / shops).toFixed(0)} % (official 2 of 4)`);
assert.ok(dupItem / shops > 0.08, `another item twice in ${(100 * dupItem / shops).toFixed(0)} % (official: 变形同构体 ×2)`);
// every shop item of tiers III–VI can come
for (const t of [3, 4, 5, 6]) for (const id of gd.shopItemsByTier[t]) assert.ok(offered.has(id), `${DATA.items[id].name} (tier ${t}) offered`);
// solo: 3 of the slots
const solo = new GameData(DATA, 'mode_single_hard');
for (let seed = 1, n = 0; n < 30 && seed < 5000; seed++) {
const d = generateDraft(solo, createRng(seed * 31 + 5), 11, {});
if (d.family !== 'shop') continue;
n++;
assert.equal(d.cards.length, 3);
assert.ok(d.cards.every((c) => c.id === COIN || DATA.items[c.id].tier >= 3));
}
});
test('机密商店 outside R11 (标准 R3 / R9, 险境 R3 / R6 / R9 — no screenshot): the previous draw, any tier I–VI per card, with replacement', () => {
// the data's shop events sit in three blocks (artifact_paid_1 by the R3 events, _2 / _3 by the R9 ones, _4 / _5 by
// hardbuff_select), so an early 机密商店 need not look like the R11 one [ASSUMED: keep the previous draw]
const all = new Set([1, 2, 3, 4, 5, 6].flatMap((t) => new GameData(DATA, 'mode_multi_funny').shopItemsByTier[t] || []));
for (const [modeId, rounds] of [['mode_multi_funny', [3, 9]], ['mode_multi_normal', [3, 6, 9]]]) {
assert.deepEqual(DATA.choices.schedule[modeId].spRounds, rounds);
const gd = new GameData(DATA, modeId);
for (const round of rounds) {
let shops = 0;
let dup = 0;
let twoVI = 0;
let lowTier = 0;
const tiers = new Set();
for (let seed = 1; shops < 300 && seed < 40000; seed++) {
const d = generateDraft(gd, createRng(seed * 7717 + round), round, { stageId: 'act2autochess_m01' });
if (d.family !== 'shop') continue;
shops++;
assert.equal(d.cards.length, 6);
assert.deepEqual(d.cards.map((c) => c.idx), [0, 1, 2, 3, 4, 5]);
for (const c of d.cards) {
assert.ok(all.has(c.id), `${modeId} R${round}: ${c.name} is a normal shop item`);
assert.equal(cardView(c).price, 0);
tiers.add(DATA.items[c.id].tier);
}
const ids = d.cards.map((c) => c.id);
if (new Set(ids).size < ids.length) dup++;
if (count(ids, (id) => DATA.items[id].tier === 6) === 2) twoVI++;
if (ids.some((id) => id !== COIN && DATA.items[id].tier <= 2)) lowTier++;
}
assert.equal(shops, 300, `${modeId} R${round}`);
assert.deepEqual([...tiers].sort(), [1, 2, 3, 4, 5, 6], `${modeId} R${round}: every tier`);
assert.ok(twoVI / shops < 0.4, `${modeId} R${round}: two tier-VI items in ${(100 * twoVI / shops).toFixed(0)} % (R11: always)`);
assert.ok(lowTier / shops > 0.5, `${modeId} R${round}: a tier-I / II item besides 盟约之币 in ${(100 * lowTier / shops).toFixed(0)} %`);
assert.ok(dup / shops > 0.12 && dup / shops < 0.42, `${modeId} R${round}: a repeat in ${(100 * dup / shops).toFixed(0)} % (6 of 51, with replacement: about 26 %)`);
}
}
});
+199
View File
@@ -0,0 +1,199 @@
// The 战术决策 after the official screenshots (the user, after the R3 / R9 / R11 screenshots of 22 official matches:
// "战术决策也按官方改成可以重复吧"). The 4 official 战术决策 (R11 of matches 7, 9, 18 and 20 in
// test/fixtures/official-bounty-drafts.json) show six cards each; match 7 offers 补给 twice, the other three six different
// cards. All 24 cards are ally cards (no 排斥 / 责罚 / 裁决 debuff, no 模拟战场演变 terrain card), and 列装 / 财富 / 补给 /
// 整备 / 升华 make half of them. choices.json `tacticDraft` (tools/build-data.mjs TACTIC_DRAFT) draws each card on its own,
// with replacement, at R11 (绝境 / 终极 only, the round of the screenshots) from the ally cards weighted 1 + the official
// cards each showed on; other rounds (标准 / 险境, no screenshot) keep every card, uniform, now with replacement too.
// Picking one of two identical cards through the match: test/content/choices.test.js (E2E); the overlay:
// test/ui/feedback1-tactic.test.js; headless Chrome: test/ui/feedback1-tactic.e2e.test.js (SP_E2E=1).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { DATA, makeMatch } from './harness.js';
import { GameData } from '../../server/match/gamedata.js';
import { generateDraft, cardView, tacticDraftCards } from '../../server/match/choices.js';
import { botPickCard } from '../../server/match/bot.js';
import { createRng } from '../../server/sim/rng.js';
const OFFICIAL = JSON.parse(readFileSync(new URL('../fixtures/official-bounty-drafts.json', import.meta.url), 'utf8'));
const SPEC = DATA.choices.tacticDraft;
const TACTIC = DATA.choices.cards.tactic;
const ALLY = TACTIC.filter((c) => c.kind === 'ally');
const byName = (name) => TACTIC.filter((c) => c.name === name);
/** The official 战术决策: [{ match, names }]. */
const DRAFTS = Object.entries(OFFICIAL.matches).flatMap(([m, rs]) => Object.entries(rs).filter(([, d]) => d.family === 'tactic').map(([r, d]) => ({ match: Number(m), round: Number(r), names: d.cards.map(([n]) => n) })));
const BASIC = ['列装', '财富', '补给', '整备', '升华'];
const repeats = (ids) => ids.length - new Set(ids).size;
const count = (list, pred) => list.filter(pred).length;
test('战术决策 (4 official drafts, R11): six ally cards each, 补给 twice in match 7, half of the cards 列装 / 财富 / 补给 / 整备 / 升华', () => {
assert.deepEqual(DRAFTS.map((d) => [d.match, d.round]), [[7, 11], [9, 11], [18, 11], [20, 11]]);
for (const { match, names } of DRAFTS) {
assert.equal(names.length, 6, `match ${match}`);
for (const n of names) {
assert.equal(byName(n).length, 1, `match ${match}: ${n} is one cards.tactic entry`);
assert.equal(byName(n)[0].kind, 'ally', `match ${match}: ${n} is an ally card`);
}
}
assert.deepEqual(DRAFTS.map((d) => repeats(d.names)), [1, 0, 0, 0], 'one repeat in four drafts');
assert.equal(count(DRAFTS[0].names, (n) => n === '补给'), 2, 'match 7: 补给 ×2');
const all = DRAFTS.flatMap((d) => d.names);
assert.equal(all.length, 24);
assert.equal(new Set(all).size, 14, '14 different cards');
assert.equal(count(all, (n) => BASIC.includes(n)), 12, 'the five basic cards: 12 of 24 (a uniform draw of the 26 ally cards: about 4.6)');
// the data: R11, ally cards, weight 1 + the official cards a card showed on
assert.deepEqual(SPEC.seen, [7, 9, 18, 20]);
assert.deepEqual(SPEC.rounds, [11], 'where the screenshots are: R11 (绝境 / 终极)');
assert.deepEqual(SPEC.kinds, ['ally']);
assert.equal(SPEC.count, 6);
const seen = {};
for (const n of all) seen[byName(n)[0].effectId] = (seen[byName(n)[0].effectId] || 0) + 1;
assert.deepEqual(SPEC.weights, Object.fromEntries(Object.entries(seen).map(([id, k]) => [id, 1 + k])));
assert.equal(SPEC.weights[byName('补给')[0].effectId], 4, '补给 on 3 of the 24 cards');
// open: every official draft holds a 驰援, a 盟誓 and two basic cards (a slot structure?) — the draws stay independent, marked
for (const { match, names } of DRAFTS) {
assert.ok(names.some((n) => n.endsWith('驰援')) && names.some((n) => n.endsWith('的盟誓')), `match ${match}: a 驰援 and a 盟誓`);
assert.ok(count(names, (n) => BASIC.includes(n)) >= 2, `match ${match}: two basic cards`);
}
assert.ok(SPEC.assumed.includes('independent draws (no slot structure)'), 'the independent draws are marked assumed');
// the official header of the event
for (const id of ['buff_select_1', 'hardbuff_select_1']) assert.equal(DATA.choices.events[id].desc, '进行协同调整,做好迎战准备。');
assert.equal(DATA.choices.families.tactic.desc, '进行协同调整,做好迎战准备。');
});
test('战术决策 generated (co-op 绝境 / 终极 R11): six ally cards, each drawn on its own — the same card can come twice', () => {
for (const modeId of ['mode_multi_hard', 'mode_multi_abyss']) {
const gd = new GameData(DATA, modeId);
let drafts = 0;
let dup = 0;
let basic = 0;
const freq = {};
for (let seed = 1; drafts < 2000 && seed < 40000; seed++) {
// act1 m02 is a 绝境 / 终极 stage with two terrain cards (map_m02_1 / 2): none comes at R11
const d = generateDraft(gd, createRng(seed * 7919 + 3), 11, { stageId: 'act1autochess_m02', bondAvailable: () => true });
if (d.family !== 'tactic') continue;
drafts++;
assert.equal(d.name, '战术决策');
assert.equal(d.desc, '进行协同调整,做好迎战准备。');
assert.equal(d.cards.length, 6);
assert.deepEqual(d.cards.map((c) => c.idx), [0, 1, 2, 3, 4, 5], 'each card its own index');
assert.equal(new Set(d.cards).size, 6, 'each card its own object');
for (const c of d.cards) {
assert.equal(c.kind, 'tactic');
assert.equal(c.tacticKind, 'ally', `${modeId}: ${c.name} is an ally card`);
freq[c.id] = (freq[c.id] || 0) + 1;
if (BASIC.includes(c.name)) basic++;
}
if (repeats(d.cards.map((c) => c.id))) dup++;
}
assert.equal(drafts, 2000, modeId);
assert.ok(dup / drafts > 0.4 && dup / drafts < 0.7, `${modeId}: a repeated card in ${(100 * dup / drafts).toFixed(0)} % (official 1 of 4)`);
assert.ok(basic / drafts > 1.6 && basic / drafts < 2.5, `${modeId}: ${(basic / drafts).toFixed(2)} basic cards a draft (official 3; uniform 1.2)`);
for (const c of ALLY) assert.ok(freq[c.effectId] > 0, `${modeId}: ${c.name} can come`);
const of = (name) => freq[byName(name)[0].effectId];
assert.ok(of('补给') > 2.5 * of('自愈'), `${modeId}: 补给 (weight 4) ${of('补给')} vs 自愈 (weight 1) ${of('自愈')}`);
}
});
test('战术决策 seeded (co-op 绝境 R11, seed 30): 补给 twice — two cards with their own index and the same face', () => {
const gd = new GameData(DATA, 'mode_multi_hard');
const d = generateDraft(gd, createRng(30), 11, { stageId: 'act1autochess_m02' });
assert.equal(d.family, 'tactic');
const names = d.cards.map((c) => c.name);
const at = names.flatMap((n, i) => (n === '补给' ? [i] : []));
assert.equal(at.length, 2, `补给 twice: ${names.join(' ')}`);
const [a, b] = at.map((i) => d.cards[i]);
assert.notEqual(a, b);
assert.notEqual(a.idx, b.idx);
const { idx: ia, ...va } = cardView(a);
const { idx: ib, ...vb } = cardView(b);
assert.deepEqual([ia, ib], at);
assert.deepEqual(va, vb, 'the same card face');
assert.deepEqual(va, { kind: 'tactic', id: 'allybuff_select_4', name: '补给', desc: byName('补给')[0].desc, tier: null, team: true, tacticKind: 'ally' });
});
test('战术决策 solo 绝境 R11: three ally cards, a repeat sometimes [ASSUMED: solo extrapolated from co-op]', () => {
const gd = new GameData(DATA, 'mode_single_hard');
let drafts = 0;
let dup = 0;
for (let seed = 1; drafts < 1000 && seed < 40000; seed++) {
const d = generateDraft(gd, createRng(seed * 31 + 5), 11, {});
if (d.family !== 'tactic') continue;
drafts++;
assert.equal(d.cards.length, 3);
assert.ok(d.cards.every((c) => c.tacticKind === 'ally'));
if (repeats(d.cards.map((c) => c.id))) dup++;
}
assert.equal(drafts, 1000);
assert.ok(dup / drafts > 0.05 && dup / drafts < 0.3, `a repeated card in ${(100 * dup / drafts).toFixed(0)} %`);
});
test('战术决策 outside R11 (标准 R3 / R9, 险境 R3 / R6 / R9, 险境 solo R9 — no screenshot): every card uniform, with replacement', () => {
for (const [modeId, rounds, stageId, cards] of [
['mode_multi_funny', [3, 9], 'act1autochess_m01', 6],
['mode_multi_normal', [3, 6, 9], 'act1autochess_m02', 6],
['mode_single_normal', [9], 'act1autochess_m02', 3],
]) {
const gd = new GameData(DATA, modeId);
const stageTerrain = TACTIC.filter((c) => c.kind === 'terrain' && c.stageId === stageId).map((c) => c.effectId);
for (const round of rounds) {
let drafts = 0;
let dup = 0;
const kinds = new Set();
const ids = new Set();
for (let seed = 1; drafts < 600 && seed < 60000; seed++) {
const d = generateDraft(gd, createRng(seed * 104729 + round), round, { stageId });
if (d.family !== 'tactic') continue;
drafts++;
assert.equal(d.cards.length, cards);
assert.deepEqual(d.cards.map((c) => c.idx), [...Array(cards).keys()]);
for (const c of d.cards) {
kinds.add(c.tacticKind);
ids.add(c.id);
if (c.tacticKind === 'terrain') assert.ok(stageTerrain.includes(c.id), `${modeId} R${round}: ${c.name} belongs to ${stageId}`);
}
if (repeats(d.cards.map((c) => c.id))) dup++;
}
assert.equal(drafts, 600, `${modeId} R${round}`);
assert.deepEqual([...kinds].sort(), ['ally', 'enemyDebuff', 'terrain'], `${modeId} R${round}: ally, debuff and the stage's terrain cards`);
for (const id of stageTerrain) assert.ok(ids.has(id), `${modeId} R${round}: ${id}`);
const [lo, hi] = cards === 6 ? [0.2, 0.5] : [0.02, 0.2];
assert.ok(dup / drafts > lo && dup / drafts < hi, `${modeId} R${round}: a repeated card in ${(100 * dup / drafts).toFixed(0)} %`);
}
}
});
test('tacticDraftCards: a draw over one card fills every place; dead-bond cards never come; without tacticDraft every card is uniform', () => {
const gd = new GameData(DATA, 'mode_multi_hard');
// a 驰援 whose bond is dead: never offered, even at R11
const yan = byName('炎盟约驰援')[0].effectId;
for (let s = 1; s <= 300; s++) assert.ok(!tacticDraftCards(gd, createRng(s), 6, { round: 11, bondAvailable: (b) => b !== 'yanShip' }).some((c) => c.id === yan));
// only one card left: six copies of it, six separate objects
const only = { ...DATA, choices: { ...DATA.choices, cards: { ...DATA.choices.cards, tactic: TACTIC.filter((c) => c.name === '升华') } } };
const six = tacticDraftCards(new GameData(only, 'mode_multi_hard'), createRng(1), 6, { round: 11 });
assert.deepEqual(six.map((c) => c.name), Array(6).fill('升华'));
assert.equal(new Set(six).size, 6);
// R11 kinds that leave nothing (a debuff-only pool): every card instead
const debuffs = { ...DATA, choices: { ...DATA.choices, cards: { ...DATA.choices.cards, tactic: TACTIC.filter((c) => c.kind === 'enemyDebuff') } } };
assert.equal(tacticDraftCards(new GameData(debuffs, 'mode_multi_hard'), createRng(2), 6, { round: 11 }).length, 6);
// no tacticDraft (older data): every card, uniform, with replacement, at R11 too
const { tacticDraft, ...rest } = DATA.choices; // eslint-disable-line no-unused-vars
const plain = new GameData({ ...DATA, choices: rest }, 'mode_multi_hard');
const kinds = new Set();
for (let s = 1; s <= 200; s++) for (const c of tacticDraftCards(plain, createRng(s), 6, { round: 11 })) kinds.add(c.tacticKind);
assert.deepEqual([...kinds].sort(), ['ally', 'enemyDebuff']);
});
test('bot: two identical tactic cards are two choices — it takes one index, and the other once the first is gone', () => {
// the merged bot (DESIGN §21.6) scores a tactic card on the player's own bonds, so a real seat; no jitter
const m = makeMatch({ mode: 'solo', difficulty: 'HARD', seed: 7, seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }] }).start().m;
const ps = m.order[0];
m.rngBots = () => 0;
const card = (idx, team) => ({ idx, kind: 'tactic', id: 'allybuff_select_4', name: '补给', team, tacticKind: 'ally' });
const cards = [card(0, true), card(1, false), card(2, true)];
assert.equal(botPickCard(m, ps, cards, [0, 1, 2]), 0, 'the first of the two team cards');
assert.equal(botPickCard(m, ps, cards, [1, 2]), 2, 'the twin, once the first is taken');
assert.equal(botPickCard(m, ps, cards, [1]), 1);
m.dispose();
});
+3 -3
View File
@@ -109,7 +109,7 @@ test('#19 a tier short of different operators: the offer tops up from the tier b
// v2.4.1 drafted 战术特训 (only 法术教鞭 makes those) and the hidden 鸭爵 set, put one or more multi-round "之后 / 后续的每场
// 作战" cards in about half of the co-op R3 drafts and showed every card as plain text, so a "每场" card looked like a
// "下场" one. Now: no 战术特训 / 鸭爵 set (战术特训 is the 教鞭 Art's, test/content/items.test.js); since player feedback
// #2 the drafts follow the 33 official screenshots (test/match/feedback1-bounty.test.js), where no multi-round card
// #2 the drafts follow the 66 official screenshots (test/match/feedback1-bounty.test.js), where no multi-round card
// appears; each card names its battles in the official colours (blue "下场作战" / "两场作战", red "每场"); a 1–2-battle
// bounty stops after its battles; a multi-round card (教鞭's 法术大师A2·多轮战术特训) lasts two battles.
@@ -161,9 +161,9 @@ test('#4 data: cards.bounty marks the draft pool — 战术特训 (教鞭), the
assert.equal(list.filter((c) => c.draft).length, 86);
const multi = list.filter((c) => c.rounds >= 99 && c.payout === 'kill');
assert.equal(multi.length, 7, '多轮悬赏 · 假想敌 ×6 + 山海众头目·多轮悬赏');
assert.ok(multi.every((c) => !c.draft && c.draftExcluded === 'unseen'), 'in none of the 27 official bounty drafts');
assert.ok(multi.every((c) => !c.draft && c.draftExcluded === 'unseen'), 'in none of the 59 official bounty drafts');
const slime = list.find((c) => c.effectId === 'enemyeffect_5_1');
assert.ok(slime.draft && slime.draftPool === 'boss', '源石虫·特训 is an R9 card (8 of the 11 official R9 drafts)');
assert.ok(slime.draft && slime.draftPool === 'boss', '源石虫·特训 is an R9 card (16 of the 23 official R9 drafts)');
});
/** Drive a co-op 绝境 match to its R3 bounty draft; the human takes the first free card `want(card, free)` accepts. */
+83
View File
@@ -0,0 +1,83 @@
// The 机密商店 overlay with the same item twice, in headless Chrome (puppeteer-core + system Chrome) through the mock
// harness (public/dev/game-mock.html ?phase=SP_DRAFT&variant=shop: the official 机密商店 of match 8 R11 — 变形同构体 in
// slots 1 and 4). Opt-in: SP_E2E=1.
//
// SP_E2E=1 node --test test/ui/feedback1-secret-shop.e2e.test.js
//
// Two identical cards render as two cards; a teammate taking the first leaves the second pickable; two taps on the
// second pick it — its own index goes to the server (g.choice), only it shows my avatar. Screenshots:
// test/e2e/out/fb1-secret-shop-*.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
describe('机密商店: the same item twice (mock harness, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
const cards = (page) => page.$$eval('.spov__grid .spcard', (els) => els.map((c) => ({
name: c.querySelector('.spcard__name')?.textContent, taken: c.classList.contains('is-taken'), mine: c.classList.contains('is-mine'),
armed: c.classList.contains('is-armed'), disabled: c.disabled, badge: !!c.querySelector('.spcard__taker'),
})));
for (const [name, w, h, touch] of [['756x366', 756, 366, true], ['1920x1080', 1920, 1080, false]]) {
test(`${name}: two 变形同构体 are two cards; the second stays pickable after the first is taken, and picking it sends its own index`, { timeout: 120000 }, async () => {
const page = await browser.newPage();
await page.setViewport({ width: w, height: h, deviceScaleFactor: 1, isMobile: touch, hasTouch: touch });
const problems = [];
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
await page.goto(`${base}/dev/game-mock.html?shot=1&phase=SP_DRAFT&variant=shop`, { waitUntil: 'networkidle0' });
await page.waitForSelector('.spov__grid .spcard', { timeout: 20000 });
await sleep(600);
// the teammate p4 has taken the first 变形同构体 (slot 0)
await page.evaluate(() => { const S = globalThis.__MOCK__.S(); S.pub.sp = { ...S.pub.sp, picks: { p4: 0 }, turn: 'p1' }; globalThis.__MOCK__.pushPublic(); });
await sleep(300);
let c = await cards(page);
assert.equal(c.length, 6);
assert.equal(c[0].name, '变形同构体');
assert.equal(c[3].name, '变形同构体');
assert.ok(c[0].taken && c[0].badge && c[0].disabled, 'the first twin: taken, with the taker\'s avatar');
assert.ok(!c[3].taken && !c[3].badge && !c[3].disabled, 'the second twin: free');
await page.screenshot({ path: path.join(OUT, `fb1-secret-shop-${name}.png`) });
// two taps on the second twin
const tap = async (i) => { const btn = (await page.$$('.spov__grid .spcard'))[i]; if (touch) await btn.tap(); else await btn.click(); await sleep(250); };
await tap(3);
c = await cards(page);
assert.deepEqual(c.map((x) => x.armed), [false, false, false, true, false, false], 'only the tapped twin is armed');
await page.screenshot({ path: path.join(OUT, `fb1-secret-shop-${name}-armed.png`) });
await tap(3);
const picks = await page.evaluate(() => ({ ...globalThis.__MOCK__.S().pub.sp.picks }));
assert.deepEqual(picks, { p4: 0, p1: 3 }, 'g.choice sent the second twin\'s index');
await sleep(250);
c = await cards(page);
assert.ok(c[3].mine && c[3].badge, 'the second twin is mine');
assert.ok(c[0].taken && !c[0].mine, 'the first twin stays the teammate\'s');
assert.deepEqual(problems, []);
await page.close();
});
}
});
+96
View File
@@ -0,0 +1,96 @@
// The 机密商店 overlay with the same item twice (the user: "机密商店按官方改成可以重复吧"; official match 8 R11: 变形同构体 ×2,
// matches 4 / 5: 盟约之币 ×2). A real server draft (server/match/choices.js generateDraft → cardView), through m.public's
// normalisation (ui/gameLogic.js normalizeSp), rendered by the overlay's pure view (ui/choiceOverlay.js ChoiceView): two
// identical cards are two buttons keyed by their index — selecting one does not select the other, a teammate taking one
// leaves the other pickable, and the pick sends that card's own index. The browser render is
// test/ui/feedback1-secret-shop.e2e.test.js (SP_E2E=1).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
// the browser data store reads the real data files from disk
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
try {
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(body) };
} catch {
return { ok: false, status: 404, json: async () => ({}) };
}
};
const { ChoiceView, cardPickable, spTap, armedCard } = await import('../../public/js/ui/choiceOverlay.js');
const { normalizeSp } = await import('../../public/js/ui/gameLogic.js');
const { DATA } = await import('../match/harness.js');
const { GameData } = await import('../../server/match/gamedata.js');
const { generateDraft, cardView } = await import('../../server/match/choices.js');
const { createRng } = await import('../../server/sim/rng.js');
const { data } = await import('../../public/js/data.js');
/** Every vnode of a preact tree (htm output), depth first. */
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
yield* walk(v.props?.children);
}
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
await data.loadAll('items', 'effects', 'choices', 'assets', 'chess', 'bonds');
/** A generated co-op 绝境 R11 机密商店 with the same non-coin item twice. */
function shopWithTwin() {
const gd = new GameData(DATA, 'mode_multi_hard');
const coin = DATA.choices.shopDraft.coin;
for (let seed = 1; seed < 50000; seed++) {
const d = generateDraft(gd, createRng(seed * 104729 + 7), 11, {});
if (d.family !== 'shop') continue;
const ids = d.cards.map((c) => c.id);
const twin = ids.find((id, i) => id !== coin && ids.indexOf(id) !== i);
if (twin) return { d, twin, at: ids.flatMap((id, i) => (id === twin ? [i] : [])) };
}
throw new Error('no generated shop with a repeated item');
}
test('机密商店 overlay: the same item twice is two cards — own keys, own selection, one taken leaves the other pickable', () => {
const { d, twin, at } = shopWithTwin();
const [a, b] = at;
const players = [{ playerId: 'me', seat: 0, name: 'Me' }, { playerId: 'p2', seat: 1, name: 'P2' }];
const pub = (picks) => normalizeSp({ family: 'shop', name: d.name, desc: d.desc, cards: d.cards.map(cardView), order: ['p2', 'me'], turn: Object.keys(picks).length ? 'me' : 'p2', picks, taken: {} }, players);
const view = (sp, armed = null) => [...walk(ChoiceView({ pub: { players, deadline: 0 }, sp, myId: 'me', solo: false, armed }))].filter((n) => hasClass(n, 'spcard'));
// before any pick: six buttons, keyed by index; the twins have the same name, icon and text
let sp = pub({});
let cards = view(sp);
assert.equal(cards.length, 6);
assert.deepEqual(cards.map((c) => c.key), [0, 1, 2, 3, 4, 5], 'keyed by index, never by item id');
const name = (c) => [...walk(c)].find((n) => hasClass(n, 'spcard__name')).props.children;
assert.equal(name(cards[a]), DATA.items[twin].name);
assert.equal(name(cards[b]), DATA.items[twin].name);
assert.equal(sp.cards[a].id, sp.cards[b].id);
// the teammate takes the first twin: only that card shows the taker; the second twin is still mine to pick
sp = pub({ p2: a });
assert.equal(sp.cards[a].takenBy, 'p2');
assert.equal(sp.cards[b].takenBy, null);
assert.equal(cardPickable(sp, sp.cards[a], { myId: 'me', solo: false }), false);
assert.equal(cardPickable(sp, sp.cards[b], { myId: 'me', solo: false }), true);
cards = view(sp);
assert.ok(hasClass(cards[a], 'is-taken') && !hasClass(cards[b], 'is-taken'));
assert.ok([...walk(cards[a])].some((n) => hasClass(n, 'spcard__taker')) && ![...walk(cards[b])].some((n) => hasClass(n, 'spcard__taker')));
assert.equal(cards[a].props.disabled, true);
assert.equal(cards[b].props.disabled, false);
// two taps on the second twin: it alone is armed, then its own index is sent
let r = spTap(null, b, cardPickable(sp, sp.cards[b], { myId: 'me', solo: false }));
assert.deepEqual(r, { armed: b, pick: null });
assert.equal(armedCard(r.armed, sp, { myId: 'me', solo: false }), b);
cards = view(sp, r.armed);
assert.deepEqual(cards.map((c) => hasClass(c, 'is-armed')), cards.map((_, i) => i === b), 'only the tapped twin is armed');
r = spTap(r.armed, b, true);
assert.deepEqual(r, { armed: null, pick: b });
});
+87
View File
@@ -0,0 +1,87 @@
// The 战术决策 overlay with the same card twice, in headless Chrome (puppeteer-core + system Chrome) through the mock
// harness (public/dev/game-mock.html ?phase=SP_DRAFT&variant=tactic: the official 战术决策 of match 7 R11 — 补给 in
// slots 1 and 2; the user: "战术决策也按官方改成可以重复吧"). Opt-in: SP_E2E=1.
//
// SP_E2E=1 node --test test/ui/feedback1-tactic.e2e.test.js
//
// Two identical cards render as two cards with the same face (name, 全队获得, text) under the official header; a
// teammate taking the first leaves the second pickable; two taps on the second pick it — its own index goes to the
// server (g.choice), only it shows my avatar. Screenshots: test/e2e/out/fb1-tactic-*.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
describe('战术决策: the same card twice (mock harness, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
const cards = (page) => page.$$eval('.spov__grid .spcard', (els) => els.map((c) => ({
name: c.querySelector('.spcard__name')?.textContent, taken: c.classList.contains('is-taken'), mine: c.classList.contains('is-mine'),
armed: c.classList.contains('is-armed'), disabled: c.disabled, badge: !!c.querySelector('.spcard__taker'),
team: !!c.querySelector('.spcard__tag--team'), desc: c.querySelector('.spcard__desc')?.textContent, w: c.getBoundingClientRect().width,
})));
for (const [name, w, h, touch] of [['756x366', 756, 366, true], ['1920x1080', 1920, 1080, false]]) {
test(`${name}: two 补给 are two cards; the second stays pickable after the first is taken, and picking it sends its own index`, { timeout: 120000 }, async () => {
const page = await browser.newPage();
await page.setViewport({ width: w, height: h, deviceScaleFactor: 1, isMobile: touch, hasTouch: touch });
const problems = [];
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
await page.goto(`${base}/dev/game-mock.html?shot=1&phase=SP_DRAFT&variant=tactic`, { waitUntil: 'networkidle0' });
await page.waitForSelector('.spov__grid .spcard', { timeout: 20000 });
await sleep(600);
const head = await page.$eval('.spov__title', (e) => e.textContent);
assert.ok(head.includes('战术决策') && head.includes('进行协同调整,做好迎战准备。'), `the official header: ${head}`);
// the teammate p4 has taken the first 补给 (slot 0)
await page.evaluate(() => { const S = globalThis.__MOCK__.S(); S.pub.sp = { ...S.pub.sp, picks: { p4: 0 }, turn: 'p1' }; globalThis.__MOCK__.pushPublic(); });
await sleep(300);
let c = await cards(page);
assert.equal(c.length, 6);
assert.deepEqual(c.map((x) => x.name), ['补给', '补给', '谢拉格驰援', '列装', '莫斯提马的盟誓', '升华']);
assert.deepEqual([c[0].team, c[0].desc, Math.round(c[0].w)], [c[1].team, c[1].desc, Math.round(c[1].w)], 'the same face');
assert.ok(c[0].team && /2.*次刷新/.test(c[0].desc), `补给: ${c[0].desc}`);
assert.ok(c[0].taken && c[0].badge && c[0].disabled, 'the first twin: taken, with the taker\'s avatar');
assert.ok(!c[1].taken && !c[1].badge && !c[1].disabled, 'the second twin: free');
await page.screenshot({ path: path.join(OUT, `fb1-tactic-${name}.png`) });
// two taps on the second twin
const tap = async (i) => { const btn = (await page.$$('.spov__grid .spcard'))[i]; if (touch) await btn.tap(); else await btn.click(); await sleep(250); };
await tap(1);
c = await cards(page);
assert.deepEqual(c.map((x) => x.armed), [false, true, false, false, false, false], 'only the tapped twin is armed');
await page.screenshot({ path: path.join(OUT, `fb1-tactic-${name}-armed.png`) });
await tap(1);
const picks = await page.evaluate(() => ({ ...globalThis.__MOCK__.S().pub.sp.picks }));
assert.deepEqual(picks, { p4: 0, p1: 1 }, 'g.choice sent the second twin\'s index');
await sleep(250);
c = await cards(page);
assert.ok(c[1].mine && c[1].badge, 'the second twin is mine');
assert.ok(c[0].taken && !c[0].mine, 'the first twin stays the teammate\'s');
assert.deepEqual(problems, []);
await page.close();
});
}
});
+100
View File
@@ -0,0 +1,100 @@
// The 战术决策 overlay with the same card twice (the user: "战术决策也按官方改成可以重复吧"; official match 7 R11: 补给 ×2).
// A real server draft (server/match/choices.js generateDraft → cardView; co-op 绝境 R11, seed 30: 补给 twice), through
// m.public's normalisation (ui/gameLogic.js normalizeSp), rendered by the overlay's pure view (ui/choiceOverlay.js
// ChoiceView): two identical cards are two buttons keyed by their index, with the same face (name, icon, 全队获得, text) —
// selecting one does not select the other, a teammate taking one leaves the other pickable, and the pick sends that
// card's own index. The header is the official one. The browser render is test/ui/feedback1-tactic.e2e.test.js (SP_E2E=1).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
// the browser data store reads the real data files from disk
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
try {
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(body) };
} catch {
return { ok: false, status: 404, json: async () => ({}) };
}
};
const { ChoiceView, cardPickable, spTap, armedCard, resolveSpCard } = await import('../../public/js/ui/choiceOverlay.js');
const { normalizeSp } = await import('../../public/js/ui/gameLogic.js');
const { DATA } = await import('../match/harness.js');
const { GameData } = await import('../../server/match/gamedata.js');
const { generateDraft, cardView } = await import('../../server/match/choices.js');
const { createRng } = await import('../../server/sim/rng.js');
const { data } = await import('../../public/js/data.js');
/** Every vnode of a preact tree (htm output), depth first. */
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
yield* walk(v.props?.children);
}
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
const text = (v) => [...walk(v)].flatMap((n) => (typeof n.props?.children === 'string' ? [n.props.children] : Array.isArray(n.props?.children) ? n.props.children.filter((x) => typeof x === 'string') : [])).join('');
await data.loadAll('items', 'effects', 'choices', 'assets', 'chess', 'bonds');
test('战术决策 overlay: the same card twice is two cards — own keys, same face, own selection, one taken leaves the other pickable', () => {
const d = generateDraft(new GameData(DATA, 'mode_multi_hard'), createRng(30), 11, { stageId: 'act1autochess_m02' });
assert.equal(d.family, 'tactic');
const at = d.cards.flatMap((c, i) => (c.name === '补给' ? [i] : []));
assert.equal(at.length, 2, 'seed 30: 补给 twice');
const [a, b] = at;
const players = [{ playerId: 'me', seat: 0, name: 'Me' }, { playerId: 'p2', seat: 1, name: 'P2' }];
const pub = (picks) => normalizeSp({ family: 'tactic', name: d.name, desc: d.desc, cards: d.cards.map(cardView), order: ['p2', 'me'], turn: Object.keys(picks).length ? 'me' : 'p2', picks, taken: {} }, players);
const view = (sp, armed = null) => [...walk(ChoiceView({ pub: { players, deadline: 0 }, sp, myId: 'me', solo: false, armed }))];
const cardsOf = (nodes) => nodes.filter((n) => hasClass(n, 'spcard'));
// the official header
let sp = pub({});
const all = view(sp);
assert.ok(text(all.find((n) => hasClass(n, 'spov__title'))).includes('战术决策'));
assert.equal(all.find((n) => hasClass(n, 'spov__desc')).props.children.props.text, '进行协同调整,做好迎战准备。');
// six buttons keyed by index; the twins have the same name, icon, team tag and text
let cards = cardsOf(all);
assert.equal(cards.length, 6);
assert.deepEqual(cards.map((c) => c.key), [0, 1, 2, 3, 4, 5], 'keyed by index, never by card id');
const face = (c) => ({ name: [...walk(c)].find((n) => hasClass(n, 'spcard__name')).props.children, team: [...walk(c)].some((n) => hasClass(n, 'spcard__tag--team')), title: c.props.title });
assert.deepEqual(face(cards[a]), face(cards[b]));
assert.equal(face(cards[a]).name, '补给');
assert.equal(face(cards[a]).team, true, '全队获得');
assert.deepEqual(resolveSpCard(sp.cards[a], 'tactic'), resolveSpCard(sp.cards[b], 'tactic'));
assert.equal(sp.cards[a].id, sp.cards[b].id);
assert.notEqual(sp.cards[a].idx, sp.cards[b].idx);
// the teammate takes the first twin: only that card shows the taker; the second twin is still mine to pick
sp = pub({ p2: a });
assert.equal(sp.cards[a].takenBy, 'p2');
assert.equal(sp.cards[b].takenBy, null);
assert.equal(cardPickable(sp, sp.cards[a], { myId: 'me', solo: false }), false);
assert.equal(cardPickable(sp, sp.cards[b], { myId: 'me', solo: false }), true);
cards = cardsOf(view(sp));
assert.ok(hasClass(cards[a], 'is-taken') && !hasClass(cards[b], 'is-taken'));
assert.ok([...walk(cards[a])].some((n) => hasClass(n, 'spcard__taker')) && ![...walk(cards[b])].some((n) => hasClass(n, 'spcard__taker')));
assert.equal(cards[a].props.disabled, true);
assert.equal(cards[b].props.disabled, false);
// two taps on the second twin: it alone is armed, then its own index is sent
let r = spTap(null, b, cardPickable(sp, sp.cards[b], { myId: 'me', solo: false }));
assert.deepEqual(r, { armed: b, pick: null });
assert.equal(armedCard(r.armed, sp, { myId: 'me', solo: false }), b);
cards = cardsOf(view(sp, r.armed));
assert.deepEqual(cards.map((c) => hasClass(c, 'is-armed')), cards.map((_, i) => i === b), 'only the tapped twin is armed');
r = spTap(r.armed, b, true);
assert.deepEqual(r, { armed: null, pick: b });
// after my pick: the first twin the teammate's, the second mine
sp = pub({ p2: a, me: b });
cards = cardsOf(view(sp));
assert.ok(hasClass(cards[b], 'is-mine') && !hasClass(cards[a], 'is-mine'));
assert.ok(cards.every((c) => c.props.disabled), 'nothing left to pick for me');
});
+195 -86
View File
@@ -2400,7 +2400,7 @@ const ROMAN = { I: 1, II: 2, III: 3 };
* "※以下悬赏任务仅由法术教鞭生成"
* 'hidden' 鸭爵 / 高普尼克 / 流泪小子 / 圆仔·悬赏 (enemyeffect_5..8): commented out of the table (the 鸭爵 strategy
* “神秘顾客” swaps those enemies into the waves instead)
* 'unseen' kill bounties no official draft showed (33 screenshots of 11 official matches, player feedback after
* 'unseen' kill bounties no official draft showed (66 screenshots of 22 official matches, player feedback after
* 0.1.0 report #2 — see BOUNTY_INITIAL_SETS below): the 7 multi-round cards ("之后 / 后续的每场作战": 山海众头目·
* 多轮悬赏, 多轮悬赏·假想敌 ×6), the pre-series cards enemyeffect_3_* and the boss bounties of no seen R9 group
* (凋零骑士, “遗弃者”, 锏, 扎罗, 迷路的巨像) [ASSUMED: not offered]
@@ -2421,76 +2421,147 @@ function bountyDraftExclusion(e, main) {
/**
* The official 悬赏决策 drafts (player feedback after 0.1.0, report #2: "本来应该后期出的悬赏的怪物在前期的悬赏就出现了,导致选了
* 打不过"), read from 33 screenshots of 11 official co-op 绝境 / 终极 matches at the start of rounds 3, 9 and 11 (collected
* by the user, 2026-10-03; the readings are test/fixtures/official-bounty-drafts.json). Every card's title, enemy and coin
* value is an act2autochess (下半) effect. A card's enemy is fixed by its effect (blackboard `enemy_id`); the title only
* names the category and tier, so 悬赏·损伤I is 底海滑动者 in enemyeffect_12_4 and 临时收音师 in enemyeffect_18_1. The draft
* of a round is one of three kinds (the event ids are the data's; which event lists which cards is server-side):
* initial R3 (enemy_initial_1..10): six "接下来两场作战" cards, always 3 × I (1 coin) + 2 × II + 1 × III. All 11 R3
* drafts are one of 7 fixed sets (BOUNTY_INITIAL_SETS): a whole series 17 / 18 / 19 (18 in 4 matches) or one
* card from each of 6 of the series 10–15 and 20 (one such set in two matches). The 3 other events of the 10
* are unseen: built by that rule (BOUNTY_INITIAL_MIXED) [ASSUMED].
* boss R9 (bossInitial_1..6): the boss bounties "X·悬赏" and 源石虫·特训, all "下场作战". All 12 drafts (11 matches +
* the Bahamut 12294 co-op screenshot) are a group of 0–3 named bosses that always come together, filled up to 6
* with cheap ones (W / 碎骨 / 弑君者 1, 大鲍勃 2, 庞贝 or 鼠王 2, 源石虫·特训 0): BOUNTY_BOSS_TEMPLATES. 7 groups
* were seen for 6 events (庞贝 and 鼠王 never in one draft; 杰斯顿 only in matches 7–11): two game versions,
* or a group set by something of the match (its leader, its map — the 杰斯顿 groups came on both boards) —
* open; uniform among the seen groups [ASSUMED]. 凋零骑士, “遗弃者”, 锏, 扎罗 and 迷路的巨像 never appeared: not
* offered [ASSUMED].
* hunter R11 (bounty_hunter_1..15; R11 is a 悬赏决策 in 5 of the 11 matches): six "下场战斗" cards, exactly one 特异III
* giant (enemyeffect_16_1..8, 3 coins), the others tier II / I faction _7 / _8 cards and enemyeffect_16_9..12,
* at most one per faction series 10–15 (5 of 5 drafts). Not a free draw within that rule: matches 1 and 6
* show the same giant (16_4) and the same four tier-II cards (16_10 15_7 12_7 13_8) and differ only in their
* one tier-I card (10_7 / 11_7); 15_7 + 13_8 come together in matches 1, 3 and 6; 10_8, 11_8 and 12_8 never
* show (a uniform draw under the rule gives some pair of 5 drafts 5 shared cards in about 2.5 % of samples).
* Like R9, a draft is a group — the giant + 1–4 tier-II cards, always together — filled to 6 with tier-I
* cards: BOUNTY_HUNTER_TEMPLATES, 4 groups seen for the 15 events (fill = the 5 tier-I cards seen); an unseen
* event draws a group of that shape over all the R11 cards (BOUNTY_HUNTER) [ASSUMED: the group / fill split
* by tier, uniform among the 15 events]. The fill cards 16_11 and 13_7 only came in matches 10–11 (the
* 杰斯顿 period of R9) — too few drafts to split the fill by period.
* In none of the 27 bounty drafts: the 7 multi-round cards (山海众头目·多轮悬赏, 多轮悬赏·假想敌 ×6), the pre-series cards
* 打不过"), read from 66 screenshots of 22 official co-op 绝境 / 终极 matches at the start of rounds 3, 9 and 11 (collected
* by the user, 2026-10-03, from videos of this season recorded at the end of March 2026; the readings are
* test/fixtures/official-bounty-drafts.json, `seen` below = its match numbers). Every card's title, enemy and coin value is
* an act2autochess (下半) effect. A card's enemy is fixed by its effect (blackboard `enemy_id`); the title only names the
* category and tier, so 悬赏·损伤I is 底海滑动者 in enemyeffect_12_4 and 临时收音师 in enemyeffect_18_1. The players pick
* in turn from ONE shared draft (a taken card stays greyed with the taker's avatar: match 17 R3, match 21 R9).
*
* One rule covers all three rounds: the draft's event is a fixed list of cards, and the draft shows 6 different cards of
* it (positions shuffled): R3 lists hold 6 cards (all shown), R9 lists up to 9 (every R9 group seen in 3 or more drafts —
* and only those — shows exactly 9 different cards: 鼠王 group, 喷气人 group, 复仇者 group), R11 lists 7 (the three R11 groups
* seen in 2–4 drafts show exactly 7: matches 1 / 6 / 21 / 22 each leave out a different one of the same 7 cards). Within a
* list a card is drawn with weight 1 + the drafts of its group it appeared in (`hits`; a card no draft of the group has
* shown yet, 1) [ASSUMED: smooths the official counts — in the 鼠王 group 鼠王 showed in 4 of 14, 杰斯顿 in 12].
* initial R3 (enemy_initial_1..10): six "接下来两场作战" cards, always 3 × I (1 coin) + 2 × II + 1 × III (22 of 22).
* 9 of the 10 fixed sets seen (BOUNTY_INITIAL_SETS: 18 ×5, 19 / the 10_5 set / the 17_6 set ×3, 17 / two more
* ×2, two ×1 — consistent with a uniform pick among 10). Hand-made: a set can hold two series-20 cards (12) or
* one card of series 17 (13 / 19 / 21). The unseen 10th set: BOUNTY_INITIAL_RULE [ASSUMED] — its III is one of
* the only two two-battle cards no draft showed (法术大师A1 10_6, 鼎沸 20_6).
* boss R9 (bossInitial_1..6): the boss bounties "X·悬赏" and 源石虫·特训 (16 of 22), all "下场作战". Six groups, one
* per event (BOUNTY_BOSS_GROUPS); 庞贝 and 鼠王 are never in one draft. The 鼠王 group (W 碎骨 弑君者 大鲍勃 源石虫
* 鼠王 杰斯顿 “自在” 陷落雪祀) came in 14 of the 22 matches, so the event is not picked uniformly: something of
* the match decides — every match on the dark grey board (7) had it (if independent of the board, 2 %); the
* leader, the map or the difficulty (终极 opened 2026-03-27) are candidates. Picked by the matches each group
* came in [ASSUMED]. A named boss does not always come with its partners (杰斯顿 alone in 12 / 15 / 21, 复仇者
* and 萨卡兹百夫长 without 邪魔的利刃 in 20). The groups hold 9, 9, 9, 9, 8 and 6 cards: the three seen in 3 or
* more drafts show 9 (the 庞贝 / 鼠王 base of 6 + three named bosses); the three single-draft groups are
* completed only with the base cards they lack [ASSUMED] — 腐败骑士 group 9, 泥岩 + 澪 group 8, and match 4
* (the base alone) 6, so that one always shows the same six. Match 4 may instead be a draft of another group
* (most likely the 泥岩 group: 1 in 28 for a uniform 6 of 8), leaving one event unseen — open.
* 凋零骑士, “遗弃者”, 锏, 扎罗 and 迷路的巨像 never appeared: not offered [ASSUMED].
* hunter R11 (R11 is a 悬赏决策 in 14 of the 22 matches): six "下场战斗" cards, the faction _7 / _8 cards and series 16
* (16_1..8 the 特异III giants), at most one per faction series 10–15 (14 of 14). 7 groups seen
* (BOUNTY_HUNTER_GROUPS): three complete (7 cards), four with 6 cards seen and a 7th drawn by the rule (`open`;
* the group of match 15 shows no giant, so its 7th is one). The list is one of these 7, uniform [ASSUMED],
* because that invents no list. The data does not say which of the 15 events bounty_hunter_1..15 R11 fires:
* in effectChoiceInfoDict bounty_hunter_1..7 sit after bossInitial_1..6, and bounty_hunter_8..15 sit after
* artifact_paid_4 / 5 and right before hardbuff_select (the tactic event only 绝境 / 终极 have, the modes
* with an R11) — read by blocks, as SHOP_DRAFT reads the shop events, R11 would be the 8 events 8..15. The
* sample fits 7, 8 or 9 events about equally (14 drafts showing exactly 7 lists: 7 events 37 %, 8 events
* 45 %, 9 events 39 %) but not 15 (7 lists or fewer 6 %); the counts 4 / 2 / 3 / 1 / 2 / 1 / 1 fit a uniform
* pick (χ² = 4, 6 df). With 8 events, a list nobody has seen would come in about 1 R11 bounty draft in 8 —
* open. No list is built from nothing: the R11 cards no draft showed (12_8 异光体孽生者, 16_2 “越长尘”, 16_6
* 高准度伦蒂尼姆城防自行炮) come only as an `open` card (BOUNTY_HUNTER_RULE: one giant for a list without one,
* else tier I / II cards of the free faction series, at most two of 16_9..12), in about 1 R11 bounty draft in
* 15.
* In none of the 59 bounty drafts: the 7 multi-round cards (山海众头目·多轮悬赏, 多轮悬赏·假想敌 ×6), the pre-series cards
* enemyeffect_3_* (法术大师A2·悬赏 …), 战术特训 (法术教鞭 only) and the 鸭爵 set; no card twice in one draft.
*/
const EE = (s) => `enemyeffect_${s}`;
const seriesIds = (n) => [1, 2, 3, 4, 5, 6].map((i) => EE(`${n}_${i}`));
/** The R3 sets seen (`seen` = match numbers of test/fixtures/official-bounty-drafts.json). */
/** The R3 sets seen. */
const BOUNTY_INITIAL_SETS = [
{ cards: seriesIds(18), seen: [1, 3, 5, 9] },
{ cards: seriesIds(19), seen: [2] },
{ cards: seriesIds(17), seen: [7] },
{ cards: ['10_5', '11_4', '12_5', '13_6', '15_4', '20_3'].map(EE), seen: [4, 10] },
{ cards: ['10_4', '11_5', '12_6', '14_4', '15_5', '20_2'].map(EE), seen: [6] },
{ cards: seriesIds(18), seen: [1, 3, 5, 9, 17] },
{ cards: seriesIds(19), seen: [2, 15, 22] },
{ cards: seriesIds(17), seen: [7, 14] },
{ cards: ['10_5', '11_4', '12_5', '13_6', '15_4', '20_3'].map(EE), seen: [4, 10, 16] },
{ cards: ['10_4', '11_5', '12_6', '14_4', '15_5', '20_2'].map(EE), seen: [6, 18] },
{ cards: ['10_4', '11_5', '12_4', '14_6', '15_4', '20_4'].map(EE), seen: [8] },
{ cards: ['10_4', '11_6', '12_4', '13_4', '14_5', '15_5'].map(EE), seen: [11] },
];
/** An unseen R3 set: one card from each of `count` of these series, with these tiers (the seen mixed sets' rule). */
const BOUNTY_INITIAL_MIXED = { series: [10, 11, 12, 13, 14, 15, 20], count: 6, tiers: [1, 1, 1, 2, 2, 3] };
const BOSS_FILL_A = ['b_1', 'b_3', 'b_10', 'b_12', 'b_13', '5_1'].map(EE); // 碎骨 弑君者 W 庞贝 大鲍勃 源石虫·特训
const BOSS_FILL_B = ['b_1', 'b_3', 'b_10', 'b_11', 'b_13', '5_1'].map(EE); // 鼠王 in place of 庞贝
/** The R9 groups seen: `featured` always all on the card row, `fill` drawn to make 6. */
const BOUNTY_BOSS_TEMPLATES = [
{ featured: [], fill: BOSS_FILL_A, seen: [4] },
{ featured: ['b_8', 'b_24'].map(EE), fill: BOSS_FILL_A, seen: [1, 5] }, // 喷气人 纠缠藤蔓
{ featured: ['b_4', 'b_19'].map(EE), fill: BOSS_FILL_A, seen: [2] }, // 泥岩 澪
{ featured: ['b_5', 'b_17', 'b_20'].map(EE), fill: BOSS_FILL_A, seen: [3] }, // 腐败骑士 “墓碑” “巨大的丑东西”
{ featured: ['b_22', 'b_21', 'b_2'].map(EE), fill: BOSS_FILL_A, seen: [6, 'bahamut-12294'] }, // “邪魔的利刃” “复仇者” 萨卡兹百夫长
{ featured: ['b_9', 'b_23'].map(EE), fill: BOSS_FILL_B, seen: [7, 11] }, // 杰斯顿·威廉姆斯 陷落雪祀
{ featured: ['b_9', 'b_14'].map(EE), fill: BOSS_FILL_B, seen: [8, 9, 10] }, // 杰斯顿·威廉姆斯 “自在”
];
const HUNTER_FILL = ['10_7', '11_7', '13_7', '14_7', '16_11'].map(EE); // 法术大师A2 沏虹 假想敌:蚀裂 重弩突袭者 心虚设计师
/** The R11 groups seen: `featured` (the 特异III giant + its tier-II cards) always all on the card row, `fill` (tier I) drawn to make 6. */
const BOUNTY_HUNTER_TEMPLATES = [
{ featured: ['16_4', '16_10', '15_7', '12_7', '13_8'].map(EE), fill: HUNTER_FILL, seen: [1, 6] }, // 温顺的武装大驮兽 异质裂兽·α 深池重甲卫士队长 灼藤 尖端萨卡兹枯朽战车
{ featured: ['16_3', '15_7', '13_8'].map(EE), fill: HUNTER_FILL, seen: [3] }, // 狂暴宿主组长 深池重甲卫士队长 尖端萨卡兹枯朽战车
{ featured: ['16_5', '14_8', '12_7', '15_8'].map(EE), fill: HUNTER_FILL, seen: [10] }, // 萨卡兹骸骨拷打者 家族暗影灭迹人 灼藤 愤怒的守墓石像
{ featured: ['16_7', '16_9'].map(EE), fill: HUNTER_FILL, seen: [11] }, // 乌顶巨角卢鲁 拥霜羽兽
{ cards: ['10_4', '11_6', '12_4', '13_4', '14_5', '15_5'].map(EE), seen: [11, 20] },
{ cards: ['10_4', '11_4', '12_5', '15_6', '20_1', '20_5'].map(EE), seen: [12] },
{ cards: ['11_4', '12_4', '13_5', '14_4', '15_5', '17_6'].map(EE), seen: [13, 19, 21] },
];
/**
* An unseen R11 group (the seen groups' shape): one tier-III giant + `groupSizes` (one drawn: the seen 4 / 2 / 3 / 1)
* tier-II cards, filled with tier-I cards, at most one card per series of `onePerSeries` — over the R11 cards by tier
* (buildChoices). [ASSUMED]
* The unseen R3 set [ASSUMED]: six two-battle cards of these series with the seen sets' tiers, at most `perSeries` of one
* series (the 20_1 / 20_5 set has two), its tier-III card one of `prefer` (the two-battle cards no draft showed).
*/
const BOUNTY_HUNTER = { groupSizes: [1, 2, 3, 4], onePerSeries: [10, 11, 12, 13, 14, 15], count: 6 };
const BOUNTY_INITIAL_RULE = { series: [10, 11, 12, 13, 14, 15, 20], tiers: [1, 1, 1, 2, 2, 3], perSeries: 2, prefer: ['10_6', '20_6'].map(EE) };
// R9 cards by name (enemyeffect_b_N, 源石虫·特训 enemyeffect_5_1)
const R9 = {
W: 'b_10', 碎骨: 'b_1', 弑君者: 'b_3', 大鲍勃: 'b_13', 庞贝: 'b_12', 鼠王: 'b_11', 源石虫: '5_1', 杰斯顿: 'b_9', 自在: 'b_14', 陷落雪祀: 'b_23',
喷气人: 'b_8', 纠缠藤蔓: 'b_24', 澪: 'b_19', 邪魔的利刃: 'b_22', 复仇者: 'b_21', 萨卡兹百夫长: 'b_2', 腐败骑士: 'b_5', 墓碑: 'b_17', 巨大的丑东西: 'b_20', 泥岩: 'b_4',
};
/** A group from [name, hits] pairs (hits = the drafts of the group the card appeared in). */
const r9Group = (pairs, seen) => ({ cards: pairs.map(([n]) => EE(R9[n])), hits: pairs.map(([, h]) => h), seen });
/** The R9 groups (one per bossInitial event), picked by the matches they came in. */
const BOUNTY_BOSS_GROUPS = [
r9Group([['W', 11], ['碎骨', 12], ['弑君者', 11], ['大鲍勃', 11], ['源石虫', 10], ['鼠王', 4], ['杰斯顿', 12], ['自在', 7], ['陷落雪祀', 6]], [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 21]),
r9Group([['W', 3], ['碎骨', 1], ['弑君者', 1], ['大鲍勃', 3], ['庞贝', 1], ['源石虫', 2], ['喷气人', 3], ['纠缠藤蔓', 3], ['澪', 1]], [1, 5, 22]),
r9Group([['W', 3], ['碎骨', 2], ['弑君者', 1], ['庞贝', 2], ['源石虫', 1], ['澪', 1], ['邪魔的利刃', 2], ['复仇者', 3], ['萨卡兹百夫长', 3]], [6, 20, 'bahamut-12294']),
r9Group([['W', 1], ['碎骨', 1], ['弑君者', 0], ['大鲍勃', 0], ['庞贝', 0], ['源石虫', 1], ['腐败骑士', 1], ['墓碑', 1], ['巨大的丑东西', 1]], [3]),
r9Group([['W', 0], ['碎骨', 0], ['弑君者', 1], ['大鲍勃', 1], ['庞贝', 1], ['源石虫', 1], ['泥岩', 1], ['澪', 1]], [2]),
r9Group([['W', 1], ['碎骨', 1], ['弑君者', 1], ['大鲍勃', 1], ['庞贝', 1], ['源石虫', 1]], [4]),
];
/** The R11 groups seen: 7 cards, or 6 + `open` cards drawn by BOUNTY_HUNTER_RULE. */
const hunterGroup = (ids, hits, seen, open = 0) => ({ cards: ids.map(EE), hits, seen, ...(open ? { open } : {}) });
const BOUNTY_HUNTER_GROUPS = [
hunterGroup(['16_4', '16_10', '15_7', '12_7', '13_8', '10_7', '11_7'], [4, 4, 3, 3, 4, 3, 3], [1, 6, 21, 22]), // 温顺的武装大驮兽 …
hunterGroup(['16_3', '15_7', '13_8', '16_9', '14_7', '11_7', '10_7'], [2, 2, 2, 1, 2, 2, 1], [3, 13]), // 狂暴宿主组长 …
hunterGroup(['16_5', '15_8', '12_7', '14_8', '10_7', '16_11', '11_7'], [3, 3, 3, 2, 3, 3, 1], [10, 12, 16]), // 萨卡兹骸骨拷打者 …
hunterGroup(['16_7', '16_9', '14_7', '16_11', '11_7', '13_7'], [1, 1, 1, 1, 1, 1], [11], 1), // 乌顶巨角卢鲁 …
hunterGroup(['16_8', '16_10', '14_8', '10_7', '16_12', '13_7'], [2, 2, 2, 2, 2, 2], [14, 19], 1), // 伊利昂的木驮兽 …
hunterGroup(['16_1', '10_8', '11_8', '15_7', '13_7', '14_7'], [1, 1, 1, 1, 1, 1], [17], 1), // 泥岩巨像 …
hunterGroup(['10_8', '12_7', '13_7', '14_7', '11_7', '16_12'], [1, 1, 1, 1, 1, 1], [15], 1), // no giant seen (枯朽萃聚使徒 …)
];
/**
* The `open` card of a seen R11 list [ASSUMED]: up to `size` cards — one tier-III giant (a list without one gets one) +
* tier I / II cards, at most one per series of `onePerSeries` and `maxSeries16` of 16_9..12 — over the R11 cards by tier
* (buildChoices).
*/
const BOUNTY_HUNTER_RULE = { size: 7, onePerSeries: [10, 11, 12, 13, 14, 15], maxSeries16: 2 };
/**
* The official 机密商店 (R11 of matches 2, 4, 5 and 8; the user: "机密商店按官方改成可以重复吧"): six item cards, free, and
* the same item can be offered twice (盟约之币 ×2 in 4 and 5, 变形同构体 ×2 in 8). Every one of the 4 has exactly two tier-VI
* items, at least one tier V and at least one 盟约之币 — never a tier-I / II item besides 盟约之币; the other two cards came
* out V ×3, IV ×2, III ×1, 盟约之币 ×2. So: six slots, each drawn on its own (with replacement) — VI, VI, V, 盟约之币 and
* twice a pick of V 3 / IV 2 / III 1 / 盟约之币 2 [ASSUMED: the slot split]; an item within its tier with weight 1 + the
* official 机密商店 cards it showed on (`seen`: 变形同构体 on 4 of the 8 tier-VI cards) [ASSUMED]. Solo shows 3 of the 6
* [ASSUMED]. Only at R11 (`rounds`; R11 is 绝境 / 终极 only, the rounds of the screenshots): the 机密商店 of 标准 R3 / R9
* and 险境 R3 / R6 / R9 has no screenshot, and the data's shop events sit in three blocks (artifact_paid_1 by the R3
* events, _2 / _3 by bossInitial / bounty_hunter_1..7, _4 / _5 by bounty_hunter_8..15 and hardbuff_select — the same
* block reading leaves open which hunter events R11 fires, see `hunter` above), so those keep the previous draw —
* any normal shop item of tiers I–VI per card, with replacement (the same item can come twice there too) [ASSUMED].
*/
const SHOP_DRAFT = {
rounds: [11],
slots: [{ 6: 1 }, { 6: 1 }, { 5: 1 }, { coin: 1 }, { 5: 3, 4: 2, 3: 1, coin: 2 }, { 5: 3, 4: 2, 3: 1, coin: 2 }],
coin: '盟约之币',
seen: { 变形同构体: 4, 天师古鼎: 1, 人事部文档: 1, 家族徽章: 1, 铳骑之威: 1, 天马之盔: 2, 双模机械臂: 2, 商业包装方案: 2, 博士投影: 1, 护盾无人机: 1, 寻呼模块: 1, 骑士储蓄罐: 1, 盟约之币: 6 },
matches: [2, 4, 5, 8],
};
/**
* The official 战术决策 (R11 of matches 7, 9, 18 and 20; the user: "战术决策也按官方改成可以重复吧"): six cards, and the same
* card can be offered twice (补给 ×2 in match 7; the other three show six different cards). All 24 cards are ally cards
* (allybuff_select_*: 盟誓, 驰援, 列装 / 财富 / 补给 / 整备 / 升华, 锐利): no 排斥 / 责罚 / 裁决 debuff (four drafts without
* one: about 0.04 % for the previous draw, 6 different cards of the 26 ally + 9 debuff cards) and no 模拟战场演变 terrain
* card. 列装 / 财富 / 补给 / 整备 / 升华 made 12 of the 24 cards (a uniform draw of the 26 ally cards: about 0.07 %), so
* the draw is weighted. At R11 (`rounds`; 绝境 / 终极 only, the round of the screenshots): each card drawn on its own (with
* replacement) from the ally cards (`kinds`), weight 1 + the official cards it showed on (`seen`) [ASSUMED: the weights;
* terrain cards left out — the maps of the 4 matches are unknown]; solo shows 3 [ASSUMED]. Other rounds (标准 / 险境, no
* screenshot) keep every card, uniform, terrain cards only for the match stage — with replacement too [ASSUMED].
* Open: a slot structure like SHOP_DRAFT's. Every one of the 4 drafts has at least one 驰援, at least one 盟誓 and at least
* two of 列装 / 财富 / 补给 / 整备 / 升华; under these independent draws a draft has 驰援 + 盟誓 about 70 % of the time (all
* four: about 24 %) and all three about 41 % (all four: about 3 %). The pattern was spotted after the fact, so it is kept as
* a question rather than slots [ASSUMED: independent draws]; more R11 战术决策 screenshots would settle it.
*/
const TACTIC_DRAFT = {
rounds: [11],
kinds: ['ally'],
seen: { 补给: 3, 列装: 3, 升华: 3, 莫斯提马的盟誓: 2, 银灰的盟誓: 2, 阿戈尔驰援: 2, 财富: 2, 谢拉格驰援: 1, 锐利: 1, 整备: 1, 玛恩纳的盟誓: 1, 斯卡蒂的盟誓: 1, 萨尔贡驰援: 1, 叙拉古驰援: 1 },
matches: [7, 9, 18, 20],
};
/** The kind of a round's 悬赏决策: R3 (and 险境 R6, [ASSUMED]) initial, R9 boss, R11 hunter. */
const bountyDraftOf = (r) => (r < 8 ? 'initial' : r < 11 ? 'boss' : 'hunter');
@@ -2555,8 +2626,8 @@ function buildChoices(ctx, effects, items, chess) {
let draftExcluded = bountyDraftExclusion(e, main);
if (draftExcluded === 'hidden' && !/鸭爵|高普尼克|流泪小子|圆仔/.test(e.name || '')) warn(`bounty ${e.effectId} "${e.name}": expected one of the 鸭爵 set`);
let draftPool = bountyDraftPool(e, main, draftExcluded);
// a boss bounty is offered only through a seen R9 group (BOUNTY_BOSS_TEMPLATES)
if (draftPool === 'boss' && !BOUNTY_BOSS_TEMPLATES.some((t) => t.featured.includes(e.effectId) || t.fill.includes(e.effectId))) draftPool = null;
// a boss bounty is offered only through a seen R9 group (BOUNTY_BOSS_GROUPS)
if (draftPool === 'boss' && !BOUNTY_BOSS_GROUPS.some((g) => g.cards.includes(e.effectId))) draftPool = null;
if (!draftExcluded && !draftPool) draftExcluded = 'unseen';
const sm = /^enemyeffect_(\d+)_\d+$/.exec(e.effectId);
bounty.push({
@@ -2570,16 +2641,21 @@ function buildChoices(ctx, effects, items, chess) {
const bountyById = new Map(bounty.map((c) => [c.effectId, c]));
const checkKind = (ids, kind, what) => { for (const id of ids) if (bountyById.get(id)?.draftPool !== kind) warn(`bounty draft ${what}: ${id} is not a "${kind}" card`); };
for (const s of BOUNTY_INITIAL_SETS) checkKind(s.cards, 'initial', 'R3 set');
for (const t of BOUNTY_BOSS_TEMPLATES) checkKind([...t.featured, ...t.fill], 'boss', 'R9 group');
for (const t of BOUNTY_HUNTER_TEMPLATES) {
checkKind([...t.featured, ...t.fill], 'hunter', 'R11 group');
const tiers = t.featured.map((id) => bountyById.get(id)?.tier);
if (tiers.filter((x) => x === 3).length !== 1 || tiers.some((x) => x !== 3 && x !== 2)) warn(`bounty draft R11 group ${t.featured.join(' ')}: not one giant + tier-II cards`);
if (t.fill.some((id) => bountyById.get(id)?.tier !== 1)) warn(`bounty draft R11 group ${t.featured.join(' ')}: a fill card is not tier I`);
for (const g of BOUNTY_BOSS_GROUPS) checkKind(g.cards, 'boss', 'R9 group');
for (const g of BOUNTY_HUNTER_GROUPS) checkKind(g.cards, 'hunter', 'R11 group');
for (const g of [...BOUNTY_INITIAL_SETS, ...BOUNTY_BOSS_GROUPS, ...BOUNTY_HUNTER_GROUPS]) {
if (new Set(g.cards).size !== g.cards.length) warn(`bounty draft group ${g.cards.join(' ')}: a card twice`);
if (g.hits && g.hits.length !== g.cards.length) warn(`bounty draft group ${g.cards.join(' ')}: hits do not match the cards`);
}
// the unseen R11 groups draw over the R11 cards by tier: III the giants, II the group cards, I the fill
for (const s of BOUNTY_INITIAL_SETS) if (s.cards.map((id) => bountyById.get(id)?.tier).sort().join('') !== '111223') warn(`bounty draft R3 set ${s.cards.join(' ')}: not I I I II II III`);
for (const g of BOUNTY_HUNTER_GROUPS) {
const giants = g.cards.filter((id) => bountyById.get(id)?.tier === 3).length;
if (giants > 1 || g.cards.length + (g.open || 0) !== BOUNTY_HUNTER_RULE.size) warn(`bounty draft R11 group ${g.cards.join(' ')}: not ${BOUNTY_HUNTER_RULE.size} cards with at most one giant`);
}
// the R11 rule draws over the R11 cards by tier: III the giants, II / I the rest
const hunterTier = (t) => bounty.filter((c) => c.draftPool === 'hunter' && c.tier === t).map((c) => c.effectId);
if (hunterTier(1).length + hunterTier(2).length + hunterTier(3).length !== bounty.filter((c) => c.draftPool === 'hunter').length) warn('bounty: an R11 card of no tier I–III');
const withWeights = ({ hits, ...g }) => ({ ...g, weights: hits.map((h) => h + 1) });
// Tactic cards (BUFF_GAIN effects).
const tactic = [];
@@ -2592,6 +2668,13 @@ function buildChoices(ctx, effects, items, chess) {
tactic.push({ effectId: e.effectId, name: e.name, desc: e.desc, kind, stageId, team });
}
tactic.sort((a, b) => naturalCmp(a.effectId, b.effectId));
// the official 战术决策 (TACTIC_DRAFT) names cards of its `kinds` only
const tacticByName = (name) => {
const hits = tactic.filter((c) => c.name === name);
if (hits.length !== 1) warn(`tactic draft card "${name}": ${hits.length} cards of that name`);
if (hits[0] && !TACTIC_DRAFT.kinds.includes(hits[0].kind)) warn(`tactic draft card "${name}" is a ${hits[0].kind} card`);
return hits[0]?.effectId ?? null;
};
const equipNormal = Object.values(items).filter((i) => i.itemType === 'EQUIP' && !i.isGolden);
const itemByName = (name) => equipNormal.find((i) => i.name === name)?.id || (warn(`pool item "${name}" not found`), null);
@@ -2613,11 +2696,11 @@ function buildChoices(ctx, effects, items, chess) {
let families;
if (diff === 'TRAINING') families = [{ family: 'supply', weight: 100 }];
else if (diff === 'HARD' || diff === 'ABYSS') {
// R11 of the 11 official co-op matches (player feedback after 0.1.0 report #2, the screenshots of BOUNTY_INITIAL_SETS):
// 悬赏决策 5, 机密商店 4, 战术决策 2, 道具补给 0 — the weights follow those counts [ASSUMED]. Solo 绝境 / 终极 take the
// R11 of the 22 official co-op matches (player feedback after 0.1.0 report #2, the screenshots of BOUNTY_INITIAL_SETS):
// 悬赏决策 14, 机密商店 4, 战术决策 4, 道具补给 0 — the weights are those counts [ASSUMED]. Solo 绝境 / 终极 take the
// same weights [ASSUMED: no solo screenshot; extrapolated from co-op — the data has solo 悬赏决策 events for R11,
// bounty_hunter_*_s, which the previous supply 50 / shop 50 never used]
families = r === 11 ? [{ family: 'bounty', weight: 45 }, { family: 'shop', weight: 35 }, { family: 'tactic', weight: 20 }] : [{ family: 'bounty', weight: 100 }];
families = r === 11 ? [{ family: 'bounty', weight: 14 }, { family: 'shop', weight: 4 }, { family: 'tactic', weight: 4 }] : [{ family: 'bounty', weight: 100 }];
} else if (diff === 'NORMAL') {
families = solo
? [{ family: r === 9 ? 'tactic' : 'supply', weight: 100 }]
@@ -2643,10 +2726,11 @@ function buildChoices(ctx, effects, items, chess) {
return {
events,
families: {
bounty: { name: '悬赏决策', desc: '选定悬赏目标,获取额外奖励。', cards: 'bountyDrafts[schedule[*].bountyDraft] — the official draft structure of the round (initial: one of the R3 sets; boss: an R9 group + cheap fill; hunter: an R11 group — one 特异III giant + tier-II cards — + tier-I fill, one per faction series), over cards.bounty entries with draft: true and that draftPool' },
bounty: { name: '悬赏决策', desc: '选定悬赏目标,获取额外奖励。', cards: 'bountyDrafts[schedule[*].bountyDraft] — one official card list of the round (initial: an R3 set of 6; boss: an R9 group of up to 9; hunter: one of the 7 seen R11 lists of 7), six different cards of it drawn by weight, over cards.bounty entries with draft: true and that draftPool' },
supply: { name: '道具补给', desc: '无需消耗资金,获得装备补给。', cards: 'random normal EQUIP items in schedule[*].supplyTiers (duplicates allowed)' },
shop: { name: '机密商店', desc: '无需消耗资金,获得装备补给。', cards: 'random normal EQUIP items of any tier I–VI (duplicates allowed)' },
tactic: { name: '战术决策', desc: '选择战术增益。', cards: 'cards.tactic (terrain cards only for the match stage)' },
shop: { name: '机密商店', desc: '无需消耗资金,获得装备补给。', cards: 'at shopDraft.rounds (R11): six slots drawn with replacement (VI, VI, V, 盟约之币, 2 × V / IV / III / 盟约之币); other rounds: random normal EQUIP shop items of tiers I–VI (duplicates allowed) — the same item can come twice' },
// desc = the official header (effectChoiceInfoDict buff_select_* / hardbuff_select_* "进行协同调整,做好迎战准备。")
tactic: { name: '战术决策', desc: '进行协同调整,做好迎战准备。', cards: 'cards.tactic, each card drawn on its own (with replacement) — the same card can come twice; at tacticDraft.rounds (R11) the ally cards by tacticDraft.weights, other rounds every card uniform (terrain cards only for the match stage)' },
},
format: {
multi: { cards: 6, pickOrder: 'random', firstPickSec: 30, otherPickSec: 16, onTimeout: 'autoPickRandom', eachPlayerPicks: 1 },
@@ -2655,17 +2739,42 @@ function buildChoices(ctx, effects, items, chess) {
},
cards: { bounty, tactic },
// the official 悬赏决策 structures by kind (tools/build-data.mjs BOUNTY_INITIAL_SETS; `seen` = the match numbers of
// test/fixtures/official-bounty-drafts.json; `slots` = the official events, the unseen ones built by `mixed` / `rule`)
// test/fixtures/official-bounty-drafts.json; `slots` = the official events, the unseen ones built by `rule`)
bountyDrafts: {
initial: { events: eventsOf('bounty', (e) => bountyEventRe.initial.test(e.id) && !e.solo), slots: eventsOf('bounty', (e) => bountyEventRe.initial.test(e.id) && !e.solo).length, sets: BOUNTY_INITIAL_SETS, mixed: BOUNTY_INITIAL_MIXED, count: 6, assumed: ['the unseen slots built by `mixed`', '险境 R6 drafts like R3'] },
boss: { events: eventsOf('bounty', (e) => bountyEventRe.boss.test(e.id) && !e.solo), templates: BOUNTY_BOSS_TEMPLATES, count: 6, assumed: ['a uniform pick among the seen groups', 'boss bounties of no seen group are not offered'] },
hunter: {
events: eventsOf('bounty', (e) => bountyEventRe.hunter.test(e.id) && !e.solo), slots: eventsOf('bounty', (e) => bountyEventRe.hunter.test(e.id) && !e.solo).length,
templates: BOUNTY_HUNTER_TEMPLATES,
rule: { giants: hunterTier(3), groupCards: hunterTier(2), groupSizes: BOUNTY_HUNTER.groupSizes, fill: hunterTier(1) },
onePerSeries: BOUNTY_HUNTER.onePerSeries, count: BOUNTY_HUNTER.count,
assumed: ['a group (the giant + tier-II cards) + tier-I fill, like R9', 'the unseen slots built by `rule`', 'a uniform pick among the 15 events'],
initial: {
events: eventsOf('bounty', (e) => bountyEventRe.initial.test(e.id) && !e.solo), slots: eventsOf('bounty', (e) => bountyEventRe.initial.test(e.id) && !e.solo).length,
pick: 'slot', groups: BOUNTY_INITIAL_SETS, rule: BOUNTY_INITIAL_RULE, count: 6,
assumed: ['a uniform pick among the 10 events', 'the unseen 10th set built by `rule`', '险境 R6 drafts like R3'],
},
boss: {
events: eventsOf('bounty', (e) => bountyEventRe.boss.test(e.id) && !e.solo), slots: BOUNTY_BOSS_GROUPS.length,
pick: 'seen', groups: BOUNTY_BOSS_GROUPS.map(withWeights), count: 6,
assumed: ['a group picked by the matches it came in (the per-match cause is open)', 'card weights 1 + hits', 'the single-draft groups completed with their base cards', 'boss bounties of no seen group are not offered'],
},
hunter: {
events: eventsOf('bounty', (e) => bountyEventRe.hunter.test(e.id) && !e.solo), slots: BOUNTY_HUNTER_GROUPS.length,
// one of the 7 seen lists, uniform [ASSUMED]: `slots` 7, so no list is built from nothing (which of the 15 events
// R11 fires is open — by the data's blocks it would be 8..15); `rule` builds the `open` cards only
pick: 'slot', groups: BOUNTY_HUNTER_GROUPS.map(withWeights),
rule: { ...BOUNTY_HUNTER_RULE, giants: hunterTier(3), cards: [...hunterTier(2), ...hunterTier(1)] }, count: 6,
assumed: ['one of the 7 seen lists, picked uniformly (which of the 15 events R11 fires is open)', 'card weights 1 + hits', 'the `open` cards built by `rule`'],
},
},
// the official 机密商店 (SHOP_DRAFT) at `rounds`: `slots` tier → weight (`coin` = 盟约之币), each drawn on its own;
// `itemWeights` = 1 + the official cards an item showed on (other shop items 1)
shopDraft: {
rounds: SHOP_DRAFT.rounds, slots: SHOP_DRAFT.slots, coin: itemByName(SHOP_DRAFT.coin),
itemWeights: Object.fromEntries(Object.entries(SHOP_DRAFT.seen).filter(([n]) => n !== SHOP_DRAFT.coin).map(([n, k]) => [itemByName(n), 1 + k]).sort((a, b) => naturalCmp(a[0], b[0]))),
seen: SHOP_DRAFT.matches, count: 6,
assumed: ['the slot split', 'item weights 1 + seen', 'solo shows 3 of the 6', 'other rounds (标准 / 险境) keep the previous draw: tiers I–VI with replacement'],
},
// the official 战术决策 (TACTIC_DRAFT) at `rounds`: each card drawn on its own (with replacement) from the cards.tactic
// entries of `kinds`, by `weights` (1 + the official cards it showed on; other cards of those kinds 1)
tacticDraft: {
rounds: TACTIC_DRAFT.rounds, kinds: TACTIC_DRAFT.kinds,
weights: Object.fromEntries(Object.entries(TACTIC_DRAFT.seen).map(([n, k]) => [tacticByName(n), 1 + k]).filter(([id]) => id).sort((a, b) => naturalCmp(a[0], b[0]))),
seen: TACTIC_DRAFT.matches, count: 6,
assumed: ['card weights 1 + seen', 'independent draws (no slot structure)', 'no terrain card at R11', 'solo shows 3', 'other rounds (标准 / 险境) keep every card, uniform, with replacement'],
},
schedule,
pools: {