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

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

No files matched your search

+123 -5
View File
@@ -8,7 +8,7 @@ import { makeMatch, give, giveItem, chessOfTier, legalTileFor, checkInvariants a
import { FakeBattle } from '../match/fakeBattle.js';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { createRegistry } from '../../server/match/effectsMeta.js';
import { applyCard, generateDraft } from '../../server/match/choices.js';
import { applyCard, generateDraft, tacticCard } from '../../server/match/choices.js';
import { withBounties } from '../../server/match/waves.js';
import { Battle } from '../../server/sim/Battle.js';
import { bountyOf, battlePlanOf, choiceCardIds, gateOf, hasBattlePart, mapAliases, BUILTIN_REFS } from '../../server/sim/content/choices.js';
@@ -41,6 +41,17 @@ function toDraft(h, r) {
return m.sp;
}
/**
* Lay out the open 战术决策 as exactly the tactic cards `ids` (each its own index). The draft draws every card on its own,
* with replacement (the official 补给 ×2), so a small test pool can repeat one card and miss another: the tests that
* drive a card's effect through g.choice offer their cards this way, after checking what the generator drew.
*/
function offerTactic(m, ids) {
m.sp.cards = ids.map((id, idx) => ({ ...tacticCard(DATA.choices.cards.tactic.find((c) => c.effectId === id)), idx, family: 'tactic' }));
m.markPublic();
return m.sp;
}
/** Pick cards in the draft's (random) order: plan = { playerId: cardId }; bots pick by themselves. */
function draftPicks(h, plan) {
const m = h.m;
@@ -350,7 +361,7 @@ test('道具补给 E2E (co-op): 6 free normal items within the round window; the
m.dispose();
});
test('机密商店 E2E (solo HARD R11): 3 free normal items of any tier I–VI; a second copy merges into the 进阶 item', () => {
test('机密商店 E2E (solo HARD R11): 3 free normal items of the official composition; a second copy merges into the 进阶 item', () => {
const data = dataWith('mode_single_hard', 11, 'shop');
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', humans: 1, seed: 42, data, registry: REG, fake: true });
const m = h.m;
@@ -372,22 +383,122 @@ test('机密商店 E2E (solo HARD R11): 3 free normal items of any tier I–VI;
m.dispose();
});
test('机密商店 draws every tier I–VI with duplicates allowed (generation, 30 drafts)', () => {
test('机密商店 draws the official composition with duplicates allowed (generation, 30 drafts; the evidence is test/match/feedback1-secret-shop.test.js)', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 1, seed: 5, data: dataWith('mode_multi_hard', 11, 'shop'), registry: REG, fake: true });
const coin = DATA.choices.shopDraft.coin;
const tiers = new Set();
let dupes = 0;
for (let i = 0; i < 30; i++) {
const d = generateDraft(h.m.gd, h.m.rngDraft, 11, { stageId: h.m.stageId });
assert.equal(d.family, 'shop');
assert.equal(d.cards.length, 6);
assert.equal(d.cards.filter((c) => DATA.items[c.id].tier === 6).length, 2, 'two tier-VI items');
assert.ok(d.cards.some((c) => c.id === coin), 'a 盟约之币');
for (const c of d.cards) tiers.add(DATA.items[c.id].tier);
if (new Set(d.cards.map((c) => c.id)).size < d.cards.length) dupes++;
}
h.m.dispose();
assert.deepEqual([...tiers].sort(), [1, 2, 3, 4, 5, 6]);
assert.deepEqual([...tiers].sort(), [1, 3, 4, 5, 6], 'tiers III–VI and the tier-I 盟约之币');
assert.ok(dupes > 0, 'duplicates allowed');
});
test('机密商店 E2E (co-op): two identical cards are two cards — both can be taken, by index, and each picker gets the item', () => {
// six 变形同构体: every slot draws the one item `coin` names
const one = Object.values(DATA.items).find((i) => i.name === '变形同构体' && !i.isGolden).id;
const data = dataWith('mode_multi_hard', 3, 'shop');
data.choices = { ...data.choices, shopDraft: { ...data.choices.shopDraft, rounds: [3], slots: Array.from({ length: 6 }, () => ({ coin: 1 })), coin: one } };
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, bots: 2, seed: 43, data, registry: REG, fake: true });
const m = h.m;
h.start();
const sp = toDraft(h, 3);
assert.equal(sp.family, 'shop');
const pub = m.publicView().sp;
assert.deepEqual(pub.cards.map((c) => c.id), Array(6).fill(one), 'six identical offers');
assert.deepEqual(pub.cards.map((c) => c.idx), [0, 1, 2, 3, 4, 5], 'each its own index');
const funds = { p_0: h.ps('p_0').funds, p_1: h.ps('p_1').funds };
const picked = {};
for (let guard = 0; guard < 1000 && m.phase === 'SP_DRAFT'; guard++) {
const pid = m.spTurn();
if (pid !== 'p_0' && pid !== 'p_1') { h.sched.runNext(); continue; }
// a taken card is refused even though an identical one is still free
const taken = Object.keys(m.sp.taken).map(Number);
if (taken.length) assert.deepEqual(m.handle(pid, { t: 'g.choice', idx: taken[0] }), { error: 'SOLD_OUT' }, 'a taken index stays taken');
const idx = m.sp.cards.find((c) => m.sp.taken[c.idx] == null).idx;
assert.deepEqual(m.handle(pid, { t: 'g.choice', idx }), { ok: true });
picked[pid] = idx;
}
assert.equal(m.phase, 'PREP');
assert.notEqual(picked.p_0, picked.p_1, 'two different cards of the same item');
assert.equal(Object.keys(sp.taken).length, 4, 'every player took one of the identical cards');
for (const pid of ['p_0', 'p_1']) {
const ps = h.ps(pid);
assert.ok([...ps.hand, ...ps.temp].some((p) => p && (p.id === one || p.id === DATA.items[one].goldenId)), `${pid} holds the item`);
assert.equal(ps.funds, funds[pid], 'free');
}
matchInvariants(m);
m.dispose();
});
test('战术决策 E2E (co-op): identical cards are separate cards — one pick applies its card exactly once, the twin stays pickable, picks stack', () => {
// a pool of one card: every place draws 补给 (with replacement; the official match 7 R11 offered 补给 twice)
const one = 'allybuff_select_4';
const data = dataWith('mode_multi_normal', 3, 'tactic', { tactic: [one] });
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, bots: 2, seed: 44, data, registry: REG, fake: true });
const m = h.m;
h.start();
const sp = toDraft(h, 3);
assert.equal(sp.family, 'tactic');
const pub = m.publicView().sp;
assert.deepEqual(pub.cards.map((c) => c.id), Array(6).fill(one), 'six 补给');
assert.deepEqual(pub.cards.map((c) => c.idx), [0, 1, 2, 3, 4, 5], 'each its own index');
assert.equal(new Set(sp.cards).size, 6, 'six card objects, not one shared');
const all = [...m.players.values()];
const free0 = new Map(all.map((ps) => [ps.playerId, ps.shop.freeRefreshes]));
const freeNow = () => all.map((ps) => ps.shop.freeRefreshes - free0.get(ps.playerId));
const picked = {};
for (let guard = 0; guard < 1000 && m.phase === 'SP_DRAFT'; guard++) {
const pid = m.spTurn();
if (pid !== 'p_0' && pid !== 'p_1') { h.sched.runNext(); continue; }
const taken = Object.keys(m.sp.taken).map(Number);
if (taken.length) assert.deepEqual(m.handle(pid, { t: 'g.choice', idx: taken[0] }), { error: 'SOLD_OUT' }, 'a taken index stays taken while a twin is free');
const before = freeNow();
const idx = m.sp.cards.find((c) => m.sp.taken[c.idx] == null).idx;
assert.deepEqual(m.handle(pid, { t: 'g.choice', idx }), { ok: true });
picked[pid] = idx;
assert.deepEqual(m.sp.taken[idx], pid, 'that index alone is taken');
assert.equal(Object.values(m.sp.taken).filter((x) => x === pid).length, 1, 'one card per pick');
// 补给 is a team card: +2 free refreshes for every alive player, once — not once per identical card
assert.deepEqual(freeNow().map((x, i) => x - before[i]), all.map(() => 2), `${pid}: one 补给 applied once`);
}
assert.equal(m.phase, 'PREP');
assert.notEqual(picked.p_0, picked.p_1, 'two different cards of the same tactic');
assert.equal(Object.keys(sp.taken).length, 4, 'every player took one of the identical cards');
assert.deepEqual(freeNow(), all.map(() => 8), 'four picks of 补给 stack (+2 each)');
matchInvariants(m);
m.dispose();
});
test('战术决策 E2E (solo): of three identical personal cards the one picked applies once; the other two stay untaken', () => {
const one = 'allybuff_select_6'; // 升华: the next purchased operator becomes elite
const data = dataWith('mode_single_normal', 9, 'tactic', { tactic: [one] });
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', humans: 1, seed: 45, data, registry: REG, fake: true });
const m = h.m;
h.start();
const sp = toDraft(h, 9);
assert.deepEqual(sp.cards.map((c) => [c.idx, c.id]), [[0, one], [1, one], [2, one]]);
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 1 }), { ok: true });
assert.deepEqual(sp.taken, { 1: 'p_0' });
assert.deepEqual(sp.picks, { p_0: 1 });
const refs = h.ps('p_0').effects.filter((e) => e.key === BUILTIN_REFS.eliteChess);
assert.equal(refs.length, 1, 'one 升华');
assert.equal(refs[0].counter, 1);
for (let guard = 0; guard < 50 && m.phase === 'SP_DRAFT'; guard++) h.sched.runNext();
assert.equal(m.phase, 'PREP');
assert.equal(h.ps('p_0').effects.filter((e) => e.key === BUILTIN_REFS.eliteChess).length, 1, 'still one after the draft closed');
matchInvariants(m);
m.dispose();
});
// =====================================================================================================================
// 战术决策 — prep-side cards
@@ -401,7 +512,10 @@ test('战术决策 E2E (co-op 3): team cards reach every teammate (AI included),
const sp = toDraft(h, 3);
assert.equal(sp.family, 'tactic');
assert.equal(m.publicView().sp.name, '战术决策');
assert.deepEqual(sp.cards.map((c) => c.id).sort(), [...TEAM_PREP].sort());
assert.equal(m.publicView().sp.desc, '进行协同调整,做好迎战准备。', 'the official header');
assert.equal(sp.cards.length, 6);
assert.ok(sp.cards.every((c) => TEAM_PREP.includes(c.id)), 'drawn from the pool (with replacement)');
offerTactic(m, TEAM_PREP);
const view = m.publicView().sp.cards;
for (const c of view) assert.equal(c.team, DATA.choices.cards.tactic.find((x) => x.effectId === c.id).team, `${c.id} team flag`);
const all = [...m.players.values()];
@@ -492,6 +606,7 @@ test('战术决策 升华 / 整备: the next purchased operator becomes elite, t
const m = h.m;
h.start();
toDraft(h, 9);
offerTactic(m, ['allybuff_select_5', 'allybuff_select_6', 'allybuff_select_3']);
draftPicks(h, { p_0: 'allybuff_select_6' });
const ps = h.ps('p_0');
const ref = ps.effects.find((e) => e.key === BUILTIN_REFS.eliteChess);
@@ -535,6 +650,7 @@ test('战术决策 E2E: 火力 / 锐利 evaluate the 手牌区 at every prep end
const m = h.m;
h.start();
const sp = toDraft(h, 3);
offerTactic(m, ['allybuff_select_13', 'allybuff_select_19']);
const [a, b] = sp.order;
draftPicks(h, { [a]: 'allybuff_select_13', [b]: 'allybuff_select_19' });
const A = h.ps(a);
@@ -597,6 +713,7 @@ test('战术决策 E2E (real battles): 排斥 debuffs every enemy of the picker\
};
h.start();
const sp = toDraft(h, 3);
offerTactic(m, [id, 'allybuff_select_3']);
const [a, b] = sp.order;
draftPicks(h, { [a]: id, [b]: 'allybuff_select_3' });
assert.ok(h.ps(a).effects.some((e) => e.key === `choice:${id}` && e.iconId === 'icon_enemy_debuff'));
@@ -747,6 +864,7 @@ test('模拟战场演变 E2E (act1 m01): the card clears the picker\'s crates fo
h.setStage('act1autochess_m01');
const sp = toDraft(h, 3);
assert.ok(!sp.cards.some((c) => c.id === 'map_m02_1'), 'terrain cards only for the match stage');
offerTactic(m, ['map_m01_1', 'allybuff_select_3']);
const [a, b] = sp.order;
draftPicks(h, { [a]: 'map_m01_1', [b]: 'allybuff_select_3' });
const A = h.ps(a);
+3 -1
View File
@@ -625,7 +625,9 @@ test('player feedback after 0.1.0 (DESIGN §21, v0.1.1): every report mapped, th
// settled: the tactician re-orientation (user-confirmed) and the screenshot-based bounty structures
const s2120 = sub(20);
assert.match(s2120, /A tactician re-oriented in place \(§21\.3\) \| a range-bound summon still inside the new range stays[^\n]*user-confirmed 2026-10-03/);
assert.match(s2120, /Bounty drafts \(§21\.2\) \| the official structure of each round from the user's 33 screenshots/);
assert.match(s2120, /Bounty drafts \(§21\.2\) \| the official structure of each round from the user's 66 screenshots of 22 matches/);
assert.match(s2120, /机密商店 \/ 战术决策 repeats \(§21\.2\) \| the same card can be offered twice/);
assert.ok(!/the remake's never do/.test(sub(2)), '§21.2: the 机密商店 and the 战术决策 repeat');
assert.match(s2120, /MULTI_ROUND_BOUNTY_BATTLES = null/);
// §21.2 = §7 = §20.6 superseded = the data
assert.match(sub(2), /initial 42, boss 20, hunter 24 \(86 drafted\)/);
+57 -1
View File
@@ -1,7 +1,8 @@
{
"_doc": "Official 机变 drafts at the start of rounds 3, 9 and 11, read from screenshots of 11 official co-op matches collected by the user (2026-10-03; player names left out). The cards are in screen order (left to right, top to bottom). A bounty card is [title, enemy as printed, coins, battles] (battles 2 = 接下来两场作战, 1 = 下场作战 / 下场战斗; 源石虫·特训 pays 0); a 机密商店 card is [item, tier]; a 战术决策 card is [name]. Names are converted to Simplified Chinese. Matches 1 and 2 come from the Traditional-Chinese client, 3-11 from Simplified-Chinese video captures. Every match is a 4-player co-op match of 绝境 or 终极 (the modes whose SP rounds are 3, 9 and 11; the HUD does not say which). Used by test/match/feedback1-bounty.test.js.",
"_doc": "Official 机变 drafts at the start of rounds 3, 9 and 11, read from screenshots of 22 official co-op matches collected by the user (33 on 2026-10-03, 33 more the same day; player names left out). The cards are in screen order (left to right, top to bottom). A bounty card is [title, enemy as printed, coins, battles] (battles 2 = 接下来两场作战, 1 = 下场作战 / 下场战斗; 源石虫·特训 pays 0); a 机密商店 card is [item, tier]; a 战术决策 card is [name]. Names are converted to Simplified Chinese. Matches 1 and 2 come from the Traditional-Chinese client, 3-22 from Simplified-Chinese video captures of one uploader; the user dates the videos to the end of March 2026, all of one season (卫戍协议:盟约 下半, act2autochess). Every match is a 4-player co-op match of 绝境 or 终极 (the modes whose SP rounds are 3, 9 and 11; the HUD does not say which). A card another player has taken stays in its slot, greyed with the taker's avatar (match 17 R3: 迷茫的修理小助手; match 21 R9: 大鲍勃 and 杰斯顿·威廉姆斯): one shared draft, one pick each in turn. `boards` = the colour of the battlefield behind the overlay (the same in all three rounds of a match). Used by test/match/feedback1-bounty.test.js.",
"season": "act2autochess",
"aliases": { "深池重甲卫队队长": "深池重甲卫士队长" },
"boards": { "_doc": "grey = dark grey metal tiles with blue / red frames; tan = sandy / beige tiles (possibly more than one map)", "grey": [8, 9, 11, 13, 16, 18, 19], "tan": [1, 2, 3, 4, 5, 6, 7, 10, 12, 14, 15, 17, 20, 21, 22] },
"matches": {
"1": {
"3": { "family": "bounty", "cards": [["悬赏·损伤I", "临时收音师", 1, 2], ["悬赏·损伤III", "主角阵营角色", 3, 2], ["悬赏·特异I", "迷茫的修理小助手", 1, 2], ["悬赏·特异II", "寻仇者", 2, 2], ["悬赏·特异II", "圣堂剑士", 2, 2], ["悬赏·特异I", "高能源石虫", 1, 2]] },
@@ -57,6 +58,61 @@
"3": { "family": "bounty", "cards": [["悬赏·持续I", "萨卡兹枯朽战士组长", 1, 2], ["悬赏·损伤I", "底海滑动者", 1, 2], ["悬赏·频次III", "清明", 3, 2], ["悬赏·折射II", "深池暗影术师", 2, 2], ["悬赏·飞行I", "妖怪", 1, 2], ["悬赏·隐匿II", "隐形术师", 2, 2]] },
"9": { "family": "bounty", "cards": [["杰斯顿·威廉姆斯·悬赏", "杰斯顿·威廉姆斯", 4, 1], ["弑君者·悬赏", "弑君者", 1, 1], ["源石虫·特训", "源石虫", 0, 1], ["W·悬赏", "W", 1, 1], ["碎骨·悬赏", "碎骨", 1, 1], ["陷落雪祀·悬赏", "陷落雪祀", 3, 1]] },
"11": { "family": "bounty", "cards": [["悬赏·特异II", "拥霜羽兽", 2, 1], ["悬赏·隐匿I", "重弩突袭者", 1, 1], ["悬赏·损伤I", "心虚设计师", 1, 1], ["悬赏·特异III", "乌顶巨角卢鲁", 3, 1], ["悬赏·频次I", "沏虹", 1, 1], ["悬赏·持续I", "假想敌:蚀裂", 1, 1]] }
},
"12": {
"3": { "family": "bounty", "cards": [["悬赏·损伤II", "控潮术师", 2, 2], ["悬赏·频次I", "磨砻", 1, 2], ["悬赏·损伤II", "无胄盟清扫小队", 2, 2], ["悬赏·持续I", "萨卡兹枯朽战士", 1, 2], ["悬赏·飞行I", "妖怪", 1, 2], ["悬赏·折射III", "深池伙友卫队", 3, 2]] },
"9": { "family": "bounty", "cards": [["碎骨·悬赏", "碎骨", 1, 1], ["大鲍勃·悬赏", "大鲍勃", 2, 1], ["弑君者·悬赏", "弑君者", 1, 1], ["W·悬赏", "W", 1, 1], ["源石虫·特训", "源石虫", 0, 1], ["杰斯顿·威廉姆斯·悬赏", "杰斯顿·威廉姆斯", 4, 1]] },
"11": { "family": "bounty", "cards": [["悬赏·折射II", "愤怒的守墓石像", 2, 1], ["悬赏·特异III", "萨卡兹骸骨拷打者", 3, 1], ["悬赏·飞行I", "法术大师A2", 1, 1], ["悬赏·损伤I", "心虚设计师", 1, 1], ["悬赏·损伤II", "灼藤", 2, 1], ["悬赏·频次I", "沏虹", 1, 1]] }
},
"13": {
"3": { "family": "bounty", "cards": [["悬赏·损伤I", "底海滑动者", 1, 2], ["悬赏·频次I", "磨砻", 1, 2], ["悬赏·持续II", "逐腐兽", 2, 2], ["悬赏·折射II", "深池暗影术师", 2, 2], ["悬赏·特异III", "自制投石机", 3, 2], ["悬赏·隐匿I", "隐形弩手", 1, 2]] },
"9": { "family": "bounty", "cards": [["陷落雪祀·悬赏", "陷落雪祀", 3, 1], ["源石虫·特训", "源石虫", 0, 1], ["碎骨·悬赏", "碎骨", 1, 1], ["弑君者·悬赏", "弑君者", 1, 1], ["W·悬赏", "W", 1, 1], ["大鲍勃·悬赏", "大鲍勃", 2, 1]] },
"11": { "family": "bounty", "cards": [["悬赏·频次I", "沏虹", 1, 1], ["悬赏·折射II", "深池重甲卫队队长", 2, 1], ["悬赏·特异III", "狂暴宿主组长", 3, 1], ["悬赏·特异II", "拥霜羽兽", 2, 1], ["悬赏·持续II", "尖端萨卡兹枯朽战车", 2, 1], ["悬赏·隐匿I", "重弩突袭者", 1, 1]] }
},
"14": {
"3": { "family": "bounty", "cards": [["悬赏·特异I", "新手祭司学徒", 1, 2], ["悬赏·特异I", "冒失的小弟", 1, 2], ["悬赏·特异II", "骇笑看客", 2, 2], ["悬赏·特异I", "雪孩子", 1, 2], ["悬赏·特异II", "水遁忍者", 2, 2], ["悬赏·特异III", "自制投石机", 3, 2]] },
"9": { "family": "bounty", "cards": [["大鲍勃·悬赏", "大鲍勃", 2, 1], ["杰斯顿·威廉姆斯·悬赏", "杰斯顿·威廉姆斯", 4, 1], ["源石虫·特训", "源石虫", 0, 1], ["弑君者·悬赏", "弑君者", 1, 1], ["碎骨·悬赏", "碎骨", 1, 1], ["“自在”·悬赏", "“自在”", 4, 1]] },
"11": { "family": "bounty", "cards": [["悬赏·飞行I", "法术大师A2", 1, 1], ["悬赏·特异II", "异质裂兽·α", 2, 1], ["悬赏·特异III", "伊利昂的木驮兽", 3, 1], ["悬赏·隐匿II", "家族暗影灭迹人", 2, 1], ["悬赏·特异I", "转译基底·α", 1, 1], ["悬赏·持续I", "假想敌:蚀裂", 1, 1]] }
},
"15": {
"3": { "family": "bounty", "cards": [["悬赏·特异II", "咸鳞汁推荐者", 2, 2], ["悬赏·折射I", "深池侦察犬pro", 1, 2], ["悬赏·损伤III", "反派阵营角色", 3, 2], ["悬赏·飞行II", "吉兆飞鳞", 2, 2], ["悬赏·特异I", "节日气球", 1, 2], ["悬赏·特异I", "简饲源石虫", 1, 2]] },
"9": { "family": "bounty", "cards": [["W·悬赏", "W", 1, 1], ["碎骨·悬赏", "碎骨", 1, 1], ["大鲍勃·悬赏", "大鲍勃", 2, 1], ["弑君者·悬赏", "弑君者", 1, 1], ["源石虫·特训", "源石虫", 0, 1], ["杰斯顿·威廉姆斯·悬赏", "杰斯顿·威廉姆斯", 4, 1]] },
"11": { "family": "bounty", "cards": [["悬赏·持续I", "假想敌:蚀裂", 1, 1], ["悬赏·隐匿I", "重弩突袭者", 1, 1], ["悬赏·频次I", "沏虹", 1, 1], ["悬赏·特异I", "转译基底·α", 1, 1], ["悬赏·飞行II", "枯朽萃聚使徒", 2, 1], ["悬赏·损伤II", "灼藤", 2, 1]] }
},
"16": {
"3": { "family": "bounty", "cards": [["悬赏·持续III", "萨卡兹枯朽战车", 3, 2], ["悬赏·特异I", "潜伏者", 1, 2], ["悬赏·频次I", "磨砻", 1, 2], ["悬赏·折射I", "深池方阵战士", 1, 2], ["悬赏·飞行II", "暴鸰", 2, 2], ["悬赏·损伤II", "控潮术师", 2, 2]] },
"9": { "family": "bounty", "cards": [["杰斯顿·威廉姆斯·悬赏", "杰斯顿·威廉姆斯", 4, 1], ["陷落雪祀·悬赏", "陷落雪祀", 3, 1], ["源石虫·特训", "源石虫", 0, 1], ["弑君者·悬赏", "弑君者", 1, 1], ["W·悬赏", "W", 1, 1], ["碎骨·悬赏", "碎骨", 1, 1]] },
"11": { "family": "bounty", "cards": [["悬赏·损伤I", "心虚设计师", 1, 1], ["悬赏·损伤II", "灼藤", 2, 1], ["悬赏·隐匿II", "家族暗影灭迹人", 2, 1], ["悬赏·折射II", "愤怒的守墓石像", 2, 1], ["悬赏·飞行I", "法术大师A2", 1, 1], ["悬赏·特异III", "萨卡兹骸骨拷打者", 3, 1]] }
},
"17": {
"3": { "family": "bounty", "cards": [["悬赏·特异II", "圣堂剑士", 2, 2], ["悬赏·特异I", "迷茫的修理小助手", 1, 2], ["悬赏·特异II", "寻仇者", 2, 2], ["悬赏·损伤I", "临时收音师", 1, 2], ["悬赏·损伤III", "主角阵营角色", 3, 2], ["悬赏·特异I", "高能源石虫", 1, 2]] },
"9": { "family": "bounty", "cards": [["碎骨·悬赏", "碎骨", 1, 1], ["“自在”·悬赏", "“自在”", 4, 1], ["陷落雪祀·悬赏", "陷落雪祀", 3, 1], ["弑君者·悬赏", "弑君者", 1, 1], ["源石虫·特训", "源石虫", 0, 1], ["大鲍勃·悬赏", "大鲍勃", 2, 1]] },
"11": { "family": "bounty", "cards": [["悬赏·飞行II", "枯朽萃聚使徒", 2, 1], ["悬赏·特异III", "泥岩巨像", 3, 1], ["悬赏·持续I", "假想敌:蚀裂", 1, 1], ["悬赏·隐匿I", "重弩突袭者", 1, 1], ["悬赏·频次II", "新硎", 2, 1], ["悬赏·折射II", "深池重甲卫队队长", 2, 1]] }
},
"18": {
"3": { "family": "bounty", "cards": [["悬赏·损伤III", "术师快艇", 3, 2], ["悬赏·隐匿I", "隐形弩手", 1, 2], ["悬赏·频次I", "俗心", 1, 2], ["悬赏·飞行I", "妖怪", 1, 2], ["悬赏·折射II", "深池暗影术师", 2, 2], ["悬赏·频次II", "烹泉", 2, 2]] },
"9": { "family": "bounty", "cards": [["杰斯顿·威廉姆斯·悬赏", "杰斯顿·威廉姆斯", 4, 1], ["“自在”·悬赏", "“自在”", 4, 1], ["源石虫·特训", "源石虫", 0, 1], ["大鲍勃·悬赏", "大鲍勃", 2, 1], ["“鼠王”·悬赏", "“鼠王”", 2, 1], ["弑君者·悬赏", "弑君者", 1, 1]] },
"11": { "family": "tactic", "cards": [["整备"], ["玛恩纳的盟誓"], ["斯卡蒂的盟誓"], ["萨尔贡驰援"], ["财富"], ["升华"]] }
},
"19": {
"3": { "family": "bounty", "cards": [["悬赏·隐匿I", "隐形弩手", 1, 2], ["悬赏·持续II", "逐腐兽", 2, 2], ["悬赏·频次I", "磨砻", 1, 2], ["悬赏·损伤I", "底海滑动者", 1, 2], ["悬赏·特异III", "自制投石机", 3, 2], ["悬赏·折射II", "深池暗影术师", 2, 2]] },
"9": { "family": "bounty", "cards": [["“自在”·悬赏", "“自在”", 4, 1], ["大鲍勃·悬赏", "大鲍勃", 2, 1], ["陷落雪祀·悬赏", "陷落雪祀", 3, 1], ["W·悬赏", "W", 1, 1], ["碎骨·悬赏", "碎骨", 1, 1], ["杰斯顿·威廉姆斯·悬赏", "杰斯顿·威廉姆斯", 4, 1]] },
"11": { "family": "bounty", "cards": [["悬赏·特异III", "伊利昂的木驮兽", 3, 1], ["悬赏·隐匿II", "家族暗影灭迹人", 2, 1], ["悬赏·特异I", "转译基底·α", 1, 1], ["悬赏·持续I", "假想敌:蚀裂", 1, 1], ["悬赏·飞行I", "法术大师A2", 1, 1], ["悬赏·特异II", "异质裂兽·α", 2, 1]] }
},
"20": {
"3": { "family": "bounty", "cards": [["悬赏·隐匿II", "隐形术师", 2, 2], ["悬赏·损伤I", "底海滑动者", 1, 2], ["悬赏·频次III", "清明", 3, 2], ["悬赏·持续I", "萨卡兹枯朽战士组长", 1, 2], ["悬赏·飞行I", "妖怪", 1, 2], ["悬赏·折射II", "深池暗影术师", 2, 2]] },
"9": { "family": "bounty", "cards": [["“庞贝”·悬赏", "“庞贝”", 2, 1], ["“复仇者”·悬赏", "“复仇者”", 6, 1], ["源石虫·特训", "源石虫", 0, 1], ["萨卡兹百夫长·悬赏", "萨卡兹百夫长", 2, 1], ["澪·悬赏", "澪", 3, 1], ["W·悬赏", "W", 1, 1]] },
"11": { "family": "tactic", "cards": [["阿戈尔驰援"], ["补给"], ["列装"], ["叙拉古驰援"], ["银灰的盟誓"], ["莫斯提马的盟誓"]] }
},
"21": {
"3": { "family": "bounty", "cards": [["悬赏·持续II", "逐腐兽", 2, 2], ["悬赏·特异III", "自制投石机", 3, 2], ["悬赏·折射II", "深池暗影术师", 2, 2], ["悬赏·隐匿I", "隐形弩手", 1, 2], ["悬赏·频次I", "磨砻", 1, 2], ["悬赏·损伤I", "底海滑动者", 1, 2]] },
"9": { "family": "bounty", "cards": [["弑君者·悬赏", "弑君者", 1, 1], ["碎骨·悬赏", "碎骨", 1, 1], ["大鲍勃·悬赏", "大鲍勃", 2, 1], ["“鼠王”·悬赏", "“鼠王”", 2, 1], ["杰斯顿·威廉姆斯·悬赏", "杰斯顿·威廉姆斯", 4, 1], ["W·悬赏", "W", 1, 1]] },
"11": { "family": "bounty", "cards": [["悬赏·特异III", "温顺的武装大驮兽", 3, 1], ["悬赏·折射II", "深池重甲卫队队长", 2, 1], ["悬赏·持续II", "尖端萨卡兹枯朽战车", 2, 1], ["悬赏·特异II", "异质裂兽·α", 2, 1], ["悬赏·频次I", "沏虹", 1, 1], ["悬赏·飞行I", "法术大师A2", 1, 1]] }
},
"22": {
"3": { "family": "bounty", "cards": [["悬赏·特异II", "咸鳞汁推荐者", 2, 2], ["悬赏·飞行II", "吉兆飞鳞", 2, 2], ["悬赏·特异I", "简饲源石虫", 1, 2], ["悬赏·特异I", "节日气球", 1, 2], ["悬赏·折射I", "深池侦察犬pro", 1, 2], ["悬赏·损伤III", "反派阵营角色", 3, 2]] },
"9": { "family": "bounty", "cards": [["澪·悬赏", "澪", 3, 1], ["弑君者·悬赏", "弑君者", 1, 1], ["喷气人·悬赏", "喷气人", 3, 1], ["W·悬赏", "W", 1, 1], ["纠缠藤蔓·悬赏", "纠缠藤蔓", 3, 1], ["大鲍勃·悬赏", "大鲍勃", 2, 1]] },
"11": { "family": "bounty", "cards": [["悬赏·特异III", "温顺的武装大驮兽", 3, 1], ["悬赏·频次I", "沏虹", 1, 1], ["悬赏·飞行I", "法术大师A2", 1, 1], ["悬赏·特异II", "异质裂兽·α", 2, 1], ["悬赏·持续II", "尖端萨卡兹枯朽战车", 2, 1], ["悬赏·损伤II", "灼藤", 2, 1]] }
}
},
"extra": {
+13 -5
View File
@@ -116,16 +116,24 @@ test('绝境 Final Assault: both pair fields drain the one pool, every hit exact
m.dispose();
});
test('终极 Final Assault vs 假想敌:胄 (seed 12): both players\' 奥术 never multiply on the leader; a drone costs it 2 % of the pool', () => {
test('终极 Final Assault vs 假想敌:胄 (seeded bot match): both players\' 奥术 never multiply on the leader; a drone costs it 2 % of the pool', () => {
// DESIGN §20.10: one 奥术 instance per target (the strongest — PRTS 作战机制 同名buff, 巴哈姆特 12316 "共享型buff會跟對面搶");
// 死亡集群's "最大生命值2%" = the leader's shown max HP, the pool (DRONE_LINK_BASE 'pool' [ASSUMED]): 72 000 at 终极
// seed 12: since the elite-to-board merge (DESIGN §20.11) the bots' boards differ; seed 7 no longer pairs two 奥术 players
const h = toFinalAssault({ difficulty: 'ABYSS', seed: 12, bossId: 'boss_1' });
// the bots' boards follow every draw of the match (the elite-to-board merge, DESIGN §20.11, moved seed 7 to 12; the
// 战术决策 drawn with replacement moved 12 on): the first of these seeds whose bots pair two 奥术 players
const pairOf = (m) => m.fields.filter((f) => f.battle).find((f) => f.players.length === 2 && f.players.every((pid) => f.battle.getPlayer(pid).bonds.arcaneShip?.active));
let h = null;
for (const seed of [13, 7, 11, 21, 23]) {
h = toFinalAssault({ difficulty: 'ABYSS', seed, bossId: 'boss_1' });
if (pairOf(h.m)) break;
h.m.dispose();
h = null;
}
assert.ok(h, 'precondition: a pair field where both players run 奥术 (bot lineups of one of the seeds)');
const m = h.m;
const fields = m.fields.filter((f) => f.battle);
const bb = bondBb('arcaneShip');
const both = fields.find((f) => f.players.length === 2 && f.players.every((pid) => f.battle.getPlayer(pid).bonds.arcaneShip?.active));
assert.ok(both, 'precondition: a pair field where both players run 奥术 (bot lineups of seed 12)');
const both = pairOf(m);
const allowed = [];
for (const pid of both.players) {
const b = both.battle.getPlayer(pid).bonds.arcaneShip;
+176 -103
View File
@@ -1,15 +1,18 @@
// Player feedback after 0.1.0, report #2 (workstream WB): "本来应该后期出的悬赏的怪物在前期的悬赏就出现了,导致选了打不过".
// The user then collected the official 悬赏决策 of rounds 3, 9 and 11 in 11 official co-op matches (33 screenshots, 绝境 /
// 终极; the readings are test/fixtures/official-bounty-drafts.json, player names left out). They settle the draft rules
// (tools/build-data.mjs BOUNTY_INITIAL_SETS, server/match/choices.js bountyDraftCards):
// R3 six "接下来两场作战" cards, 3 × I + 2 × II + 1 × III, one of the official fixed sets (a whole series 17 / 18 / 19,
// or one card from each of 6 of the series 10–15 / 20) — 7 of the 10 events seen;
// R9 boss bounties + 源石虫·特训: a group of named bosses that come together + cheap ones to 6;
// R11 悬赏决策 / 机密商店 / 战术决策 (never 道具补给); its bounty: a group (one 特异III giant + 1–4 tier-II "下场战斗" cards,
// always together — matches 1 and 6 share the whole group) + tier-I cards to 6, at most one per faction series;
// The user then collected the official 机变 of rounds 3, 9 and 11 in 22 official co-op matches (66 screenshots, 绝境 /
// 终极, one season — videos of the end of March 2026; the readings are test/fixtures/official-bounty-drafts.json, player
// names left out). They settle the draft rules (tools/build-data.mjs BOUNTY_INITIAL_SETS, server/match/choices.js
// bountyDraftCards): a 悬赏决策 event is a fixed card list and the draft shows 6 different cards of it —
// R3 a set of six "接下来两场作战" cards (all six), 3 × I + 2 × II + 1 × III: 9 of the 10 events seen;
// R9 a group of up to 9 boss bounties / 源石虫·特训: 6 groups (9, 9, 9, 9, 8, 6 cards) for the 6 events, the 鼠王
// group in 14 of 22 matches;
// R11 悬赏决策 14 / 机密商店 4 / 战术决策 4 / 道具补给 0; its bounty: a list of 7 "下场战斗" cards (one 特异III giant),
// one per faction series — 7 lists seen, three of them whole (each draft leaves out one card), picked uniformly
// (no list built from nothing; which of the 15 bounty_hunter events R11 fires is open);
// no draft shows a multi-round card, a pre-series card (enemyeffect_3_*), 战术特训 or the 鸭爵 set; no card twice.
// Each card's enemy is fixed by its effect; the title only names category and tier (悬赏·损伤I = 底海滑动者 in
// enemyeffect_12_4, 临时收音师 in enemyeffect_18_1). Real data, real draft code, the real match path for the players' case.
// The 机密商店 is in test/match/feedback1-secret-shop.test.js.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
@@ -27,57 +30,39 @@ const SPEC = DATA.choices.bountyDrafts;
const COOP_BOUNTY_MODES = ['mode_multi_normal', 'mode_multi_hard', 'mode_multi_abyss'];
const isBoss = (key) => DATA.enemies[key] && DATA.enemies[key].rank === 'BOSS';
const sameSet = (a, b) => a.length === b.length && [...a].sort().join() === [...b].sort().join();
const subset = (a, b) => a.every((x) => b.includes(x));
const EE = (s) => `enemyeffect_${s}`;
/** The data card of a screenshot reading [title, enemy, coins, battles] (by title, the enemy in the text, coins, battles). */
function resolve([title, enemy, coin, battles]) {
const name = OFFICIAL.aliases[enemy] || enemy;
const hits = CARDS.filter((c) => c.name === title && c.coin === coin && (c.desc.includes(`1只${name}`) || c.desc.includes(`1个${name}`))
// "1只萨卡兹枯朽战士," — the enemy's whole name (萨卡兹枯朽战士 is not 萨卡兹枯朽战士组长)
const hits = CARDS.filter((c) => c.name === title && c.coin === coin && (c.desc.includes(`1只${name},`) || c.desc.includes(`1个${name},`))
&& (battles === 2 ? c.rounds === 2 : c.rounds === 1));
assert.equal(hits.length, 1, `${title} ${enemy} ${coin}: ${hits.map((c) => c.effectId).join(', ') || 'no card'}`);
return hits[0].effectId;
}
/** Every official draft of the fixture: [{ where, round, family, ids? }] (ids for 悬赏决策). */
/** Every official draft of the fixture: [{ where, match, round, family, ids? }] (ids for 悬赏决策). */
function officialDrafts() {
const out = [];
for (const [match, rounds] of Object.entries(OFFICIAL.matches)) {
for (const [round, d] of Object.entries(rounds)) out.push({ where: `match ${match} R${round}`, round: Number(round), family: d.family, ids: d.family === 'bounty' ? d.cards.map(resolve) : null });
for (const [round, d] of Object.entries(rounds)) out.push({ where: `match ${match} R${round}`, match: Number(match), round: Number(round), family: d.family, ids: d.family === 'bounty' ? d.cards.map(resolve) : null });
}
for (const [key, d] of Object.entries(OFFICIAL.extra)) out.push({ where: key, round: 9, family: d.family, ids: d.cards.map(resolve) });
for (const [key, d] of Object.entries(OFFICIAL.extra)) out.push({ where: key, match: key, round: 9, family: d.family, ids: d.cards.map(resolve) });
return out;
}
const tiersOf = (ids) => ids.map((id) => CARD.get(id).tier).sort().join('');
/** The rule of the unseen R3 slots (and of the seen mixed sets): one card from each of 6 of the series, I I I II II III. */
function mixedRule(ids) {
const mx = SPEC.initial.mixed;
const series = ids.map((id) => CARD.get(id).series);
return ids.length === mx.count && ids.every((id) => CARD.get(id).draftPool === 'initial') && new Set(series).size === series.length
&& series.every((s) => mx.series.includes(s)) && tiersOf(ids) === mx.tiers.slice().sort().join('');
}
/** Which official R3 structure a set of ids is: 'set:<i>' (a seen set), 'mixed' (the unseen-slot rule) or null. */
function initialFit(ids) {
const i = SPEC.initial.sets.findIndex((s) => sameSet(s.cards, ids));
if (i >= 0) return `set:${i}`;
return mixedRule(ids) ? 'mixed' : null;
}
/** The R9 groups a set of ids fits (its featured bosses all there, the rest from the group's fill). */
const bossFits = (ids) => SPEC.boss.templates.map((t, i) => [t, i]).filter(([t]) => ids.length === SPEC.boss.count
&& t.featured.every((id) => ids.includes(id)) && ids.filter((id) => !t.featured.includes(id)).every((id) => t.fill.includes(id))).map(([, i]) => i);
/** The group of `spec` whose `seen` lists the draft's match. */
const groupOf = (spec, d) => spec.groups.filter((g) => g.seen.includes(d.match));
/** At most one card per faction series 10–15 (every official R11 draft). */
function onePerSeries(ids) {
const series = ids.map((id) => CARD.get(id).series).filter((s) => SPEC.hunter.onePerSeries.includes(s));
const series = ids.map((id) => CARD.get(id).series).filter((s) => SPEC.hunter.rule.onePerSeries.includes(s));
return new Set(series).size === series.length;
}
/** The seen R11 groups a set of ids fits (the group's giant + tier-II cards all there, the rest from its fill). */
const hunterFits = (ids) => SPEC.hunter.templates.map((t, i) => [t, i]).filter(([t]) => ids.length === SPEC.hunter.count && onePerSeries(ids)
&& t.featured.every((id) => ids.includes(id)) && ids.filter((id) => !t.featured.includes(id)).every((id) => t.fill.includes(id))).map(([, i]) => i);
/** The R11 shape an unseen slot is built by: one tier-III giant, 1–4 tier-II group cards, tier-I fill, one per faction series. */
function hunterShape(ids) {
const r = SPEC.hunter.rule;
const giants = ids.filter((id) => r.giants.includes(id)).length;
const group = ids.filter((id) => r.groupCards.includes(id)).length;
const fill = ids.filter((id) => r.fill.includes(id)).length;
return ids.length === SPEC.hunter.count && giants === 1 && r.groupSizes.includes(group) && giants + group + fill === ids.length && onePerSeries(ids);
const giantsIn = (ids) => ids.filter((id) => SPEC.hunter.rule.giants.includes(id)).length;
/** The weights the data gives a group's cards: 1 + the official drafts of that group the card appeared in. */
function expectedWeights(g, drafts) {
return g.cards.map((id) => 1 + drafts.filter((d) => d.ids.includes(id)).length);
}
/** Bounty drafts generated for `modeId` at `round` over `seeds` seeds. */
@@ -91,13 +76,23 @@ function drafts(modeId, round, seeds, data = DATA) {
return out;
}
/** An unseen-slot R3 set (`rule`): two-battle cards of its series, I I I II II III, ≤ perSeries a series, the III preferred. */
function initialRule(ids) {
const r = SPEC.initial.rule;
const series = ids.map((id) => CARD.get(id).series);
return ids.length === 6 && ids.every((id) => CARD.get(id).draftPool === 'initial' && r.series.includes(CARD.get(id).series))
&& series.every((s) => series.filter((x) => x === s).length <= r.perSeries) && tiersOf(ids) === r.tiers.slice().sort().join('')
&& ids.some((id) => r.prefer.includes(id));
}
test('#2 screenshots → data: every card read is one act2autochess (下半) effect, whose enemy_id is the enemy the card brings', (t) => {
const seen = new Set();
for (const d of officialDrafts()) for (const id of d.ids || []) seen.add(id);
assert.equal(seen.size, 72, 'distinct cards in the 27 bounty drafts: 36 at R3, 20 at R9, 16 at R11');
assert.equal(seen.size, 81, 'distinct cards in the 59 bounty drafts: 40 at R3, 20 at R9, 21 at R11');
// the R3(1) question: the title names category and tier only — two different 悬赏·损伤I cards, each with its own enemy
assert.equal(resolve(['悬赏·损伤I', '临时收音师', 1, 2]), 'enemyeffect_18_1');
assert.equal(resolve(['悬赏·损伤I', '底海滑动者', 1, 2]), 'enemyeffect_12_4');
assert.equal(resolve(['悬赏·持续I', '萨卡兹枯朽战士', 1, 2]), 'enemyeffect_20_1', 'not 萨卡兹枯朽战士组长 (13_4)');
assert.equal(CARD.get('enemyeffect_18_1').enemyKey, 'enemy_10094_crstf');
assert.equal(CARD.get('enemyeffect_12_4').enemyKey, 'enemy_1148_dssbr');
const p = new URL('../../.cache/gamedata/excel/activity_table.json', import.meta.url);
@@ -112,40 +107,47 @@ test('#2 screenshots → data: every card read is one act2autochess (下半) eff
assert.ok(seen.has(id) && !act.act1autochess.effectInfoDataDict[id], `${id} is 下半-only`);
}
assert.match(act.act1autochess.effectInfoDataDict.enemyeffect_11_6.effectDesc, /沉沙/);
// all 22 matches are one season: 终极 (mode_multi_abyss) opened on 2026-03-27 16:00 (UTC+8), before the end-of-March videos
assert.equal(act.act2autochess.modeDataDict.mode_multi_abyss.startTime, Date.UTC(2026, 2, 27, 8) / 1000);
});
test('#2 R3 (11 of 11 official drafts): one of the official sets of six "接下来两场作战" cards, 3 × I + 2 × II + 1 × III', () => {
test('#2 R3 (22 of 22 official drafts): one of the official sets of six "接下来两场作战" cards, 3 × I + 2 × II + 1 × III — 9 of the 10 seen', () => {
const r3 = officialDrafts().filter((d) => d.round === 3);
assert.equal(r3.length, 11);
const hits = SPEC.initial.sets.map(() => 0);
assert.equal(r3.length, 22);
const hits = SPEC.initial.groups.map(() => []);
for (const d of r3) {
assert.equal(d.family, 'bounty', `${d.where}: R3 is a 悬赏决策`);
assert.ok(d.ids.every((id) => CARD.get(id).rounds === 2 && CARD.get(id).draftPool === 'initial'), `${d.where}: two-battle cards`);
assert.equal(tiersOf(d.ids), '111223', `${d.where}: tiers`);
assert.ok(d.ids.every((id) => !isBoss(CARD.get(id).enemyKey)), `${d.where}: no boss`);
const fit = initialFit(d.ids);
assert.match(fit || '', /^set:/, `${d.where}: a seen set`);
hits[Number(fit.slice(4))]++;
const i = SPEC.initial.groups.findIndex((s) => sameSet(s.cards, d.ids));
assert.ok(i >= 0, `${d.where}: a seen set`);
hits[i].push(d.match);
}
assert.deepEqual(hits, [4, 1, 1, 2, 1, 1, 1], 'series 18 ×4, 19, 17, the 10_5… set ×2, three more mixed sets');
// each seen set is a whole series 17 / 18 / 19 or follows the rule the unseen slots are built by
for (const s of SPEC.initial.sets) {
const series = new Set(s.cards.map((id) => CARD.get(id).series));
assert.ok(series.size === 1 ? [17, 18, 19].includes([...series][0]) && s.cards.length === 6 : mixedRule(s.cards), s.cards.join());
assert.equal(tiersOf(s.cards), '111223');
}
assert.equal(SPEC.initial.slots, 10, 'enemy_initial_1..10');
assert.deepEqual(hits, SPEC.initial.groups.map((s) => s.seen), 'each set\'s `seen` lists the matches it came in');
assert.deepEqual(hits.map((x) => x.length), [5, 3, 2, 3, 2, 1, 2, 1, 3]);
// hand-made sets, not one card per series: two series-20 cards (match 12), a series-17 card among faction cards (13 / 19 / 21)
assert.deepEqual(SPEC.initial.groups[7].cards.filter((id) => CARD.get(id).series === 20), [EE('20_1'), EE('20_5')]);
assert.ok(SPEC.initial.groups[8].cards.includes(EE('17_6')));
for (const s of SPEC.initial.groups) assert.equal(tiersOf(s.cards), '111223', s.cards.join());
assert.equal(SPEC.initial.slots, 10, 'enemy_initial_1..10: one unseen');
assert.equal(SPEC.initial.pick, 'slot');
// the only two-battle cards no draft showed are the unseen set's preferred tier-III cards
const shown = new Set(r3.flatMap((d) => d.ids));
assert.deepEqual(CARDS.filter((c) => c.draftPool === 'initial' && !shown.has(c.effectId)).map((c) => c.effectId), SPEC.initial.rule.prefer);
assert.ok(SPEC.initial.rule.prefer.every((id) => CARD.get(id).tier === 3));
});
test('#2 R3 generated: a seen set or an unseen-slot set (one card from each of 6 of the series 10–15 / 20, I I I II II III) — every seen set comes up, the unseen slots about 3 in 10', () => {
test('#2 R3 generated: a seen set (all six cards) or the unseen 10th set — every seen set comes up, the unseen one about 1 in 10', () => {
const counts = new Map();
let n = 0;
for (const modeId of COOP_BOUNTY_MODES) {
for (const round of DATA.choices.schedule[modeId].spRounds.filter((r) => bountyDraftKind(r) === 'initial')) {
for (const d of drafts(modeId, round, modeId === 'mode_multi_hard' ? 400 : 60)) {
for (const d of drafts(modeId, round, modeId === 'mode_multi_hard' ? 600 : 60)) {
const ids = d.cards.map((c) => c.id);
assert.equal(new Set(ids).size, 6, 'six different cards');
const fit = initialFit(ids);
const i = SPEC.initial.groups.findIndex((s) => sameSet(s.cards, ids));
const fit = i >= 0 ? `set:${i}` : initialRule(ids) ? 'rule' : null;
assert.ok(fit, `${modeId} R${round}: ${d.cards.map((c) => c.name).join(', ')}`);
assert.ok(d.cards.every((c) => c.rounds === 2 && !isBoss(c.enemyKey)), 'two-battle cards, no boss');
assert.match(d.eventId, /^enemy_initial_\d+$/);
@@ -153,9 +155,9 @@ test('#2 R3 generated: a seen set or an unseen-slot set (one card from each of 6
}
}
}
for (let i = 0; i < SPEC.initial.sets.length; i++) assert.ok(counts.get(`set:${i}`) > 0, `set ${i} offered`);
const mixed = (counts.get('mixed') || 0) / n;
assert.ok(mixed > 0.2 && mixed < 0.4, `unseen-slot sets ${(mixed * 100).toFixed(0)} %`);
for (let i = 0; i < SPEC.initial.groups.length; i++) assert.ok(counts.get(`set:${i}`) > 0, `set ${i} offered`);
const rule = (counts.get('rule') || 0) / n;
assert.ok(rule > 0.05 && rule < 0.16, `unseen-set drafts ${(rule * 100).toFixed(0)} %`);
// solo: 3 cards of such a set
for (const d of drafts('mode_single_hard', 3, 40)) {
assert.equal(d.cards.length, 3);
@@ -163,88 +165,158 @@ test('#2 R3 generated: a seen set or an unseen-slot set (one card from each of 6
}
});
test('#2 R9 (12 of 12 official drafts): boss bounties + 源石虫·特训 — a group of named bosses always together, filled to 6 with the cheap ones', () => {
test('#2 R9 (23 official drafts): six groups (9, 9, 9, 9, 8, 6 cards) of boss bounties / 源石虫·特训, 6 cards a draft — 庞贝 and 鼠王 never together, the 鼠王 group in 14 of 22 matches', () => {
const r9 = officialDrafts().filter((d) => d.round === 9);
assert.equal(r9.length, 12, '11 matches + the Bahamut co-op screenshot');
const hits = SPEC.boss.templates.map(() => 0);
assert.equal(r9.length, 23, '22 matches + the Bahamut co-op screenshot');
const B = SPEC.boss;
assert.equal(B.groups.length, B.events.length, 'one group per bossInitial event');
// the groups as they are: 9 cards where three named bosses came; the single-draft groups completed only with the
// base cards they lack — 腐败骑士 9, 泥岩 + 澪 8, match 4 (the base alone) 6 [ASSUMED]
assert.deepEqual(B.groups.map((g) => g.cards.length), [9, 9, 9, 9, 8, 6]);
const base = B.groups.find((g) => g.seen.includes(4));
assert.deepEqual(base.seen, [4]);
assert.ok(sameSet(base.cards, ['b_10', 'b_1', 'b_3', 'b_13', 'b_12', '5_1'].map(EE)), 'match 4: W 碎骨 弑君者 大鲍勃 庞贝 源石虫');
// …which is also six cards of the 喷气人 and 泥岩 groups: match 4 may be a draft of one of them (open question)
for (const boss of ['b_8', 'b_4']) assert.ok(subset(base.cards, B.groups.find((g) => g.cards.includes(EE(boss))).cards));
for (const d of r9) {
assert.equal(d.family, 'bounty', `${d.where}: R9 is a 悬赏决策`);
assert.ok(d.ids.every((id) => CARD.get(id).draftPool === 'boss' && CARD.get(id).rounds === 1), `${d.where}: boss bounties / 源石虫·特训, 下场作战`);
const fits = bossFits(d.ids);
assert.equal(fits.length, 1, `${d.where}: fits exactly one seen group (${fits.join(', ')})`);
hits[fits[0]]++;
const g = groupOf(B, d);
assert.equal(g.length, 1, `${d.where}: listed by one group`);
assert.ok(subset(d.ids, g[0].cards), `${d.where}: six cards of its group`);
assert.ok(!(d.ids.includes(EE('b_12')) && d.ids.includes(EE('b_11'))), `${d.where}: 庞贝 and 鼠王 never together`);
}
assert.ok(hits.every((x) => x > 0), `every group seen (${hits.join(' ')})`);
for (const d of drafts('mode_multi_abyss', 9, 300)) {
for (const g of B.groups) {
const ds = r9.filter((d) => g.seen.includes(d.match));
assert.equal(ds.length, g.seen.length, `${g.cards.join(' ')}: every match it lists`);
assert.deepEqual(g.weights, expectedWeights(g, ds), `${g.cards.join(' ')}: weights = 1 + the drafts each card showed in`);
// every group seen in 3 or more drafts shows exactly 9 different cards — the list
if (ds.length >= 3) assert.ok(sameSet([...new Set(ds.flatMap((d) => d.ids))], g.cards) && g.cards.length === 9, `${g.cards.join(' ')}: the 9 cards its drafts show`);
}
// a named boss does not always come with its partners: 杰斯顿 alone (12, 15, 21); 复仇者 + 百夫长 without 邪魔的利刃 (20)
const named = (d) => d.ids.filter((id) => ['b_9', 'b_14', 'b_23'].map(EE).includes(id));
for (const m of [12, 15, 21]) assert.deepEqual(named(r9.find((d) => d.match === m)), [EE('b_9')], `match ${m}`);
const m20 = r9.find((d) => d.match === 20).ids;
assert.ok(m20.includes(EE('b_21')) && m20.includes(EE('b_2')) && !m20.includes(EE('b_22')));
// the 鼠王 group: 14 of 22 matches, every match on the dark grey board (7 of 7) — something of the match picks the group
const rat = B.groups.find((g) => g.cards.includes(EE('b_11')));
assert.equal(rat.seen.length, 14);
for (const m of OFFICIAL.boards.grey) assert.ok(rat.seen.includes(m), `match ${m} (grey board): the 鼠王 group`);
assert.equal(B.pick, 'seen');
// generated: six different cards of one group; the 鼠王 group about 14 / 22; every card of every group offered
const offered = new Set();
let ratN = 0;
const gen = drafts('mode_multi_abyss', 9, 1500);
for (const d of gen) {
const ids = d.cards.map((c) => c.id);
assert.equal(new Set(ids).size, 6);
assert.ok(bossFits(ids).length > 0, ids.join(', '));
assert.ok(B.groups.some((g) => subset(ids, g.cards)), ids.join(', '));
assert.ok(!(ids.includes(EE('b_12')) && ids.includes(EE('b_11'))), '庞贝 and 鼠王 never together');
assert.match(d.eventId, /^bossInitial_\d+$/);
if (ids.some((id) => ['b_11', 'b_9', 'b_14', 'b_23'].map(EE).includes(id)) && subset(ids, rat.cards)) ratN++;
ids.forEach((id) => offered.add(id));
}
const offered = new Set(drafts('mode_multi_hard', 9, 300).flatMap((d) => d.cards.map((c) => c.id)));
for (const t of SPEC.boss.templates) for (const id of t.featured) assert.ok(offered.has(id), `${CARD.get(id).name} offered`);
assert.ok(ratN / gen.length > 0.55 && ratN / gen.length < 0.73, `the 鼠王 group ${(100 * ratN / gen.length).toFixed(0)} %`);
for (const g of B.groups) for (const id of g.cards) assert.ok(offered.has(id), `${CARD.get(id).name} offered`);
for (const id of ['enemyeffect_b_6', 'enemyeffect_b_7', 'enemyeffect_b_15', 'enemyeffect_b_16', 'enemyeffect_b_18']) {
assert.equal(CARD.get(id).draftExcluded, 'unseen', `${CARD.get(id).name}: in no official R9 draft`);
assert.ok(!offered.has(id));
}
});
test('#2 R11 (11 matches: 悬赏决策 5, 机密商店 4, 战术决策 2, 道具补给 0): the families, and the bounty — a group (one 特异III giant + its tier-II cards, always together) + tier-I fill, one card per faction series', () => {
test('#2 R11 (22 matches: 悬赏决策 14, 机密商店 4, 战术决策 4, 道具补给 0): the families, and the bounty — 6 of a list of 7 "下场战斗" cards, one per faction series', () => {
const r11 = officialDrafts().filter((d) => d.round === 11);
const fam = {};
for (const d of r11) fam[d.family] = (fam[d.family] || 0) + 1;
assert.deepEqual(fam, { bounty: 5, shop: 4, tactic: 2 });
const hits = SPEC.hunter.templates.map(() => 0);
const byMatch = {};
for (const d of r11.filter((x) => x.family === 'bounty')) {
assert.deepEqual(fam, { bounty: 14, shop: 4, tactic: 4 });
const H = SPEC.hunter;
const bounty = r11.filter((x) => x.family === 'bounty');
for (const d of bounty) {
assert.ok(d.ids.every((id) => CARD.get(id).draftPool === 'hunter' && CARD.get(id).rounds === 1), `${d.where}: R11 cards, 下场战斗`);
assert.ok(hunterShape(d.ids), `${d.where}: the shape the unseen slots are built by (${tiersOf(d.ids)})`);
const fits = hunterFits(d.ids);
assert.equal(fits.length, 1, `${d.where}: fits exactly one seen group (${fits.join(', ')})`);
hits[fits[0]]++;
byMatch[d.where.split(' ')[1]] = d.ids;
assert.ok(onePerSeries(d.ids), `${d.where}: one card per faction series`);
assert.ok(giantsIn(d.ids) <= 1, `${d.where}: at most one giant`);
const g = groupOf(H, d);
assert.equal(g.length, 1, `${d.where}: listed by one group`);
assert.ok(subset(d.ids, g[0].cards), `${d.where}: six cards of its group`);
}
assert.deepEqual(hits, [2, 1, 1, 1], 'the 16_4 group in matches 1 and 6, three more groups once');
// the evidence against a free draw: matches 1 and 6 share the giant and all four tier-II cards, only the tier-I card differs
const shared = byMatch['1'].filter((id) => byMatch['6'].includes(id));
assert.equal(shared.length, 5);
assert.deepEqual(byMatch['1'].filter((id) => !shared.includes(id)).concat(byMatch['6'].filter((id) => !shared.includes(id))).map((id) => CARD.get(id).tier), [1, 1]);
for (const id of ['enemyeffect_10_8', 'enemyeffect_11_8', 'enemyeffect_12_8']) assert.ok(!Object.values(byMatch).flat().includes(id), `${id} in no official R11 draft`);
assert.equal(bounty.filter((d) => giantsIn(d.ids) === 1).length, 13, 'a giant in 13 of 14 (not match 15)');
for (const g of H.groups) {
const ds = bounty.filter((d) => g.seen.includes(d.match));
assert.equal(ds.length, g.seen.length);
assert.equal(g.cards.length + (g.open || 0), H.rule.size, `${g.cards.join(' ')}: a list of 7`);
assert.deepEqual(g.weights, expectedWeights(g, ds), `${g.cards.join(' ')}: weights = 1 + hits`);
assert.ok(sameSet([...new Set(ds.flatMap((d) => d.ids))], g.cards), `${g.cards.join(' ')}: the cards its drafts show`);
assert.ok(onePerSeries(g.cards) && giantsIn(g.cards) <= 1);
if (!giantsIn(g.cards)) assert.equal(g.open, 1, 'a list showing no giant gets one by the rule');
}
// the evidence for lists of 7: the 16_4 group's four drafts each leave out a different one of the same 7 cards
const big = H.groups.find((g) => g.cards.includes(EE('16_4')));
const left = bounty.filter((d) => big.seen.includes(d.match)).map((d) => big.cards.find((id) => !d.ids.includes(id)));
assert.equal(new Set(left).size, 4, left.join(' '));
assert.equal(H.groups.filter((g) => !g.open).length, 3, 'three groups seen whole');
assert.deepEqual(CARDS.filter((c) => c.draftPool === 'hunter' && !bounty.some((d) => d.ids.includes(c.effectId))).map((c) => c.effectId),
['12_8', '16_2', '16_6'].map(EE), 'R11 cards no draft showed');
for (const modeId of ['mode_multi_hard', 'mode_multi_abyss', 'mode_single_hard', 'mode_single_abyss']) {
const sch = DATA.choices.schedule[modeId].rounds['11'];
assert.deepEqual(sch.families.map((f) => f.family).sort(), ['bounty', 'shop', 'tactic'], `${modeId} R11 (solo: extrapolated from co-op, [ASSUMED])`);
assert.deepEqual(sch.families, [{ family: 'bounty', weight: 14 }, { family: 'shop', weight: 4 }, { family: 'tactic', weight: 4 }], `${modeId} R11 (solo: extrapolated from co-op, [ASSUMED])`);
assert.equal(sch.bountyDraft, 'hunter');
assert.equal(sch.assumed, true);
}
assert.equal(SPEC.hunter.slots, 15, 'bounty_hunter_1..15');
// generated: a seen group (about 4 in 15) or a group of the same shape; every seen group and every R11 card comes up
// the list is one of the 7 seen, uniform — no list built from nothing [ASSUMED]: 15 equally likely events would show 7
// lists or fewer in 14 drafts about 6 % of the time; 7, 8 or 9 events show exactly 7 about equally often (37 / 45 / 39 %)
assert.equal(H.events.length, 15, 'bounty_hunter_1..15 in the data');
assert.equal(H.slots, H.groups.length);
assert.equal(H.slots, 7);
assert.equal(H.pick, 'slot');
// the data's block order, which does NOT settle which events R11 fires: bounty_hunter_1..7 after bossInitial_1..6;
// 8..15 after artifact_paid_4 / 5 and right before the 绝境 / 终极-only hardbuff_select (read by blocks, R11 = 8..15)
const p = new URL('../../.cache/gamedata/excel/activity_table.json', import.meta.url);
if (existsSync(p)) {
const keys = Object.keys(JSON.parse(readFileSync(p, 'utf8')).activity.AUTOCHESS_SEASON.act2autochess.effectChoiceInfoDict);
const at = (k) => keys.indexOf(k);
assert.equal(at('bounty_hunter_1'), at('bossInitial_6') + 1, 'bounty_hunter_1..7 right after the R9 events');
for (let i = 2; i <= 7; i++) assert.equal(at(`bounty_hunter_${i}`), at('bounty_hunter_1') + i - 1);
assert.ok(at('artifact_paid_5') > at('bounty_hunter_7') + 1, 'bounty_hunter_8..15 not with 1..7');
for (let i = 8; i <= 15; i++) assert.equal(at(`bounty_hunter_${i}`), at('artifact_paid_5') + i - 7, 'bounty_hunter_8..15 right after artifact_paid_4 / 5');
assert.equal(at('hardbuff_select_1'), at('bounty_hunter_15') + 1);
}
// generated: six different cards, one per faction series, at most one giant, at most the `open` card outside one seen
// list; a whole seen list about 3 in 7; every R11 card comes up (12_8 / 16_2 / 16_6 only as an `open` card, rarely)
const gd = new GameData(DATA, 'mode_multi_hard');
const seen = {};
const groups = SPEC.hunter.templates.map(() => 0);
const offered = new Set();
let bounty = 0;
const never = ['12_8', '16_2', '16_6'].map(EE);
let n = 0;
let fitSeen = 0;
for (let seed = 1; seed <= 1500; seed++) {
let giants = 0;
let unseenCard = 0;
for (let seed = 1; seed <= 2000; seed++) {
const d = generateDraft(gd, createRng(seed * 13 + 11), 11, { stageId: 'act2autochess_m01' });
seen[d.family] = (seen[d.family] || 0) + 1;
if (d.family !== 'bounty') continue;
const ids = d.cards.map((c) => c.id);
assert.equal(new Set(ids).size, 6);
assert.ok(hunterShape(ids), d.cards.map((c) => c.name).join(', '));
assert.ok(ids.every((id) => CARD.get(id).draftPool === 'hunter') && onePerSeries(ids) && giantsIn(ids) <= 1, d.cards.map((c) => c.name).join(', '));
assert.match(d.eventId, /^bounty_hunter_\d+$/);
const fits = hunterFits(ids);
if (fits.length) { fitSeen++; groups[fits[0]]++; }
bounty++;
assert.ok(H.groups.some((g) => ids.filter((id) => !g.cards.includes(id)).length <= (g.open || 0)), `six cards of a seen list (+ its open card): ${d.cards.map((c) => c.name).join(', ')}`);
if (H.groups.some((g) => !g.open && subset(ids, g.cards))) fitSeen++;
if (ids.some((id) => never.includes(id))) unseenCard++;
giants += giantsIn(ids);
n++;
ids.forEach((id) => offered.add(id));
}
assert.deepEqual(Object.keys(seen).sort(), ['bounty', 'shop', 'tactic'], 'no 道具补给 at R11');
assert.ok(seen.bounty > seen.shop && seen.shop > seen.tactic, JSON.stringify(seen));
assert.ok(groups.every((x) => x > 0), `every seen group offered (${groups.join(' ')})`);
assert.ok(fitSeen / bounty > 0.17 && fitSeen / bounty < 0.4, `seen groups ${(100 * fitSeen / bounty).toFixed(0)} % of ${bounty}`);
assert.ok(seen.bounty / 2000 > 0.56 && seen.bounty / 2000 < 0.71, JSON.stringify(seen));
assert.ok(fitSeen / n > 0.36 && fitSeen / n < 0.5, `drafts of the three whole seen groups ${(100 * fitSeen / n).toFixed(0)} % (3 / 7)`);
// a card no official draft showed: rare enough that 14 drafts without one (the official sample) stays likely
const q = unseenCard / n;
assert.ok(q > 0.02 && q < 0.1, `12_8 / 16_2 / 16_6 in ${(100 * q).toFixed(1)} % of R11 bounty drafts`);
assert.ok((1 - q) ** 14 > 0.25, `14 drafts without one: ${(100 * (1 - q) ** 14).toFixed(0)} %`);
assert.ok(giants / n > 0.78 && giants / n < 0.93, `a giant in ${(100 * giants / n).toFixed(0)} % (official 13 / 14)`);
for (const c of CARDS.filter((x) => x.draftPool === 'hunter')) assert.ok(offered.has(c.effectId), `${c.effectId} ${c.name} offered at R11`);
});
test('#2 never offered by a draft (in none of the 27 official bounty drafts): multi-round cards, enemyeffect_3_*, 战术特训, the 鸭爵 set', () => {
test('#2 never offered by a draft (in none of the 59 official bounty drafts): multi-round cards, enemyeffect_3_*, 战术特训, the 鸭爵 set', () => {
const reasons = {};
for (const c of CARDS) {
if (!c.draft) reasons[c.draftExcluded] = (reasons[c.draftExcluded] || 0) + 1;
@@ -288,7 +360,8 @@ test('#2 E2E (co-op 绝境, the players\' case): the real R3 draft is an officia
assert.ok(h.drive(() => m.phase === PHASE.SP_DRAFT && m.round === 3, { ready: true }), 'the R3 draft');
assert.equal(m.sp.family, 'bounty', 'R3 of 绝境 is always a bounty draft');
const names = m.sp.cards.map((c) => c.name).join(', ');
assert.ok(initialFit(m.sp.cards.map((c) => c.id)), `seed ${seed}: ${names}`);
const ids = m.sp.cards.map((c) => c.id);
assert.ok(SPEC.initial.groups.some((g) => sameSet(g.cards, ids)) || initialRule(ids), `seed ${seed}: ${names}`);
for (const c of m.sp.cards) assert.ok(!isBoss(c.enemyKey), `seed ${seed}: ${c.name} is a boss bounty`);
assert.equal(m.publicView().sp.cards.length, m.sp.cards.length);
// the human takes a card when its turn comes; the bot picks by itself
+142
View File
@@ -0,0 +1,142 @@
// The 机密商店 after the official screenshots (the user, after the R3 / R9 / R11 screenshots of 22 official matches:
// "机密商店按官方改成可以重复吧"). The 4 official 机密商店 (R11 of matches 2, 4, 5 and 8 in
// test/fixtures/official-bounty-drafts.json) offer the same item twice (盟约之币 ×2 in 4 and 5, 变形同构体 ×2 in 8) and
// share one composition: exactly two tier-VI items, at least one tier V, at least one 盟约之币, no other tier-I / II item.
// choices.json `shopDraft` (tools/build-data.mjs SHOP_DRAFT) draws six slots on their own, with replacement, at R11 (the
// round of the screenshots; 绝境 / 终极 only); the earlier 机密商店 of 标准 / 险境 (no screenshot) keep the previous draw,
// any tier I–VI with replacement. Picking one of two identical cards is in test/content/choices.test.js (E2E), the
// overlay with two identical cards in test/ui/feedback1-secret-shop.test.js.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { DATA } from './harness.js';
import { GameData } from '../../server/match/gamedata.js';
import { generateDraft, cardView } from '../../server/match/choices.js';
import { createRng } from '../../server/sim/rng.js';
const OFFICIAL = JSON.parse(readFileSync(new URL('../fixtures/official-bounty-drafts.json', import.meta.url), 'utf8'));
const SHOP = DATA.choices.shopDraft;
const COIN = SHOP.coin;
const itemByName = (name) => Object.values(DATA.items).find((i) => i.name === name && !i.isGolden && i.itemType === 'EQUIP');
/** The official 机密商店: [{ match, cards: [[name, tier]] }]. */
const SHOPS = Object.entries(OFFICIAL.matches).flatMap(([m, rs]) => Object.values(rs).filter((d) => d.family === 'shop').map((d) => ({ match: Number(m), cards: d.cards })));
const count = (list, pred) => list.filter(pred).length;
test('机密商店 (4 official shops): six free items, two of them tier VI, a tier V and a 盟约之币 in each, the same item twice in three', () => {
assert.deepEqual(SHOPS.map((s) => s.match), [2, 4, 5, 8]);
assert.deepEqual(SHOP.seen, [2, 4, 5, 8]);
let dupShops = 0;
for (const { match, cards } of SHOPS) {
assert.equal(cards.length, 6, `match ${match}`);
for (const [name, tier] of cards) assert.equal(itemByName(name)?.tier, tier, `match ${match}: ${name} is tier ${tier} in the data`);
assert.equal(count(cards, ([, t]) => t === 6), 2, `match ${match}: exactly two tier-VI items`);
assert.ok(count(cards, ([, t]) => t === 5) >= 1, `match ${match}: a tier-V item`);
assert.ok(count(cards, ([n]) => n === '盟约之币') >= 1, `match ${match}: a 盟约之币`);
assert.ok(cards.every(([n, t]) => t >= 3 || n === '盟约之币'), `match ${match}: no other tier-I / II item`);
if (new Set(cards.map(([n]) => n)).size < cards.length) dupShops++;
}
assert.equal(dupShops, 3, '盟约之币 ×2 (matches 4, 5), 变形同构体 ×2 (match 8)');
// the data: four fixed slots (VI, VI, V, 盟约之币) + two drawn by what the other two cards of each shop were
assert.equal(DATA.items[COIN].name, '盟约之币');
assert.deepEqual(SHOP.slots.slice(0, 4), [{ 6: 1 }, { 6: 1 }, { 5: 1 }, { coin: 1 }]);
const rest = { 3: 0, 4: 0, 5: 0, coin: 0 };
for (const { cards } of SHOPS) {
const left = cards.map(([n, t]) => (n === '盟约之币' ? 'coin' : String(t)));
for (const k of ['6', '6', '5', 'coin']) left.splice(left.indexOf(k), 1);
for (const k of left) rest[k]++;
}
assert.deepEqual(rest, { 3: 1, 4: 2, 5: 3, coin: 2 });
for (const s of SHOP.slots.slice(4)) assert.deepEqual(s, rest);
assert.equal(SHOP.slots.length, 6);
assert.deepEqual(SHOP.rounds, [11], 'the composition where the screenshots are: R11 (绝境 / 终极)');
// an item's weight within its tier: 1 + the official cards it showed on
const seen = {};
for (const { cards } of SHOPS) for (const [n] of cards) if (n !== '盟约之币') seen[itemByName(n).id] = (seen[itemByName(n).id] || 0) + 1;
assert.deepEqual(SHOP.itemWeights, Object.fromEntries(Object.entries(seen).map(([id, k]) => [id, 1 + k]).sort()));
assert.equal(SHOP.itemWeights[itemByName('变形同构体').id], 5, '变形同构体 on 4 of the 8 tier-VI cards');
});
test('机密商店 generated (co-op 绝境 R11): the official composition, drawn with replacement — the same item can come twice', () => {
const gd = new GameData(DATA, 'mode_multi_hard');
let shops = 0;
let dup = 0;
let dupItem = 0;
let coins2 = 0;
const offered = new Set();
for (let seed = 1; shops < 2000 && seed < 20000; seed++) {
const d = generateDraft(gd, createRng(seed * 104729 + 7), 11, { stageId: 'act2autochess_m01' });
if (d.family !== 'shop') continue;
shops++;
assert.equal(d.name, '机密商店');
assert.equal(d.cards.length, 6);
const tiers = d.cards.map((c) => DATA.items[c.id].tier);
assert.equal(count(tiers, (t) => t === 6), 2, 'two tier-VI items');
assert.ok(count(tiers, (t) => t === 5) >= 1, 'a tier-V item');
assert.ok(count(d.cards, (c) => c.id === COIN) >= 1, 'a 盟约之币');
assert.ok(d.cards.every((c) => c.id === COIN || DATA.items[c.id].tier >= 3), 'no other tier-I / II item');
assert.deepEqual(d.cards.map((c) => c.idx), [0, 1, 2, 3, 4, 5], 'each card its own index');
for (const c of d.cards) {
assert.equal(c.kind, 'item');
assert.equal(cardView(c).price, 0, '无需消耗资金');
assert.ok(!DATA.items[c.id].isGolden && !DATA.items[c.id].shopExcluded);
offered.add(c.id);
}
const ids = d.cards.map((c) => c.id);
if (new Set(ids).size < ids.length) dup++;
if (new Set(ids.filter((id) => id !== COIN)).size < count(ids, (id) => id !== COIN)) dupItem++;
if (count(ids, (id) => id === COIN) >= 2) coins2++;
}
assert.equal(shops, 2000);
assert.ok(dup / shops > 0.45 && dup / shops < 0.75, `a repeated item in ${(100 * dup / shops).toFixed(0)} % (official 3 of 4)`);
assert.ok(coins2 / shops > 0.35 && coins2 / shops < 0.55, `盟约之币 ×2 in ${(100 * coins2 / shops).toFixed(0)} % (official 2 of 4)`);
assert.ok(dupItem / shops > 0.08, `another item twice in ${(100 * dupItem / shops).toFixed(0)} % (official: 变形同构体 ×2)`);
// every shop item of tiers III–VI can come
for (const t of [3, 4, 5, 6]) for (const id of gd.shopItemsByTier[t]) assert.ok(offered.has(id), `${DATA.items[id].name} (tier ${t}) offered`);
// solo: 3 of the slots
const solo = new GameData(DATA, 'mode_single_hard');
for (let seed = 1, n = 0; n < 30 && seed < 5000; seed++) {
const d = generateDraft(solo, createRng(seed * 31 + 5), 11, {});
if (d.family !== 'shop') continue;
n++;
assert.equal(d.cards.length, 3);
assert.ok(d.cards.every((c) => c.id === COIN || DATA.items[c.id].tier >= 3));
}
});
test('机密商店 outside R11 (标准 R3 / R9, 险境 R3 / R6 / R9 — no screenshot): the previous draw, any tier I–VI per card, with replacement', () => {
// the data's shop events sit in three blocks (artifact_paid_1 by the R3 events, _2 / _3 by the R9 ones, _4 / _5 by
// hardbuff_select), so an early 机密商店 need not look like the R11 one [ASSUMED: keep the previous draw]
const all = new Set([1, 2, 3, 4, 5, 6].flatMap((t) => new GameData(DATA, 'mode_multi_funny').shopItemsByTier[t] || []));
for (const [modeId, rounds] of [['mode_multi_funny', [3, 9]], ['mode_multi_normal', [3, 6, 9]]]) {
assert.deepEqual(DATA.choices.schedule[modeId].spRounds, rounds);
const gd = new GameData(DATA, modeId);
for (const round of rounds) {
let shops = 0;
let dup = 0;
let twoVI = 0;
let lowTier = 0;
const tiers = new Set();
for (let seed = 1; shops < 300 && seed < 40000; seed++) {
const d = generateDraft(gd, createRng(seed * 7717 + round), round, { stageId: 'act2autochess_m01' });
if (d.family !== 'shop') continue;
shops++;
assert.equal(d.cards.length, 6);
assert.deepEqual(d.cards.map((c) => c.idx), [0, 1, 2, 3, 4, 5]);
for (const c of d.cards) {
assert.ok(all.has(c.id), `${modeId} R${round}: ${c.name} is a normal shop item`);
assert.equal(cardView(c).price, 0);
tiers.add(DATA.items[c.id].tier);
}
const ids = d.cards.map((c) => c.id);
if (new Set(ids).size < ids.length) dup++;
if (count(ids, (id) => DATA.items[id].tier === 6) === 2) twoVI++;
if (ids.some((id) => id !== COIN && DATA.items[id].tier <= 2)) lowTier++;
}
assert.equal(shops, 300, `${modeId} R${round}`);
assert.deepEqual([...tiers].sort(), [1, 2, 3, 4, 5, 6], `${modeId} R${round}: every tier`);
assert.ok(twoVI / shops < 0.4, `${modeId} R${round}: two tier-VI items in ${(100 * twoVI / shops).toFixed(0)} % (R11: always)`);
assert.ok(lowTier / shops > 0.5, `${modeId} R${round}: a tier-I / II item besides 盟约之币 in ${(100 * lowTier / shops).toFixed(0)} %`);
assert.ok(dup / shops > 0.12 && dup / shops < 0.42, `${modeId} R${round}: a repeat in ${(100 * dup / shops).toFixed(0)} % (6 of 51, with replacement: about 26 %)`);
}
}
});
+199
View File
@@ -0,0 +1,199 @@
// The 战术决策 after the official screenshots (the user, after the R3 / R9 / R11 screenshots of 22 official matches:
// "战术决策也按官方改成可以重复吧"). The 4 official 战术决策 (R11 of matches 7, 9, 18 and 20 in
// test/fixtures/official-bounty-drafts.json) show six cards each; match 7 offers 补给 twice, the other three six different
// cards. All 24 cards are ally cards (no 排斥 / 责罚 / 裁决 debuff, no 模拟战场演变 terrain card), and 列装 / 财富 / 补给 /
// 整备 / 升华 make half of them. choices.json `tacticDraft` (tools/build-data.mjs TACTIC_DRAFT) draws each card on its own,
// with replacement, at R11 (绝境 / 终极 only, the round of the screenshots) from the ally cards weighted 1 + the official
// cards each showed on; other rounds (标准 / 险境, no screenshot) keep every card, uniform, now with replacement too.
// Picking one of two identical cards through the match: test/content/choices.test.js (E2E); the overlay:
// test/ui/feedback1-tactic.test.js; headless Chrome: test/ui/feedback1-tactic.e2e.test.js (SP_E2E=1).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { DATA, makeMatch } from './harness.js';
import { GameData } from '../../server/match/gamedata.js';
import { generateDraft, cardView, tacticDraftCards } from '../../server/match/choices.js';
import { botPickCard } from '../../server/match/bot.js';
import { createRng } from '../../server/sim/rng.js';
const OFFICIAL = JSON.parse(readFileSync(new URL('../fixtures/official-bounty-drafts.json', import.meta.url), 'utf8'));
const SPEC = DATA.choices.tacticDraft;
const TACTIC = DATA.choices.cards.tactic;
const ALLY = TACTIC.filter((c) => c.kind === 'ally');
const byName = (name) => TACTIC.filter((c) => c.name === name);
/** The official 战术决策: [{ match, names }]. */
const DRAFTS = Object.entries(OFFICIAL.matches).flatMap(([m, rs]) => Object.entries(rs).filter(([, d]) => d.family === 'tactic').map(([r, d]) => ({ match: Number(m), round: Number(r), names: d.cards.map(([n]) => n) })));
const BASIC = ['列装', '财富', '补给', '整备', '升华'];
const repeats = (ids) => ids.length - new Set(ids).size;
const count = (list, pred) => list.filter(pred).length;
test('战术决策 (4 official drafts, R11): six ally cards each, 补给 twice in match 7, half of the cards 列装 / 财富 / 补给 / 整备 / 升华', () => {
assert.deepEqual(DRAFTS.map((d) => [d.match, d.round]), [[7, 11], [9, 11], [18, 11], [20, 11]]);
for (const { match, names } of DRAFTS) {
assert.equal(names.length, 6, `match ${match}`);
for (const n of names) {
assert.equal(byName(n).length, 1, `match ${match}: ${n} is one cards.tactic entry`);
assert.equal(byName(n)[0].kind, 'ally', `match ${match}: ${n} is an ally card`);
}
}
assert.deepEqual(DRAFTS.map((d) => repeats(d.names)), [1, 0, 0, 0], 'one repeat in four drafts');
assert.equal(count(DRAFTS[0].names, (n) => n === '补给'), 2, 'match 7: 补给 ×2');
const all = DRAFTS.flatMap((d) => d.names);
assert.equal(all.length, 24);
assert.equal(new Set(all).size, 14, '14 different cards');
assert.equal(count(all, (n) => BASIC.includes(n)), 12, 'the five basic cards: 12 of 24 (a uniform draw of the 26 ally cards: about 4.6)');
// the data: R11, ally cards, weight 1 + the official cards a card showed on
assert.deepEqual(SPEC.seen, [7, 9, 18, 20]);
assert.deepEqual(SPEC.rounds, [11], 'where the screenshots are: R11 (绝境 / 终极)');
assert.deepEqual(SPEC.kinds, ['ally']);
assert.equal(SPEC.count, 6);
const seen = {};
for (const n of all) seen[byName(n)[0].effectId] = (seen[byName(n)[0].effectId] || 0) + 1;
assert.deepEqual(SPEC.weights, Object.fromEntries(Object.entries(seen).map(([id, k]) => [id, 1 + k])));
assert.equal(SPEC.weights[byName('补给')[0].effectId], 4, '补给 on 3 of the 24 cards');
// open: every official draft holds a 驰援, a 盟誓 and two basic cards (a slot structure?) — the draws stay independent, marked
for (const { match, names } of DRAFTS) {
assert.ok(names.some((n) => n.endsWith('驰援')) && names.some((n) => n.endsWith('的盟誓')), `match ${match}: a 驰援 and a 盟誓`);
assert.ok(count(names, (n) => BASIC.includes(n)) >= 2, `match ${match}: two basic cards`);
}
assert.ok(SPEC.assumed.includes('independent draws (no slot structure)'), 'the independent draws are marked assumed');
// the official header of the event
for (const id of ['buff_select_1', 'hardbuff_select_1']) assert.equal(DATA.choices.events[id].desc, '进行协同调整,做好迎战准备。');
assert.equal(DATA.choices.families.tactic.desc, '进行协同调整,做好迎战准备。');
});
test('战术决策 generated (co-op 绝境 / 终极 R11): six ally cards, each drawn on its own — the same card can come twice', () => {
for (const modeId of ['mode_multi_hard', 'mode_multi_abyss']) {
const gd = new GameData(DATA, modeId);
let drafts = 0;
let dup = 0;
let basic = 0;
const freq = {};
for (let seed = 1; drafts < 2000 && seed < 40000; seed++) {
// act1 m02 is a 绝境 / 终极 stage with two terrain cards (map_m02_1 / 2): none comes at R11
const d = generateDraft(gd, createRng(seed * 7919 + 3), 11, { stageId: 'act1autochess_m02', bondAvailable: () => true });
if (d.family !== 'tactic') continue;
drafts++;
assert.equal(d.name, '战术决策');
assert.equal(d.desc, '进行协同调整,做好迎战准备。');
assert.equal(d.cards.length, 6);
assert.deepEqual(d.cards.map((c) => c.idx), [0, 1, 2, 3, 4, 5], 'each card its own index');
assert.equal(new Set(d.cards).size, 6, 'each card its own object');
for (const c of d.cards) {
assert.equal(c.kind, 'tactic');
assert.equal(c.tacticKind, 'ally', `${modeId}: ${c.name} is an ally card`);
freq[c.id] = (freq[c.id] || 0) + 1;
if (BASIC.includes(c.name)) basic++;
}
if (repeats(d.cards.map((c) => c.id))) dup++;
}
assert.equal(drafts, 2000, modeId);
assert.ok(dup / drafts > 0.4 && dup / drafts < 0.7, `${modeId}: a repeated card in ${(100 * dup / drafts).toFixed(0)} % (official 1 of 4)`);
assert.ok(basic / drafts > 1.6 && basic / drafts < 2.5, `${modeId}: ${(basic / drafts).toFixed(2)} basic cards a draft (official 3; uniform 1.2)`);
for (const c of ALLY) assert.ok(freq[c.effectId] > 0, `${modeId}: ${c.name} can come`);
const of = (name) => freq[byName(name)[0].effectId];
assert.ok(of('补给') > 2.5 * of('自愈'), `${modeId}: 补给 (weight 4) ${of('补给')} vs 自愈 (weight 1) ${of('自愈')}`);
}
});
test('战术决策 seeded (co-op 绝境 R11, seed 30): 补给 twice — two cards with their own index and the same face', () => {
const gd = new GameData(DATA, 'mode_multi_hard');
const d = generateDraft(gd, createRng(30), 11, { stageId: 'act1autochess_m02' });
assert.equal(d.family, 'tactic');
const names = d.cards.map((c) => c.name);
const at = names.flatMap((n, i) => (n === '补给' ? [i] : []));
assert.equal(at.length, 2, `补给 twice: ${names.join(' ')}`);
const [a, b] = at.map((i) => d.cards[i]);
assert.notEqual(a, b);
assert.notEqual(a.idx, b.idx);
const { idx: ia, ...va } = cardView(a);
const { idx: ib, ...vb } = cardView(b);
assert.deepEqual([ia, ib], at);
assert.deepEqual(va, vb, 'the same card face');
assert.deepEqual(va, { kind: 'tactic', id: 'allybuff_select_4', name: '补给', desc: byName('补给')[0].desc, tier: null, team: true, tacticKind: 'ally' });
});
test('战术决策 solo 绝境 R11: three ally cards, a repeat sometimes [ASSUMED: solo extrapolated from co-op]', () => {
const gd = new GameData(DATA, 'mode_single_hard');
let drafts = 0;
let dup = 0;
for (let seed = 1; drafts < 1000 && seed < 40000; seed++) {
const d = generateDraft(gd, createRng(seed * 31 + 5), 11, {});
if (d.family !== 'tactic') continue;
drafts++;
assert.equal(d.cards.length, 3);
assert.ok(d.cards.every((c) => c.tacticKind === 'ally'));
if (repeats(d.cards.map((c) => c.id))) dup++;
}
assert.equal(drafts, 1000);
assert.ok(dup / drafts > 0.05 && dup / drafts < 0.3, `a repeated card in ${(100 * dup / drafts).toFixed(0)} %`);
});
test('战术决策 outside R11 (标准 R3 / R9, 险境 R3 / R6 / R9, 险境 solo R9 — no screenshot): every card uniform, with replacement', () => {
for (const [modeId, rounds, stageId, cards] of [
['mode_multi_funny', [3, 9], 'act1autochess_m01', 6],
['mode_multi_normal', [3, 6, 9], 'act1autochess_m02', 6],
['mode_single_normal', [9], 'act1autochess_m02', 3],
]) {
const gd = new GameData(DATA, modeId);
const stageTerrain = TACTIC.filter((c) => c.kind === 'terrain' && c.stageId === stageId).map((c) => c.effectId);
for (const round of rounds) {
let drafts = 0;
let dup = 0;
const kinds = new Set();
const ids = new Set();
for (let seed = 1; drafts < 600 && seed < 60000; seed++) {
const d = generateDraft(gd, createRng(seed * 104729 + round), round, { stageId });
if (d.family !== 'tactic') continue;
drafts++;
assert.equal(d.cards.length, cards);
assert.deepEqual(d.cards.map((c) => c.idx), [...Array(cards).keys()]);
for (const c of d.cards) {
kinds.add(c.tacticKind);
ids.add(c.id);
if (c.tacticKind === 'terrain') assert.ok(stageTerrain.includes(c.id), `${modeId} R${round}: ${c.name} belongs to ${stageId}`);
}
if (repeats(d.cards.map((c) => c.id))) dup++;
}
assert.equal(drafts, 600, `${modeId} R${round}`);
assert.deepEqual([...kinds].sort(), ['ally', 'enemyDebuff', 'terrain'], `${modeId} R${round}: ally, debuff and the stage's terrain cards`);
for (const id of stageTerrain) assert.ok(ids.has(id), `${modeId} R${round}: ${id}`);
const [lo, hi] = cards === 6 ? [0.2, 0.5] : [0.02, 0.2];
assert.ok(dup / drafts > lo && dup / drafts < hi, `${modeId} R${round}: a repeated card in ${(100 * dup / drafts).toFixed(0)} %`);
}
}
});
test('tacticDraftCards: a draw over one card fills every place; dead-bond cards never come; without tacticDraft every card is uniform', () => {
const gd = new GameData(DATA, 'mode_multi_hard');
// a 驰援 whose bond is dead: never offered, even at R11
const yan = byName('炎盟约驰援')[0].effectId;
for (let s = 1; s <= 300; s++) assert.ok(!tacticDraftCards(gd, createRng(s), 6, { round: 11, bondAvailable: (b) => b !== 'yanShip' }).some((c) => c.id === yan));
// only one card left: six copies of it, six separate objects
const only = { ...DATA, choices: { ...DATA.choices, cards: { ...DATA.choices.cards, tactic: TACTIC.filter((c) => c.name === '升华') } } };
const six = tacticDraftCards(new GameData(only, 'mode_multi_hard'), createRng(1), 6, { round: 11 });
assert.deepEqual(six.map((c) => c.name), Array(6).fill('升华'));
assert.equal(new Set(six).size, 6);
// R11 kinds that leave nothing (a debuff-only pool): every card instead
const debuffs = { ...DATA, choices: { ...DATA.choices, cards: { ...DATA.choices.cards, tactic: TACTIC.filter((c) => c.kind === 'enemyDebuff') } } };
assert.equal(tacticDraftCards(new GameData(debuffs, 'mode_multi_hard'), createRng(2), 6, { round: 11 }).length, 6);
// no tacticDraft (older data): every card, uniform, with replacement, at R11 too
const { tacticDraft, ...rest } = DATA.choices; // eslint-disable-line no-unused-vars
const plain = new GameData({ ...DATA, choices: rest }, 'mode_multi_hard');
const kinds = new Set();
for (let s = 1; s <= 200; s++) for (const c of tacticDraftCards(plain, createRng(s), 6, { round: 11 })) kinds.add(c.tacticKind);
assert.deepEqual([...kinds].sort(), ['ally', 'enemyDebuff']);
});
test('bot: two identical tactic cards are two choices — it takes one index, and the other once the first is gone', () => {
// the merged bot (DESIGN §21.6) scores a tactic card on the player's own bonds, so a real seat; no jitter
const m = makeMatch({ mode: 'solo', difficulty: 'HARD', seed: 7, seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }] }).start().m;
const ps = m.order[0];
m.rngBots = () => 0;
const card = (idx, team) => ({ idx, kind: 'tactic', id: 'allybuff_select_4', name: '补给', team, tacticKind: 'ally' });
const cards = [card(0, true), card(1, false), card(2, true)];
assert.equal(botPickCard(m, ps, cards, [0, 1, 2]), 0, 'the first of the two team cards');
assert.equal(botPickCard(m, ps, cards, [1, 2]), 2, 'the twin, once the first is taken');
assert.equal(botPickCard(m, ps, cards, [1]), 1);
m.dispose();
});
+3 -3
View File
@@ -109,7 +109,7 @@ test('#19 a tier short of different operators: the offer tops up from the tier b
// v2.4.1 drafted 战术特训 (only 法术教鞭 makes those) and the hidden 鸭爵 set, put one or more multi-round "之后 / 后续的每场
// 作战" cards in about half of the co-op R3 drafts and showed every card as plain text, so a "每场" card looked like a
// "下场" one. Now: no 战术特训 / 鸭爵 set (战术特训 is the 教鞭 Art's, test/content/items.test.js); since player feedback
// #2 the drafts follow the 33 official screenshots (test/match/feedback1-bounty.test.js), where no multi-round card
// #2 the drafts follow the 66 official screenshots (test/match/feedback1-bounty.test.js), where no multi-round card
// appears; each card names its battles in the official colours (blue "下场作战" / "两场作战", red "每场"); a 1–2-battle
// bounty stops after its battles; a multi-round card (教鞭's 法术大师A2·多轮战术特训) lasts two battles.
@@ -161,9 +161,9 @@ test('#4 data: cards.bounty marks the draft pool — 战术特训 (教鞭), the
assert.equal(list.filter((c) => c.draft).length, 86);
const multi = list.filter((c) => c.rounds >= 99 && c.payout === 'kill');
assert.equal(multi.length, 7, '多轮悬赏 · 假想敌 ×6 + 山海众头目·多轮悬赏');
assert.ok(multi.every((c) => !c.draft && c.draftExcluded === 'unseen'), 'in none of the 27 official bounty drafts');
assert.ok(multi.every((c) => !c.draft && c.draftExcluded === 'unseen'), 'in none of the 59 official bounty drafts');
const slime = list.find((c) => c.effectId === 'enemyeffect_5_1');
assert.ok(slime.draft && slime.draftPool === 'boss', '源石虫·特训 is an R9 card (8 of the 11 official R9 drafts)');
assert.ok(slime.draft && slime.draftPool === 'boss', '源石虫·特训 is an R9 card (16 of the 23 official R9 drafts)');
});
/** Drive a co-op 绝境 match to its R3 bounty draft; the human takes the first free card `want(card, free)` accepts. */
+83
View File
@@ -0,0 +1,83 @@
// The 机密商店 overlay with the same item twice, in headless Chrome (puppeteer-core + system Chrome) through the mock
// harness (public/dev/game-mock.html ?phase=SP_DRAFT&variant=shop: the official 机密商店 of match 8 R11 — 变形同构体 in
// slots 1 and 4). Opt-in: SP_E2E=1.
//
// SP_E2E=1 node --test test/ui/feedback1-secret-shop.e2e.test.js
//
// Two identical cards render as two cards; a teammate taking the first leaves the second pickable; two taps on the
// second pick it — its own index goes to the server (g.choice), only it shows my avatar. Screenshots:
// test/e2e/out/fb1-secret-shop-*.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
describe('机密商店: the same item twice (mock harness, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
const cards = (page) => page.$$eval('.spov__grid .spcard', (els) => els.map((c) => ({
name: c.querySelector('.spcard__name')?.textContent, taken: c.classList.contains('is-taken'), mine: c.classList.contains('is-mine'),
armed: c.classList.contains('is-armed'), disabled: c.disabled, badge: !!c.querySelector('.spcard__taker'),
})));
for (const [name, w, h, touch] of [['756x366', 756, 366, true], ['1920x1080', 1920, 1080, false]]) {
test(`${name}: two 变形同构体 are two cards; the second stays pickable after the first is taken, and picking it sends its own index`, { timeout: 120000 }, async () => {
const page = await browser.newPage();
await page.setViewport({ width: w, height: h, deviceScaleFactor: 1, isMobile: touch, hasTouch: touch });
const problems = [];
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
await page.goto(`${base}/dev/game-mock.html?shot=1&phase=SP_DRAFT&variant=shop`, { waitUntil: 'networkidle0' });
await page.waitForSelector('.spov__grid .spcard', { timeout: 20000 });
await sleep(600);
// the teammate p4 has taken the first 变形同构体 (slot 0)
await page.evaluate(() => { const S = globalThis.__MOCK__.S(); S.pub.sp = { ...S.pub.sp, picks: { p4: 0 }, turn: 'p1' }; globalThis.__MOCK__.pushPublic(); });
await sleep(300);
let c = await cards(page);
assert.equal(c.length, 6);
assert.equal(c[0].name, '变形同构体');
assert.equal(c[3].name, '变形同构体');
assert.ok(c[0].taken && c[0].badge && c[0].disabled, 'the first twin: taken, with the taker\'s avatar');
assert.ok(!c[3].taken && !c[3].badge && !c[3].disabled, 'the second twin: free');
await page.screenshot({ path: path.join(OUT, `fb1-secret-shop-${name}.png`) });
// two taps on the second twin
const tap = async (i) => { const btn = (await page.$$('.spov__grid .spcard'))[i]; if (touch) await btn.tap(); else await btn.click(); await sleep(250); };
await tap(3);
c = await cards(page);
assert.deepEqual(c.map((x) => x.armed), [false, false, false, true, false, false], 'only the tapped twin is armed');
await page.screenshot({ path: path.join(OUT, `fb1-secret-shop-${name}-armed.png`) });
await tap(3);
const picks = await page.evaluate(() => ({ ...globalThis.__MOCK__.S().pub.sp.picks }));
assert.deepEqual(picks, { p4: 0, p1: 3 }, 'g.choice sent the second twin\'s index');
await sleep(250);
c = await cards(page);
assert.ok(c[3].mine && c[3].badge, 'the second twin is mine');
assert.ok(c[0].taken && !c[0].mine, 'the first twin stays the teammate\'s');
assert.deepEqual(problems, []);
await page.close();
});
}
});
+96
View File
@@ -0,0 +1,96 @@
// The 机密商店 overlay with the same item twice (the user: "机密商店按官方改成可以重复吧"; official match 8 R11: 变形同构体 ×2,
// matches 4 / 5: 盟约之币 ×2). A real server draft (server/match/choices.js generateDraft → cardView), through m.public's
// normalisation (ui/gameLogic.js normalizeSp), rendered by the overlay's pure view (ui/choiceOverlay.js ChoiceView): two
// identical cards are two buttons keyed by their index — selecting one does not select the other, a teammate taking one
// leaves the other pickable, and the pick sends that card's own index. The browser render is
// test/ui/feedback1-secret-shop.e2e.test.js (SP_E2E=1).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
// the browser data store reads the real data files from disk
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
try {
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(body) };
} catch {
return { ok: false, status: 404, json: async () => ({}) };
}
};
const { ChoiceView, cardPickable, spTap, armedCard } = await import('../../public/js/ui/choiceOverlay.js');
const { normalizeSp } = await import('../../public/js/ui/gameLogic.js');
const { DATA } = await import('../match/harness.js');
const { GameData } = await import('../../server/match/gamedata.js');
const { generateDraft, cardView } = await import('../../server/match/choices.js');
const { createRng } = await import('../../server/sim/rng.js');
const { data } = await import('../../public/js/data.js');
/** Every vnode of a preact tree (htm output), depth first. */
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
yield* walk(v.props?.children);
}
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
await data.loadAll('items', 'effects', 'choices', 'assets', 'chess', 'bonds');
/** A generated co-op 绝境 R11 机密商店 with the same non-coin item twice. */
function shopWithTwin() {
const gd = new GameData(DATA, 'mode_multi_hard');
const coin = DATA.choices.shopDraft.coin;
for (let seed = 1; seed < 50000; seed++) {
const d = generateDraft(gd, createRng(seed * 104729 + 7), 11, {});
if (d.family !== 'shop') continue;
const ids = d.cards.map((c) => c.id);
const twin = ids.find((id, i) => id !== coin && ids.indexOf(id) !== i);
if (twin) return { d, twin, at: ids.flatMap((id, i) => (id === twin ? [i] : [])) };
}
throw new Error('no generated shop with a repeated item');
}
test('机密商店 overlay: the same item twice is two cards — own keys, own selection, one taken leaves the other pickable', () => {
const { d, twin, at } = shopWithTwin();
const [a, b] = at;
const players = [{ playerId: 'me', seat: 0, name: 'Me' }, { playerId: 'p2', seat: 1, name: 'P2' }];
const pub = (picks) => normalizeSp({ family: 'shop', name: d.name, desc: d.desc, cards: d.cards.map(cardView), order: ['p2', 'me'], turn: Object.keys(picks).length ? 'me' : 'p2', picks, taken: {} }, players);
const view = (sp, armed = null) => [...walk(ChoiceView({ pub: { players, deadline: 0 }, sp, myId: 'me', solo: false, armed }))].filter((n) => hasClass(n, 'spcard'));
// before any pick: six buttons, keyed by index; the twins have the same name, icon and text
let sp = pub({});
let cards = view(sp);
assert.equal(cards.length, 6);
assert.deepEqual(cards.map((c) => c.key), [0, 1, 2, 3, 4, 5], 'keyed by index, never by item id');
const name = (c) => [...walk(c)].find((n) => hasClass(n, 'spcard__name')).props.children;
assert.equal(name(cards[a]), DATA.items[twin].name);
assert.equal(name(cards[b]), DATA.items[twin].name);
assert.equal(sp.cards[a].id, sp.cards[b].id);
// the teammate takes the first twin: only that card shows the taker; the second twin is still mine to pick
sp = pub({ p2: a });
assert.equal(sp.cards[a].takenBy, 'p2');
assert.equal(sp.cards[b].takenBy, null);
assert.equal(cardPickable(sp, sp.cards[a], { myId: 'me', solo: false }), false);
assert.equal(cardPickable(sp, sp.cards[b], { myId: 'me', solo: false }), true);
cards = view(sp);
assert.ok(hasClass(cards[a], 'is-taken') && !hasClass(cards[b], 'is-taken'));
assert.ok([...walk(cards[a])].some((n) => hasClass(n, 'spcard__taker')) && ![...walk(cards[b])].some((n) => hasClass(n, 'spcard__taker')));
assert.equal(cards[a].props.disabled, true);
assert.equal(cards[b].props.disabled, false);
// two taps on the second twin: it alone is armed, then its own index is sent
let r = spTap(null, b, cardPickable(sp, sp.cards[b], { myId: 'me', solo: false }));
assert.deepEqual(r, { armed: b, pick: null });
assert.equal(armedCard(r.armed, sp, { myId: 'me', solo: false }), b);
cards = view(sp, r.armed);
assert.deepEqual(cards.map((c) => hasClass(c, 'is-armed')), cards.map((_, i) => i === b), 'only the tapped twin is armed');
r = spTap(r.armed, b, true);
assert.deepEqual(r, { armed: null, pick: b });
});
+87
View File
@@ -0,0 +1,87 @@
// The 战术决策 overlay with the same card twice, in headless Chrome (puppeteer-core + system Chrome) through the mock
// harness (public/dev/game-mock.html ?phase=SP_DRAFT&variant=tactic: the official 战术决策 of match 7 R11 — 补给 in
// slots 1 and 2; the user: "战术决策也按官方改成可以重复吧"). Opt-in: SP_E2E=1.
//
// SP_E2E=1 node --test test/ui/feedback1-tactic.e2e.test.js
//
// Two identical cards render as two cards with the same face (name, 全队获得, text) under the official header; a
// teammate taking the first leaves the second pickable; two taps on the second pick it — its own index goes to the
// server (g.choice), only it shows my avatar. Screenshots: test/e2e/out/fb1-tactic-*.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
describe('战术决策: the same card twice (mock harness, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
const cards = (page) => page.$$eval('.spov__grid .spcard', (els) => els.map((c) => ({
name: c.querySelector('.spcard__name')?.textContent, taken: c.classList.contains('is-taken'), mine: c.classList.contains('is-mine'),
armed: c.classList.contains('is-armed'), disabled: c.disabled, badge: !!c.querySelector('.spcard__taker'),
team: !!c.querySelector('.spcard__tag--team'), desc: c.querySelector('.spcard__desc')?.textContent, w: c.getBoundingClientRect().width,
})));
for (const [name, w, h, touch] of [['756x366', 756, 366, true], ['1920x1080', 1920, 1080, false]]) {
test(`${name}: two 补给 are two cards; the second stays pickable after the first is taken, and picking it sends its own index`, { timeout: 120000 }, async () => {
const page = await browser.newPage();
await page.setViewport({ width: w, height: h, deviceScaleFactor: 1, isMobile: touch, hasTouch: touch });
const problems = [];
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
await page.goto(`${base}/dev/game-mock.html?shot=1&phase=SP_DRAFT&variant=tactic`, { waitUntil: 'networkidle0' });
await page.waitForSelector('.spov__grid .spcard', { timeout: 20000 });
await sleep(600);
const head = await page.$eval('.spov__title', (e) => e.textContent);
assert.ok(head.includes('战术决策') && head.includes('进行协同调整,做好迎战准备。'), `the official header: ${head}`);
// the teammate p4 has taken the first 补给 (slot 0)
await page.evaluate(() => { const S = globalThis.__MOCK__.S(); S.pub.sp = { ...S.pub.sp, picks: { p4: 0 }, turn: 'p1' }; globalThis.__MOCK__.pushPublic(); });
await sleep(300);
let c = await cards(page);
assert.equal(c.length, 6);
assert.deepEqual(c.map((x) => x.name), ['补给', '补给', '谢拉格驰援', '列装', '莫斯提马的盟誓', '升华']);
assert.deepEqual([c[0].team, c[0].desc, Math.round(c[0].w)], [c[1].team, c[1].desc, Math.round(c[1].w)], 'the same face');
assert.ok(c[0].team && /2.*次刷新/.test(c[0].desc), `补给: ${c[0].desc}`);
assert.ok(c[0].taken && c[0].badge && c[0].disabled, 'the first twin: taken, with the taker\'s avatar');
assert.ok(!c[1].taken && !c[1].badge && !c[1].disabled, 'the second twin: free');
await page.screenshot({ path: path.join(OUT, `fb1-tactic-${name}.png`) });
// two taps on the second twin
const tap = async (i) => { const btn = (await page.$$('.spov__grid .spcard'))[i]; if (touch) await btn.tap(); else await btn.click(); await sleep(250); };
await tap(1);
c = await cards(page);
assert.deepEqual(c.map((x) => x.armed), [false, true, false, false, false, false], 'only the tapped twin is armed');
await page.screenshot({ path: path.join(OUT, `fb1-tactic-${name}-armed.png`) });
await tap(1);
const picks = await page.evaluate(() => ({ ...globalThis.__MOCK__.S().pub.sp.picks }));
assert.deepEqual(picks, { p4: 0, p1: 1 }, 'g.choice sent the second twin\'s index');
await sleep(250);
c = await cards(page);
assert.ok(c[1].mine && c[1].badge, 'the second twin is mine');
assert.ok(c[0].taken && !c[0].mine, 'the first twin stays the teammate\'s');
assert.deepEqual(problems, []);
await page.close();
});
}
});
+100
View File
@@ -0,0 +1,100 @@
// The 战术决策 overlay with the same card twice (the user: "战术决策也按官方改成可以重复吧"; official match 7 R11: 补给 ×2).
// A real server draft (server/match/choices.js generateDraft → cardView; co-op 绝境 R11, seed 30: 补给 twice), through
// m.public's normalisation (ui/gameLogic.js normalizeSp), rendered by the overlay's pure view (ui/choiceOverlay.js
// ChoiceView): two identical cards are two buttons keyed by their index, with the same face (name, icon, 全队获得, text) —
// selecting one does not select the other, a teammate taking one leaves the other pickable, and the pick sends that
// card's own index. The header is the official one. The browser render is test/ui/feedback1-tactic.e2e.test.js (SP_E2E=1).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
// the browser data store reads the real data files from disk
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
try {
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(body) };
} catch {
return { ok: false, status: 404, json: async () => ({}) };
}
};
const { ChoiceView, cardPickable, spTap, armedCard, resolveSpCard } = await import('../../public/js/ui/choiceOverlay.js');
const { normalizeSp } = await import('../../public/js/ui/gameLogic.js');
const { DATA } = await import('../match/harness.js');
const { GameData } = await import('../../server/match/gamedata.js');
const { generateDraft, cardView } = await import('../../server/match/choices.js');
const { createRng } = await import('../../server/sim/rng.js');
const { data } = await import('../../public/js/data.js');
/** Every vnode of a preact tree (htm output), depth first. */
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
yield* walk(v.props?.children);
}
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
const text = (v) => [...walk(v)].flatMap((n) => (typeof n.props?.children === 'string' ? [n.props.children] : Array.isArray(n.props?.children) ? n.props.children.filter((x) => typeof x === 'string') : [])).join('');
await data.loadAll('items', 'effects', 'choices', 'assets', 'chess', 'bonds');
test('战术决策 overlay: the same card twice is two cards — own keys, same face, own selection, one taken leaves the other pickable', () => {
const d = generateDraft(new GameData(DATA, 'mode_multi_hard'), createRng(30), 11, { stageId: 'act1autochess_m02' });
assert.equal(d.family, 'tactic');
const at = d.cards.flatMap((c, i) => (c.name === '补给' ? [i] : []));
assert.equal(at.length, 2, 'seed 30: 补给 twice');
const [a, b] = at;
const players = [{ playerId: 'me', seat: 0, name: 'Me' }, { playerId: 'p2', seat: 1, name: 'P2' }];
const pub = (picks) => normalizeSp({ family: 'tactic', name: d.name, desc: d.desc, cards: d.cards.map(cardView), order: ['p2', 'me'], turn: Object.keys(picks).length ? 'me' : 'p2', picks, taken: {} }, players);
const view = (sp, armed = null) => [...walk(ChoiceView({ pub: { players, deadline: 0 }, sp, myId: 'me', solo: false, armed }))];
const cardsOf = (nodes) => nodes.filter((n) => hasClass(n, 'spcard'));
// the official header
let sp = pub({});
const all = view(sp);
assert.ok(text(all.find((n) => hasClass(n, 'spov__title'))).includes('战术决策'));
assert.equal(all.find((n) => hasClass(n, 'spov__desc')).props.children.props.text, '进行协同调整,做好迎战准备。');
// six buttons keyed by index; the twins have the same name, icon, team tag and text
let cards = cardsOf(all);
assert.equal(cards.length, 6);
assert.deepEqual(cards.map((c) => c.key), [0, 1, 2, 3, 4, 5], 'keyed by index, never by card id');
const face = (c) => ({ name: [...walk(c)].find((n) => hasClass(n, 'spcard__name')).props.children, team: [...walk(c)].some((n) => hasClass(n, 'spcard__tag--team')), title: c.props.title });
assert.deepEqual(face(cards[a]), face(cards[b]));
assert.equal(face(cards[a]).name, '补给');
assert.equal(face(cards[a]).team, true, '全队获得');
assert.deepEqual(resolveSpCard(sp.cards[a], 'tactic'), resolveSpCard(sp.cards[b], 'tactic'));
assert.equal(sp.cards[a].id, sp.cards[b].id);
assert.notEqual(sp.cards[a].idx, sp.cards[b].idx);
// the teammate takes the first twin: only that card shows the taker; the second twin is still mine to pick
sp = pub({ p2: a });
assert.equal(sp.cards[a].takenBy, 'p2');
assert.equal(sp.cards[b].takenBy, null);
assert.equal(cardPickable(sp, sp.cards[a], { myId: 'me', solo: false }), false);
assert.equal(cardPickable(sp, sp.cards[b], { myId: 'me', solo: false }), true);
cards = cardsOf(view(sp));
assert.ok(hasClass(cards[a], 'is-taken') && !hasClass(cards[b], 'is-taken'));
assert.ok([...walk(cards[a])].some((n) => hasClass(n, 'spcard__taker')) && ![...walk(cards[b])].some((n) => hasClass(n, 'spcard__taker')));
assert.equal(cards[a].props.disabled, true);
assert.equal(cards[b].props.disabled, false);
// two taps on the second twin: it alone is armed, then its own index is sent
let r = spTap(null, b, cardPickable(sp, sp.cards[b], { myId: 'me', solo: false }));
assert.deepEqual(r, { armed: b, pick: null });
assert.equal(armedCard(r.armed, sp, { myId: 'me', solo: false }), b);
cards = cardsOf(view(sp, r.armed));
assert.deepEqual(cards.map((c) => hasClass(c, 'is-armed')), cards.map((_, i) => i === b), 'only the tapped twin is armed');
r = spTap(r.armed, b, true);
assert.deepEqual(r, { armed: null, pick: b });
// after my pick: the first twin the teammate's, the second mine
sp = pub({ p2: a, me: b });
cards = cardsOf(view(sp));
assert.ok(hasClass(cards[b], 'is-mine') && !hasClass(cards[a], 'is-mine'));
assert.ok(cards.every((c) => c.props.disabled), 'nothing left to pick for me');
});