feedback1 WB: bounty drafts and the 机密商店 from 66 official screenshots (22 matches)

Bounty drafts: every 悬赏决策 event is a fixed card list and the draft shows
6 different cards of it, drawn with weight 1 + the official drafts of its
group the card showed in.
- R3: 9 of the 10 fixed sets seen (two new: 20_1 + 20_5, and one with 17_6);
  3 x I + 2 x II + 1 x III in 22 of 22. The unseen 10th set is built by a
  rule whose III is 10_6 or 20_6, the only two-battle cards no draft showed.
- R9: 6 groups of 9 cards, one per bossInitial event. The 鼠王 group came in
  14 of 22 matches (every grey-board match among them), so groups are picked
  by the matches they came in. Named bosses no longer always come together.
- R11: lists of 7 cards, the draft leaves out one (matches 1 / 6 / 21 / 22
  leave out four different cards of one list); 7 of 15 lists seen, the rest
  and the 4 partial lists' 7th cards built by rule. Families 14 / 4 / 4.

机密商店 (the user: "机密商店按官方改成可以重复吧"): choices.json shopDraft,
six slots drawn with replacement - VI, VI, V, 盟约之币 and twice
V / IV / III / 盟约之币, items weighted by the official cards they showed on.
A repeated item now comes in about 61 % of shops (official 3 of 4). Two
identical cards stay two cards (picks by index): E2E, overlay and a
headless-Chrome test (game-mock ?variant=shop, the official match 8 shop).
This commit is contained in:
sganggs committed 2026-10-03 07:42:20 +08:00
1 parent 7c13e38422
commit d1f8a49829
12 files changed
+799 -261

No files matched your search

+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
+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 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. 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 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) |leaker (联防: you let enemies through — the ×N counter)
|overtime|drain (boss rounds: level time over / overtime drain) |paused (solo battles) |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) &shot=1 (hide the switcher) &render=engine|fallback (default: the Pixi engine, DOM fallback if it fails)
+4 -2
View File
@@ -282,10 +282,12 @@ function setPhase(phase, variant) {
break; break;
case PHASE.SP_DRAFT: { case PHASE.SP_DRAFT: {
pub.round = 9; pub.deadline = Date.now() + 14000; 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'); const ch = data.get('choices');
let cards; let cards;
if (fam === 'supply') cards = shuffle(shopItems().filter((i) => i.tier >= 3)).slice(0, 6).map((i) => ({ itemId: i.id })); if (fam === 'supply') cards = shuffle(shopItems().filter((i) => i.tier >= 3)).slice(0, 6).map((i) => ({ itemId: i.id }));
// 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 }));
else if (fam === 'tactic') cards = shuffle(ch.cards.tactic.filter((t) => t.kind !== 'terrain')).slice(0, 6).map((t) => ({ effectId: t.effectId })); else if (fam === 'tactic') cards = shuffle(ch.cards.tactic.filter((t) => t.kind !== 'terrain')).slice(0, 6).map((t) => ({ effectId: t.effectId }));
else cards = shuffle(ch.cards.bounty.filter((b) => b.draft !== false)).slice(0, 6).map((b) => ({ effectId: b.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; } if (solo) { cards = cards.slice(0, 3); pub.deadline = 0; }
@@ -698,7 +700,7 @@ const SWITCH = [
['BATTLE_CHECK', PHASE.BATTLE_CHECK, ''], ['PREP', PHASE.PREP, ''], ['PREP + reward', PHASE.PREP, 'reward'], ['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 + 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'], ['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, ''], ['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'], ['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'], ['HIDDEN_CORE', PHASE.HIDDEN_CORE, ''], ['COMBAT solo (pause)', PHASE.COMBAT, 'solo'], ['FA solo paused', PHASE.FINAL_ASSAULT, 'solo,paused'],
+134 -64
View File
@@ -5,15 +5,17 @@
// count is `cards` (co-op 6, solo 3): // 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 // 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 // (`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` // drafts; 66 official screenshots of 22 matches, tools/build-data.mjs BOUNTY_INITIAL_SETS): schedule
// names the kind and choices.json `bountyDrafts[kind]` its structure — // `bountyDraft` names the kind and choices.json `bountyDrafts[kind]` its card lists — the event is a
// initial (R3, 险境 R6 [ASSUMED]): one of the 10 official sets of "接下来两场作战" cards — a seen // fixed list and the draft shows 6 different cards of it, each drawn with its weight (1 + the
// set, or for an unseen slot one card from each of 6 of the series 10–15 / 20 with the // official drafts of the group it showed in):
// tiers I I I II II III; // initial (R3, 险境 R6 [ASSUMED]): one of the 10 official sets of six "接下来两场作战" cards (all
// boss (R9): a seen group of named bosses + the group's cheap boss bounties / 源石虫·特训 to 6; // six) — 9 seen, the 10th built by `rule` (I I I II II III) [ASSUMED];
// hunter (R11): one of the 15 official events — a seen group (a 特异III giant + its tier-II // boss (R9): one of the 6 groups of 9 boss bounties / 源石虫·特训, picked by the matches it came in
// "下场战斗" cards, always together) or for an unseen slot a group of that shape // [ASSUMED];
// [ASSUMED] — filled to 6 with tier-I cards, at most one card per faction series; // hunter (R11): one of the 15 events, a list of 7 "下场战斗" cards — 7 seen (their `open` cards and
// the 8 unseen lists built by `rule`: one 特异III giant + tier I / II cards, one per faction
// series) [ASSUMED];
// only `draft` cards of that `draftPool` (never 战术特训 — 法术教鞭 only —, the 鸭爵 set, the 7 // 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 // 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 "以上调整仅针对战术特训敌人, // mode's inactive enemy list does not apply (PRTS 卫戍协议:盟约 11/18 note "以上调整仅针对战术特训敌人,
@@ -22,8 +24,13 @@
// MULTI_ROUND_BOUNTY_BATTLES battles and says so (`bountyBattles` / `bountyText`: the user's call // MULTI_ROUND_BOUNTY_BATTLES battles and says so (`bountyBattles` / `bountyText`: the user's call
// after playtest #6 — "我不记得有过多轮悬赏") // after playtest #6 — "我不记得有过多轮悬赏")
// supply 道具补给 random normal EQUIP shop items with tier in supplyTiers [lo, hi] (duplicates allowed) // 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 // shop 机密商店 choices.json `shopDraft` (the 4 official 机密商店 at R11; the user: "机密商店按官方改成可以重复吧"):
// text is "无需消耗资金,获得装备补给" (research 01 A4/04 addendum), so the price is 0 // 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]); without `shopDraft`
// random normal EQUIP shop items of any tier. 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 战术决策 distinct cards.tactic entries; terrain cards only for the match stage; a 驰援 card // 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) // (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: // only while at least one of its bonds is live in this match (opts.bondAvailable = Match.bondLive:
@@ -185,76 +192,107 @@ 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. */ /** Up to `k` cards of `list` not in `taken`, in a shuffled order. */
function drawDistinct(rng, list, k, taken, onePer = null) { function drawDistinct(rng, list, k, taken) {
const out = []; const out = [];
const used = new Set([...taken].map((c) => c.series));
for (const c of rng.shuffle(list.slice())) { for (const c of rng.shuffle(list.slice())) {
if (out.length >= k) break; if (out.length >= k) break;
if (taken.has(c)) continue; if (taken.has(c)) continue;
if (onePer && onePer.has(c.series) && used.has(c.series)) continue;
out.push(c); out.push(c);
taken.add(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; 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. * 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`;
* 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) { 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; const count = Number.isInteger(spec.count) ? spec.count : 6;
if (kind === 'initial') { const groups = Array.isArray(spec.groups) ? spec.groups : [];
const sets = Array.isArray(spec.sets) ? spec.sets : []; let group = null;
const slot = rng.int(Math.max(sets.length, Number.isInteger(spec.slots) ? spec.slots : 0)); if (spec.pick === 'seen') {
if (slot < sets.length) return get(sets[slot].cards).slice(0, count); group = weightedPick(rng, groups.map((g) => [g, (Array.isArray(g.seen) ? g.seen.filter((x) => Number.isInteger(x)).length : 0) || 1]));
// an unseen official set: one card from each of `count` series, with the seen sets' tier mix } else {
const mx = spec.mixed || {}; const slots = Math.max(groups.length, Number.isInteger(spec.slots) ? spec.slots : 0);
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 slot = slots > 0 ? rng.int(slots) : 0; const slot = slots > 0 ? rng.int(slots) : 0;
let group; group = slot < groups.length ? groups[slot] : null;
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)];
} }
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 +312,41 @@ function bountyDraftCards(gd, rng, n, sch, round) {
return rng.shuffle(out).slice(0, n).map((c) => bountyCard(gd, c)); 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`.
*/
export function shopDraftCards(gd, rng, n) {
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;
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));
}
function buildCards(gd, rng, family, n, sch, { stageId = null, bondAvailable = null, round = 1 } = {}) { function buildCards(gd, rng, family, n, sch, { stageId = null, bondAvailable = null, round = 1 } = {}) {
const cardsData = gd.choices.cards || {}; const cardsData = gd.choices.cards || {};
if (family === 'bounty') return bountyDraftCards(gd, rng, n, sch, round); if (family === 'bounty') return bountyDraftCards(gd, rng, n, sch, round);
if (family === 'shop') {
const cards = shopDraftCards(gd, rng, n);
if (cards) return cards;
}
if (family === 'supply' || family === 'shop') { if (family === 'supply' || family === 'shop') {
let lo = 1; let lo = 1;
let hi = 6; let hi = 6;
+43 -3
View File
@@ -350,7 +350,7 @@ test('道具补给 E2E (co-op): 6 free normal items within the round window; the
m.dispose(); 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 data = dataWith('mode_single_hard', 11, 'shop');
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', humans: 1, seed: 42, data, registry: REG, fake: true }); const h = makeMatch({ mode: 'solo', difficulty: 'HARD', humans: 1, seed: 42, data, registry: REG, fake: true });
const m = h.m; const m = h.m;
@@ -372,22 +372,62 @@ test('机密商店 E2E (solo HARD R11): 3 free normal items of any tier I–VI;
m.dispose(); 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 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(); const tiers = new Set();
let dupes = 0; let dupes = 0;
for (let i = 0; i < 30; i++) { for (let i = 0; i < 30; i++) {
const d = generateDraft(h.m.gd, h.m.rngDraft, 11, { stageId: h.m.stageId }); const d = generateDraft(h.m.gd, h.m.rngDraft, 11, { stageId: h.m.stageId });
assert.equal(d.family, 'shop'); assert.equal(d.family, 'shop');
assert.equal(d.cards.length, 6); 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); 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++; if (new Set(d.cards.map((c) => c.id)).size < d.cards.length) dupes++;
} }
h.m.dispose(); 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'); 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, 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();
});
// ===================================================================================================================== // =====================================================================================================================
// 战术决策 — prep-side cards // 战术决策 — prep-side cards
+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", "season": "act2autochess",
"aliases": { "深池重甲卫队队长": "深池重甲卫士队长" }, "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": { "matches": {
"1": { "1": {
"3": { "family": "bounty", "cards": [["悬赏·损伤I", "临时收音师", 1, 2], ["悬赏·损伤III", "主角阵营角色", 3, 2], ["悬赏·特异I", "迷茫的修理小助手", 1, 2], ["悬赏·特异II", "寻仇者", 2, 2], ["悬赏·特异II", "圣堂剑士", 2, 2], ["悬赏·特异I", "高能源石虫", 1, 2]] }, "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]] }, "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]] }, "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]] } "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": { "extra": {
+141 -103
View File
@@ -1,15 +1,16 @@
// Player feedback after 0.1.0, report #2 (workstream WB): "本来应该后期出的悬赏的怪物在前期的悬赏就出现了,导致选了打不过". // 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 user then collected the official 机变 of rounds 3, 9 and 11 in 22 official co-op matches (66 screenshots, 绝境 /
// 终极; the readings are test/fixtures/official-bounty-drafts.json, player names left out). They settle the draft rules // 终极, one season — videos of the end of March 2026; the readings are test/fixtures/official-bounty-drafts.json, player
// (tools/build-data.mjs BOUNTY_INITIAL_SETS, server/match/choices.js bountyDraftCards): // names left out). They settle the draft rules (tools/build-data.mjs BOUNTY_INITIAL_SETS, server/match/choices.js
// R3 six "接下来两场作战" cards, 3 × I + 2 × II + 1 × III, one of the official fixed sets (a whole series 17 / 18 / 19, // bountyDraftCards): a 悬赏决策 event is a fixed card list and the draft shows 6 different cards of it —
// or one card from each of 6 of the series 10–15 / 20) — 7 of the 10 events seen; // R3 a set of six "接下来两场作战" cards (all six), 3 × I + 2 × II + 1 × III: 9 of the 10 events seen;
// R9 boss bounties + 源石虫·特训: a group of named bosses that come together + cheap ones to 6; // R9 a group of 9 boss bounties / 源石虫·特训: 6 groups for the 6 events, the 鼠王 group in 14 of 22 matches;
// R11 悬赏决策 / 机密商店 / 战术决策 (never 道具补给); its bounty: a group (one 特异III giant + 1–4 tier-II "下场战斗" cards, // R11 悬赏决策 14 / 机密商店 4 / 战术决策 4 / 道具补给 0; its bounty: a list of 7 "下场战斗" cards (one 特异III giant),
// always together — matches 1 and 6 share the whole group) + tier-I cards to 6, at most one per faction series; // one per faction series — 7 of the 15 events seen, three of them whole (each draft leaves out one card);
// no draft shows a multi-round card, a pre-series card (enemyeffect_3_*), 战术特训 or the 鸭爵 set; no card twice. // 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 // 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. // 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 { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs'; import { readFileSync, existsSync } from 'node:fs';
@@ -27,57 +28,39 @@ const SPEC = DATA.choices.bountyDrafts;
const COOP_BOUNTY_MODES = ['mode_multi_normal', 'mode_multi_hard', 'mode_multi_abyss']; 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 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 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). */ /** 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]) { function resolve([title, enemy, coin, battles]) {
const name = OFFICIAL.aliases[enemy] || enemy; 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)); && (battles === 2 ? c.rounds === 2 : c.rounds === 1));
assert.equal(hits.length, 1, `${title} ${enemy} ${coin}: ${hits.map((c) => c.effectId).join(', ') || 'no card'}`); assert.equal(hits.length, 1, `${title} ${enemy} ${coin}: ${hits.map((c) => c.effectId).join(', ') || 'no card'}`);
return hits[0].effectId; 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() { function officialDrafts() {
const out = []; const out = [];
for (const [match, rounds] of Object.entries(OFFICIAL.matches)) { 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; return out;
} }
const tiersOf = (ids) => ids.map((id) => CARD.get(id).tier).sort().join(''); const tiersOf = (ids) => ids.map((id) => CARD.get(id).tier).sort().join('');
/** The group of `spec` whose `seen` lists the draft's match. */
/** 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. */ const groupOf = (spec, d) => spec.groups.filter((g) => g.seen.includes(d.match));
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);
/** At most one card per faction series 10–15 (every official R11 draft). */ /** At most one card per faction series 10–15 (every official R11 draft). */
function onePerSeries(ids) { 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; 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 giantsIn = (ids) => ids.filter((id) => SPEC.hunter.rule.giants.includes(id)).length;
const hunterFits = (ids) => SPEC.hunter.templates.map((t, i) => [t, i]).filter(([t]) => ids.length === SPEC.hunter.count && onePerSeries(ids) /** The weights the data gives a group's cards: 1 + the official drafts of that group the card appeared in. */
&& t.featured.every((id) => ids.includes(id)) && ids.filter((id) => !t.featured.includes(id)).every((id) => t.fill.includes(id))).map(([, i]) => i); function expectedWeights(g, drafts) {
/** 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. */ return g.cards.map((id) => 1 + drafts.filter((d) => d.ids.includes(id)).length);
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);
} }
/** Bounty drafts generated for `modeId` at `round` over `seeds` seeds. */ /** Bounty drafts generated for `modeId` at `round` over `seeds` seeds. */
@@ -91,13 +74,23 @@ function drafts(modeId, round, seeds, data = DATA) {
return out; 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) => { 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(); const seen = new Set();
for (const d of officialDrafts()) for (const id of d.ids || []) seen.add(id); 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 // 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_18_1');
assert.equal(resolve(['悬赏·损伤I', '底海滑动者', 1, 2]), 'enemyeffect_12_4'); 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_18_1').enemyKey, 'enemy_10094_crstf');
assert.equal(CARD.get('enemyeffect_12_4').enemyKey, 'enemy_1148_dssbr'); assert.equal(CARD.get('enemyeffect_12_4').enemyKey, 'enemy_1148_dssbr');
const p = new URL('../../.cache/gamedata/excel/activity_table.json', import.meta.url); const p = new URL('../../.cache/gamedata/excel/activity_table.json', import.meta.url);
@@ -112,40 +105,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.ok(seen.has(id) && !act.act1autochess.effectInfoDataDict[id], `${id} is 下半-only`);
} }
assert.match(act.act1autochess.effectInfoDataDict.enemyeffect_11_6.effectDesc, /沉沙/); 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); const r3 = officialDrafts().filter((d) => d.round === 3);
assert.equal(r3.length, 11); assert.equal(r3.length, 22);
const hits = SPEC.initial.sets.map(() => 0); const hits = SPEC.initial.groups.map(() => []);
for (const d of r3) { for (const d of r3) {
assert.equal(d.family, 'bounty', `${d.where}: R3 is a 悬赏决策`); 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.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.equal(tiersOf(d.ids), '111223', `${d.where}: tiers`);
assert.ok(d.ids.every((id) => !isBoss(CARD.get(id).enemyKey)), `${d.where}: no boss`); assert.ok(d.ids.every((id) => !isBoss(CARD.get(id).enemyKey)), `${d.where}: no boss`);
const fit = initialFit(d.ids); const i = SPEC.initial.groups.findIndex((s) => sameSet(s.cards, d.ids));
assert.match(fit || '', /^set:/, `${d.where}: a seen set`); assert.ok(i >= 0, `${d.where}: a seen set`);
hits[Number(fit.slice(4))]++; 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'); assert.deepEqual(hits, SPEC.initial.groups.map((s) => s.seen), 'each set\'s `seen` lists the matches it came in');
// each seen set is a whole series 17 / 18 / 19 or follows the rule the unseen slots are built by assert.deepEqual(hits.map((x) => x.length), [5, 3, 2, 3, 2, 1, 2, 1, 3]);
for (const s of SPEC.initial.sets) { // hand-made sets, not one card per series: two series-20 cards (match 12), a series-17 card among faction cards (13 / 19 / 21)
const series = new Set(s.cards.map((id) => CARD.get(id).series)); assert.deepEqual(SPEC.initial.groups[7].cards.filter((id) => CARD.get(id).series === 20), [EE('20_1'), EE('20_5')]);
assert.ok(series.size === 1 ? [17, 18, 19].includes([...series][0]) && s.cards.length === 6 : mixedRule(s.cards), s.cards.join()); assert.ok(SPEC.initial.groups[8].cards.includes(EE('17_6')));
assert.equal(tiersOf(s.cards), '111223'); 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.slots, 10, 'enemy_initial_1..10'); 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(); const counts = new Map();
let n = 0; let n = 0;
for (const modeId of COOP_BOUNTY_MODES) { for (const modeId of COOP_BOUNTY_MODES) {
for (const round of DATA.choices.schedule[modeId].spRounds.filter((r) => bountyDraftKind(r) === 'initial')) { 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); const ids = d.cards.map((c) => c.id);
assert.equal(new Set(ids).size, 6, 'six different cards'); 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(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.ok(d.cards.every((c) => c.rounds === 2 && !isBoss(c.enemyKey)), 'two-battle cards, no boss');
assert.match(d.eventId, /^enemy_initial_\d+$/); assert.match(d.eventId, /^enemy_initial_\d+$/);
@@ -153,9 +153,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`); for (let i = 0; i < SPEC.initial.groups.length; i++) assert.ok(counts.get(`set:${i}`) > 0, `set ${i} offered`);
const mixed = (counts.get('mixed') || 0) / n; const rule = (counts.get('rule') || 0) / n;
assert.ok(mixed > 0.2 && mixed < 0.4, `unseen-slot sets ${(mixed * 100).toFixed(0)} %`); assert.ok(rule > 0.05 && rule < 0.16, `unseen-set drafts ${(rule * 100).toFixed(0)} %`);
// solo: 3 cards of such a set // solo: 3 cards of such a set
for (const d of drafts('mode_single_hard', 3, 40)) { for (const d of drafts('mode_single_hard', 3, 40)) {
assert.equal(d.cards.length, 3); assert.equal(d.cards.length, 3);
@@ -163,88 +163,125 @@ 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 of 9 boss bounties / 源石虫·特训, 6 cards each — 庞贝 and 鼠王 never together, the 鼠王 group in 14 of 22 matches', () => {
const r9 = officialDrafts().filter((d) => d.round === 9); const r9 = officialDrafts().filter((d) => d.round === 9);
assert.equal(r9.length, 12, '11 matches + the Bahamut co-op screenshot'); assert.equal(r9.length, 23, '22 matches + the Bahamut co-op screenshot');
const hits = SPEC.boss.templates.map(() => 0); const B = SPEC.boss;
assert.equal(B.groups.length, B.events.length, 'one group per bossInitial event');
for (const d of r9) { for (const d of r9) {
assert.equal(d.family, 'bounty', `${d.where}: R9 is a 悬赏决策`); 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 / 源石虫·特训, 下场作战`); 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); const g = groupOf(B, d);
assert.equal(fits.length, 1, `${d.where}: fits exactly one seen group (${fits.join(', ')})`); assert.equal(g.length, 1, `${d.where}: listed by one group`);
hits[fits[0]]++; 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 g of B.groups) {
for (const d of drafts('mode_multi_abyss', 9, 300)) { 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); const ids = d.cards.map((c) => c.id);
assert.equal(new Set(ids).size, 6); 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+$/); 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))); assert.ok(ratN / gen.length > 0.55 && ratN / gen.length < 0.73, `the 鼠王 group ${(100 * ratN / gen.length).toFixed(0)} %`);
for (const t of SPEC.boss.templates) for (const id of t.featured) assert.ok(offered.has(id), `${CARD.get(id).name} offered`); 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']) { 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.equal(CARD.get(id).draftExcluded, 'unseen', `${CARD.get(id).name}: in no official R9 draft`);
assert.ok(!offered.has(id)); 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 r11 = officialDrafts().filter((d) => d.round === 11);
const fam = {}; const fam = {};
for (const d of r11) fam[d.family] = (fam[d.family] || 0) + 1; for (const d of r11) fam[d.family] = (fam[d.family] || 0) + 1;
assert.deepEqual(fam, { bounty: 5, shop: 4, tactic: 2 }); assert.deepEqual(fam, { bounty: 14, shop: 4, tactic: 4 });
const hits = SPEC.hunter.templates.map(() => 0); const H = SPEC.hunter;
const byMatch = {}; const bounty = r11.filter((x) => x.family === 'bounty');
for (const d of 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(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)})`); assert.ok(onePerSeries(d.ids), `${d.where}: one card per faction series`);
const fits = hunterFits(d.ids); assert.ok(giantsIn(d.ids) <= 1, `${d.where}: at most one giant`);
assert.equal(fits.length, 1, `${d.where}: fits exactly one seen group (${fits.join(', ')})`); const g = groupOf(H, d);
hits[fits[0]]++; assert.equal(g.length, 1, `${d.where}: listed by one group`);
byMatch[d.where.split(' ')[1]] = d.ids; 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'); assert.equal(bounty.filter((d) => giantsIn(d.ids) === 1).length, 13, 'a giant in 13 of 14 (not match 15)');
// 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 for (const g of H.groups) {
const shared = byMatch['1'].filter((id) => byMatch['6'].includes(id)); const ds = bounty.filter((d) => g.seen.includes(d.match));
assert.equal(shared.length, 5); assert.equal(ds.length, g.seen.length);
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]); assert.equal(g.cards.length + (g.open || 0), H.rule.size, `${g.cards.join(' ')}: a list of 7`);
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.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']) { for (const modeId of ['mode_multi_hard', 'mode_multi_abyss', 'mode_single_hard', 'mode_single_abyss']) {
const sch = DATA.choices.schedule[modeId].rounds['11']; 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.bountyDraft, 'hunter');
assert.equal(sch.assumed, true); assert.equal(sch.assumed, true);
} }
assert.equal(SPEC.hunter.slots, 15, 'bounty_hunter_1..15'); assert.equal(H.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 // generated: six different cards, one per faction series, at most one giant; a seen group about 7 in 15; every R11
// card comes up (the unseen ones through the rule)
const gd = new GameData(DATA, 'mode_multi_hard'); const gd = new GameData(DATA, 'mode_multi_hard');
const seen = {}; const seen = {};
const groups = SPEC.hunter.templates.map(() => 0);
const offered = new Set(); const offered = new Set();
let bounty = 0; let n = 0;
let fitSeen = 0; let fitSeen = 0;
for (let seed = 1; seed <= 1500; seed++) { let giants = 0;
for (let seed = 1; seed <= 2000; seed++) {
const d = generateDraft(gd, createRng(seed * 13 + 11), 11, { stageId: 'act2autochess_m01' }); const d = generateDraft(gd, createRng(seed * 13 + 11), 11, { stageId: 'act2autochess_m01' });
seen[d.family] = (seen[d.family] || 0) + 1; seen[d.family] = (seen[d.family] || 0) + 1;
if (d.family !== 'bounty') continue; if (d.family !== 'bounty') continue;
const ids = d.cards.map((c) => c.id); const ids = d.cards.map((c) => c.id);
assert.equal(new Set(ids).size, 6); 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+$/); assert.match(d.eventId, /^bounty_hunter_\d+$/);
const fits = hunterFits(ids); if (H.groups.some((g) => !g.open && subset(ids, g.cards))) fitSeen++;
if (fits.length) { fitSeen++; groups[fits[0]]++; } giants += giantsIn(ids);
bounty++; n++;
ids.forEach((id) => offered.add(id)); ids.forEach((id) => offered.add(id));
} }
assert.deepEqual(Object.keys(seen).sort(), ['bounty', 'shop', 'tactic'], 'no 道具补给 at R11'); 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(seen.bounty / 2000 > 0.56 && seen.bounty / 2000 < 0.71, JSON.stringify(seen));
assert.ok(groups.every((x) => x > 0), `every seen group offered (${groups.join(' ')})`); assert.ok(fitSeen / n > 0.13 && fitSeen / n < 0.27, `drafts of the three whole seen groups ${(100 * fitSeen / n).toFixed(0)} % (3 / 15)`);
assert.ok(fitSeen / bounty > 0.17 && fitSeen / bounty < 0.4, `seen groups ${(100 * fitSeen / bounty).toFixed(0)} % of ${bounty}`); 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`); 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 = {}; const reasons = {};
for (const c of CARDS) { for (const c of CARDS) {
if (!c.draft) reasons[c.draftExcluded] = (reasons[c.draftExcluded] || 0) + 1; if (!c.draft) reasons[c.draftExcluded] = (reasons[c.draftExcluded] || 0) + 1;
@@ -288,7 +325,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.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'); assert.equal(m.sp.family, 'bounty', 'R3 of 绝境 is always a bounty draft');
const names = m.sp.cards.map((c) => c.name).join(', '); 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`); 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); 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 // the human takes a card when its turn comes; the bot picks by itself
+102
View File
@@ -0,0 +1,102 @@
// 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; 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);
// 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));
}
});
+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 "之后 / 后续的每场 // 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 // 作战" 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 // "下场" 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 // 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. // 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); assert.equal(list.filter((c) => c.draft).length, 86);
const multi = list.filter((c) => c.rounds >= 99 && c.payout === 'kill'); const multi = list.filter((c) => c.rounds >= 99 && c.payout === 'kill');
assert.equal(multi.length, 7, '多轮悬赏 · 假想敌 ×6 + 山海众头目·多轮悬赏'); 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'); 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. */ /** 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 });
});
+134 -83
View File
@@ -2319,7 +2319,7 @@ const ROMAN = { I: 1, II: 2, III: 3 };
* "※以下悬赏任务仅由法术教鞭生成" * "※以下悬赏任务仅由法术教鞭生成"
* 'hidden' 鸭爵 / 高普尼克 / 流泪小子 / 圆仔·悬赏 (enemyeffect_5..8): commented out of the table (the 鸭爵 strategy * 'hidden' 鸭爵 / 高普尼克 / 流泪小子 / 圆仔·悬赏 (enemyeffect_5..8): commented out of the table (the 鸭爵 strategy
* “神秘顾客” swaps those enemies into the waves instead) * “神秘顾客” 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 ("之后 / 后续的每场作战": 山海众头目· * 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 * 多轮悬赏, 多轮悬赏·假想敌 ×6), the pre-series cards enemyeffect_3_* and the boss bounties of no seen R9 group
* (凋零骑士, “遗弃者”, 锏, 扎罗, 迷路的巨像) [ASSUMED: not offered] * (凋零骑士, “遗弃者”, 锏, 扎罗, 迷路的巨像) [ASSUMED: not offered]
@@ -2340,76 +2340,107 @@ function bountyDraftExclusion(e, main) {
/** /**
* The official 悬赏决策 drafts (player feedback after 0.1.0, report #2: "本来应该后期出的悬赏的怪物在前期的悬赏就出现了,导致选了 * 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 * 打不过"), 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; the readings are test/fixtures/official-bounty-drafts.json). Every card's title, enemy and coin * by the user, 2026-10-03, from videos of this season recorded at the end of March 2026; the readings are
* value is an act2autochess (下半) effect. A card's enemy is fixed by its effect (blackboard `enemy_id`); the title only * test/fixtures/official-bounty-drafts.json, `seen` below = its match numbers). Every card's title, enemy and coin value is
* names the category and tier, so 悬赏·损伤I is 底海滑动者 in enemyeffect_12_4 and 临时收音师 in enemyeffect_18_1. The draft * an act2autochess (下半) effect. A card's enemy is fixed by its effect (blackboard `enemy_id`); the title only names the
* of a round is one of three kinds (the event ids are the data's; which event lists which cards is server-side): * category and tier, so 悬赏·损伤I is 底海滑动者 in enemyeffect_12_4 and 临时收音师 in enemyeffect_18_1. The players pick
* initial R3 (enemy_initial_1..10): six "接下来两场作战" cards, always 3 × I (1 coin) + 2 × II + 1 × III. All 11 R3 * in turn from ONE shared draft (a taken card stays greyed with the taker's avatar: match 17 R3, match 21 R9).
* 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 * One rule covers all three rounds: the draft's event is a fixed list of cards, and the draft shows 6 different cards of
* are unseen: built by that rule (BOUNTY_INITIAL_MIXED) [ASSUMED]. * it (positions shuffled): R3 lists hold 6 cards (all shown), R9 lists 9 (every R9 group seen in 3 or more drafts — and
* boss R9 (bossInitial_1..6): the boss bounties "X·悬赏" and 源石虫·特训, all "下场作战". All 12 drafts (11 matches + * only those — shows exactly 9 different cards: 鼠王 group, 喷气人 group, 复仇者 group), R11 lists 7 (the three R11 groups
* the Bahamut 12294 co-op screenshot) are a group of 0–3 named bosses that always come together, filled up to 6 * 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
* with cheap ones (W / 碎骨 / 弑君者 1, 大鲍勃 2, 庞贝 or 鼠王 2, 源石虫·特训 0): BOUNTY_BOSS_TEMPLATES. 7 groups * 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
* were seen for 6 events (庞贝 and 鼠王 never in one draft; 杰斯顿 only in matches 7–11): two game versions, * shown yet, 1) [ASSUMED: smooths the official counts — in the 鼠王 group 鼠王 showed in 4 of 14, 杰斯顿 in 12].
* or a group set by something of the match (its leader, its map — the 杰斯顿 groups came on both boards) — * initial R3 (enemy_initial_1..10): six "接下来两场作战" cards, always 3 × I (1 coin) + 2 × II + 1 × III (22 of 22).
* open; uniform among the seen groups [ASSUMED]. 凋零骑士, “遗弃者”, 锏, 扎罗 and 迷路的巨像 never appeared: not * 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
* offered [ASSUMED]. * ×2, two ×1 — consistent with a uniform pick among 10). Hand-made: a set can hold two series-20 cards (12) or
* hunter R11 (bounty_hunter_1..15; R11 is a 悬赏决策 in 5 of the 11 matches): six "下场战斗" cards, exactly one 特异III * one card of series 17 (13 / 19 / 21). The unseen 10th set: BOUNTY_INITIAL_RULE [ASSUMED] — its III is one of
* giant (enemyeffect_16_1..8, 3 coins), the others tier II / I faction _7 / _8 cards and enemyeffect_16_9..12, * the only two two-battle cards no draft showed (法术大师A1 10_6, 鼎沸 20_6).
* at most one per faction series 10–15 (5 of 5 drafts). Not a free draw within that rule: matches 1 and 6 * boss R9 (bossInitial_1..6): the boss bounties "X·悬赏" and 源石虫·特训 (16 of 22), all "下场作战". Six groups, one
* 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 * per event (BOUNTY_BOSS_GROUPS); 庞贝 and 鼠王 are never in one draft. The 鼠王 group (W 碎骨 弑君者 大鲍勃 源石虫
* 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 * 鼠王 杰斯顿 “自在” 陷落雪祀) came in 14 of the 22 matches, so the event is not picked uniformly: something of
* show (a uniform draw under the rule gives some pair of 5 drafts 5 shared cards in about 2.5 % of samples). * the match decides — every match on the dark grey board (7) had it (if independent of the board, 2 %); the
* Like R9, a draft is a group — the giant + 1–4 tier-II cards, always together — filled to 6 with tier-I * leader, the map or the difficulty (终极 opened 2026-03-27) are candidates. Picked by the matches each group
* cards: BOUNTY_HUNTER_TEMPLATES, 4 groups seen for the 15 events (fill = the 5 tier-I cards seen); an unseen * came in [ASSUMED]. A named boss does not always come with its partners (杰斯顿 alone in 12 / 15 / 21, 复仇者
* event draws a group of that shape over all the R11 cards (BOUNTY_HUNTER) [ASSUMED: the group / fill split * and 萨卡兹百夫长 without 邪魔的利刃 in 20). The three single-draft groups are completed with the 庞贝 base
* by tier, uniform among the 15 events]. The fill cards 16_11 and 13_7 only came in matches 10–11 (the * cards they lack [ASSUMED]. 凋零骑士, “遗弃者”, 锏, 扎罗 and 迷路的巨像 never appeared: not offered [ASSUMED].
* 杰斯顿 period of R9) — too few drafts to split the fill by period. * hunter R11 (bounty_hunter_1..15; R11 is a 悬赏决策 in 14 of the 22 matches): six "下场战斗" cards, the faction _7 / _8
* In none of the 27 bounty drafts: the 7 multi-round cards (山海众头目·多轮悬赏, 多轮悬赏·假想敌 ×6), the pre-series cards * 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 8 unseen events: BOUNTY_HUNTER_RULE
* [ASSUMED] — one giant + 6 tier I / II cards, one per faction series, at most two of 16_9..12.
* In none of the 58 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. * enemyeffect_3_* (法术大师A2·悬赏 …), 战术特训 (法术教鞭 only) and the 鸭爵 set; no card twice in one draft.
*/ */
const EE = (s) => `enemyeffect_${s}`; const EE = (s) => `enemyeffect_${s}`;
const seriesIds = (n) => [1, 2, 3, 4, 5, 6].map((i) => EE(`${n}_${i}`)); 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 = [ const BOUNTY_INITIAL_SETS = [
{ cards: seriesIds(18), seen: [1, 3, 5, 9] }, { cards: seriesIds(18), seen: [1, 3, 5, 9, 17] },
{ cards: seriesIds(19), seen: [2] }, { cards: seriesIds(19), seen: [2, 15, 22] },
{ cards: seriesIds(17), seen: [7] }, { cards: seriesIds(17), seen: [7, 14] },
{ cards: ['10_5', '11_4', '12_5', '13_6', '15_4', '20_3'].map(EE), seen: [4, 10] }, { 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] }, { 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_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] }, { 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] },
/** An unseen R3 set: one card from each of `count` of these series, with these tiers (the seen mixed sets' rule). */ { cards: ['11_4', '12_4', '13_5', '14_4', '15_5', '17_6'].map(EE), seen: [13, 19, 21] },
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] }, // 乌顶巨角卢鲁 拥霜羽兽
]; ];
/** /**
* An unseen R11 group (the seen groups' shape): one tier-III giant + `groupSizes` (one drawn: the seen 4 / 2 / 3 / 1) * The unseen R3 set [ASSUMED]: six two-battle cards of these series with the seen sets' tiers, at most `perSeries` of one
* tier-II cards, filled with tier-I cards, at most one card per series of `onePerSeries` — over the R11 cards by tier * series (the 20_1 / 20_5 set has two), its tier-III card one of `prefer` (the two-battle cards no draft showed).
* (buildChoices). [ASSUMED]
*/ */
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 (枯朽萃聚使徒 …)
];
/**
* An unseen R11 list, and the `open` card of a seen one [ASSUMED]: `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]; outside R11 of 绝境 / 终极 no screenshot [ASSUMED the same].
*/
const SHOP_DRAFT = {
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 kind of a round's 悬赏决策: R3 (and 险境 R6, [ASSUMED]) initial, R9 boss, R11 hunter. */ /** 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'); const bountyDraftOf = (r) => (r < 8 ? 'initial' : r < 11 ? 'boss' : 'hunter');
@@ -2474,8 +2505,8 @@ function buildChoices(ctx, effects, items, chess) {
let draftExcluded = bountyDraftExclusion(e, main); let draftExcluded = bountyDraftExclusion(e, main);
if (draftExcluded === 'hidden' && !/鸭爵|高普尼克|流泪小子|圆仔/.test(e.name || '')) warn(`bounty ${e.effectId} "${e.name}": expected one of the 鸭爵 set`); if (draftExcluded === 'hidden' && !/鸭爵|高普尼克|流泪小子|圆仔/.test(e.name || '')) warn(`bounty ${e.effectId} "${e.name}": expected one of the 鸭爵 set`);
let draftPool = bountyDraftPool(e, main, draftExcluded); let draftPool = bountyDraftPool(e, main, draftExcluded);
// a boss bounty is offered only through a seen R9 group (BOUNTY_BOSS_TEMPLATES) // a boss bounty is offered only through a seen R9 group (BOUNTY_BOSS_GROUPS)
if (draftPool === 'boss' && !BOUNTY_BOSS_TEMPLATES.some((t) => t.featured.includes(e.effectId) || t.fill.includes(e.effectId))) draftPool = null; if (draftPool === 'boss' && !BOUNTY_BOSS_GROUPS.some((g) => g.cards.includes(e.effectId))) draftPool = null;
if (!draftExcluded && !draftPool) draftExcluded = 'unseen'; if (!draftExcluded && !draftPool) draftExcluded = 'unseen';
const sm = /^enemyeffect_(\d+)_\d+$/.exec(e.effectId); const sm = /^enemyeffect_(\d+)_\d+$/.exec(e.effectId);
bounty.push({ bounty.push({
@@ -2489,16 +2520,21 @@ function buildChoices(ctx, effects, items, chess) {
const bountyById = new Map(bounty.map((c) => [c.effectId, c])); 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`); }; 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 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 g of BOUNTY_BOSS_GROUPS) checkKind(g.cards, 'boss', 'R9 group');
for (const t of BOUNTY_HUNTER_TEMPLATES) { for (const g of BOUNTY_HUNTER_GROUPS) checkKind(g.cards, 'hunter', 'R11 group');
checkKind([...t.featured, ...t.fill], 'hunter', 'R11 group'); for (const g of [...BOUNTY_INITIAL_SETS, ...BOUNTY_BOSS_GROUPS, ...BOUNTY_HUNTER_GROUPS]) {
const tiers = t.featured.map((id) => bountyById.get(id)?.tier); if (new Set(g.cards).size !== g.cards.length) warn(`bounty draft group ${g.cards.join(' ')}: a card twice`);
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 (g.hits && g.hits.length !== g.cards.length) warn(`bounty draft group ${g.cards.join(' ')}: hits do not match the 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`);
} }
// 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); 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'); 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). // Tactic cards (BUFF_GAIN effects).
const tactic = []; const tactic = [];
@@ -2532,11 +2568,11 @@ function buildChoices(ctx, effects, items, chess) {
let families; let families;
if (diff === 'TRAINING') families = [{ family: 'supply', weight: 100 }]; if (diff === 'TRAINING') families = [{ family: 'supply', weight: 100 }];
else if (diff === 'HARD' || diff === 'ABYSS') { 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): // R11 of the 22 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 // 悬赏决策 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, // 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] // 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') { } else if (diff === 'NORMAL') {
families = solo families = solo
? [{ family: r === 9 ? 'tactic' : 'supply', weight: 100 }] ? [{ family: r === 9 ? 'tactic' : 'supply', weight: 100 }]
@@ -2562,9 +2598,9 @@ function buildChoices(ctx, effects, items, chess) {
return { return {
events, events,
families: { 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 9; hunter: an R11 group 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)' }, 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)' }, shop: { name: '机密商店', desc: '无需消耗资金,获得装备补给。', cards: 'shopDraft: six slots drawn with replacement (VI, VI, V, 盟约之币, 2 × V / IV / III / 盟约之币) — the same item can come twice' },
tactic: { name: '战术决策', desc: '选择战术增益。', cards: 'cards.tactic (terrain cards only for the match stage)' }, tactic: { name: '战术决策', desc: '选择战术增益。', cards: 'cards.tactic (terrain cards only for the match stage)' },
}, },
format: { format: {
@@ -2574,18 +2610,33 @@ function buildChoices(ctx, effects, items, chess) {
}, },
cards: { bounty, tactic }, cards: { bounty, tactic },
// the official 悬赏决策 structures by kind (tools/build-data.mjs BOUNTY_INITIAL_SETS; `seen` = the match numbers of // 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: { 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'] }, initial: {
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'] }, 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: { hunter: {
events: eventsOf('bounty', (e) => bountyEventRe.hunter.test(e.id) && !e.solo), slots: eventsOf('bounty', (e) => bountyEventRe.hunter.test(e.id) && !e.solo).length, 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, pick: 'slot', groups: BOUNTY_HUNTER_GROUPS.map(withWeights),
rule: { giants: hunterTier(3), groupCards: hunterTier(2), groupSizes: BOUNTY_HUNTER.groupSizes, fill: hunterTier(1) }, rule: { ...BOUNTY_HUNTER_RULE, giants: hunterTier(3), cards: [...hunterTier(2), ...hunterTier(1)] }, count: 6,
onePerSeries: BOUNTY_HUNTER.onePerSeries, count: BOUNTY_HUNTER.count, assumed: ['a uniform pick among the 15 events', 'card weights 1 + hits', 'the `open` cards and the 8 unseen lists built by `rule`'],
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'],
}, },
}, },
// the official 机密商店 (SHOP_DRAFT): `slots` tier → weight (`coin` = 盟约之币), each drawn on its own; `itemWeights` =
// 1 + the official cards an item showed on (other shop items 1)
shopDraft: {
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', 'the same outside R11 of 绝境 / 终极'],
},
schedule, schedule,
pools: { pools: {
pool_equip_normal: { kind: 'equip', rule: 'shopEligible', maxTier: 'shopLevel', assumed: true }, pool_equip_normal: { kind: 'equip', rule: 'shopEligible', maxTier: 'shopLevel', assumed: true },