feedback1 WB: bounty drafts and the 机密商店 from 66 official screenshots (22 matches)
Bounty drafts: every 悬赏决策 event is a fixed card list and the draft shows 6 different cards of it, drawn with weight 1 + the official drafts of its group the card showed in. - R3: 9 of the 10 fixed sets seen (two new: 20_1 + 20_5, and one with 17_6); 3 x I + 2 x II + 1 x III in 22 of 22. The unseen 10th set is built by a rule whose III is 10_6 or 20_6, the only two-battle cards no draft showed. - R9: 6 groups of 9 cards, one per bossInitial event. The 鼠王 group came in 14 of 22 matches (every grey-board match among them), so groups are picked by the matches they came in. Named bosses no longer always come together. - R11: lists of 7 cards, the draft leaves out one (matches 1 / 6 / 21 / 22 leave out four different cards of one list); 7 of 15 lists seen, the rest and the 4 partial lists' 7th cards built by rule. Families 14 / 4 / 4. 机密商店 (the user: "机密商店按官方改成可以重复吧"): choices.json shopDraft, six slots drawn with replacement - VI, VI, V, 盟约之币 and twice V / IV / III / 盟约之币, items weighted by the official cards they showed on. A repeated item now comes in about 61 % of shops (official 3 of 4). Two identical cards stay two cards (picks by index): E2E, overlay and a headless-Chrome test (game-mock ?variant=shop, the official match 8 shop).
This commit is contained in:
1 parent
7c13e38422
commit
d1f8a49829
12 files changed
+799
-261
No files matched your search
+141
-103
@@ -1,15 +1,16 @@
|
||||
// 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 9 boss bounties / 源石虫·特训: 6 groups 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 of the 15 events seen, three of them whole (each draft leaves out one card);
|
||||
// no draft shows a multi-round card, a pre-series card (enemyeffect_3_*), 战术特训 or the 鸭爵 set; no card twice.
|
||||
// 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 +28,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 +74,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 +105,47 @@ test('#2 screenshots → data: every card read is one act2autochess (下半) eff
|
||||
assert.ok(seen.has(id) && !act.act1autochess.effectInfoDataDict[id], `${id} is 下半-only`);
|
||||
}
|
||||
assert.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 +153,9 @@ test('#2 R3 generated: a seen set or an unseen-slot set (one card from each of 6
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < SPEC.initial.sets.length; i++) assert.ok(counts.get(`set:${i}`) > 0, `set ${i} offered`);
|
||||
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 +163,125 @@ test('#2 R3 generated: a seen set or an unseen-slot set (one card from each of 6
|
||||
}
|
||||
});
|
||||
|
||||
test('#2 R9 (12 of 12 official drafts): boss bounties + 源石虫·特训 — a group of named bosses always together, filled to 6 with the cheap ones', () => {
|
||||
test('#2 R9 (23 official drafts): six groups of 9 boss bounties / 源石虫·特训, 6 cards each — 庞贝 and 鼠王 never together, the 鼠王 group in 14 of 22 matches', () => {
|
||||
const r9 = officialDrafts().filter((d) => d.round === 9);
|
||||
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');
|
||||
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
|
||||
assert.equal(H.slots, 15, 'bounty_hunter_1..15');
|
||||
// generated: six different cards, one per faction series, at most one giant; a seen group about 7 in 15; every R11
|
||||
// card comes up (the unseen ones through the rule)
|
||||
const gd = new GameData(DATA, 'mode_multi_hard');
|
||||
const seen = {};
|
||||
const groups = SPEC.hunter.templates.map(() => 0);
|
||||
const offered = new Set();
|
||||
let bounty = 0;
|
||||
let n = 0;
|
||||
let fitSeen = 0;
|
||||
for (let seed = 1; seed <= 1500; seed++) {
|
||||
let giants = 0;
|
||||
for (let seed = 1; seed <= 2000; seed++) {
|
||||
const d = generateDraft(gd, createRng(seed * 13 + 11), 11, { stageId: 'act2autochess_m01' });
|
||||
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++;
|
||||
if (H.groups.some((g) => !g.open && subset(ids, g.cards))) fitSeen++;
|
||||
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.13 && fitSeen / n < 0.27, `drafts of the three whole seen groups ${(100 * fitSeen / n).toFixed(0)} % (3 / 15)`);
|
||||
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 +325,8 @@ test('#2 E2E (co-op 绝境, the players\' case): the real R3 draft is an officia
|
||||
assert.ok(h.drive(() => m.phase === PHASE.SP_DRAFT && m.round === 3, { ready: true }), 'the R3 draft');
|
||||
assert.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
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// The 机密商店 after the official screenshots (the user, after the R3 / R9 / R11 screenshots of 22 official matches:
|
||||
// "机密商店按官方改成可以重复吧"). The 4 official 机密商店 (R11 of matches 2, 4, 5 and 8 in
|
||||
// test/fixtures/official-bounty-drafts.json) offer the same item twice (盟约之币 ×2 in 4 and 5, 变形同构体 ×2 in 8) and
|
||||
// share one composition: exactly two tier-VI items, at least one tier V, at least one 盟约之币, no other tier-I / II item.
|
||||
// choices.json `shopDraft` (tools/build-data.mjs SHOP_DRAFT) draws six slots on their own, with replacement; picking one
|
||||
// of two identical cards is in test/content/choices.test.js (E2E), the overlay with two identical cards in
|
||||
// test/ui/feedback1-secret-shop.test.js.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { DATA } from './harness.js';
|
||||
import { GameData } from '../../server/match/gamedata.js';
|
||||
import { generateDraft, cardView } from '../../server/match/choices.js';
|
||||
import { createRng } from '../../server/sim/rng.js';
|
||||
|
||||
const OFFICIAL = JSON.parse(readFileSync(new URL('../fixtures/official-bounty-drafts.json', import.meta.url), 'utf8'));
|
||||
const SHOP = DATA.choices.shopDraft;
|
||||
const COIN = SHOP.coin;
|
||||
const itemByName = (name) => Object.values(DATA.items).find((i) => i.name === name && !i.isGolden && i.itemType === 'EQUIP');
|
||||
/** The official 机密商店: [{ match, cards: [[name, tier]] }]. */
|
||||
const SHOPS = Object.entries(OFFICIAL.matches).flatMap(([m, rs]) => Object.values(rs).filter((d) => d.family === 'shop').map((d) => ({ match: Number(m), cards: d.cards })));
|
||||
const count = (list, pred) => list.filter(pred).length;
|
||||
|
||||
test('机密商店 (4 official shops): six free items, two of them tier VI, a tier V and a 盟约之币 in each, the same item twice in three', () => {
|
||||
assert.deepEqual(SHOPS.map((s) => s.match), [2, 4, 5, 8]);
|
||||
assert.deepEqual(SHOP.seen, [2, 4, 5, 8]);
|
||||
let dupShops = 0;
|
||||
for (const { match, cards } of SHOPS) {
|
||||
assert.equal(cards.length, 6, `match ${match}`);
|
||||
for (const [name, tier] of cards) assert.equal(itemByName(name)?.tier, tier, `match ${match}: ${name} is tier ${tier} in the data`);
|
||||
assert.equal(count(cards, ([, t]) => t === 6), 2, `match ${match}: exactly two tier-VI items`);
|
||||
assert.ok(count(cards, ([, t]) => t === 5) >= 1, `match ${match}: a tier-V item`);
|
||||
assert.ok(count(cards, ([n]) => n === '盟约之币') >= 1, `match ${match}: a 盟约之币`);
|
||||
assert.ok(cards.every(([n, t]) => t >= 3 || n === '盟约之币'), `match ${match}: no other tier-I / II item`);
|
||||
if (new Set(cards.map(([n]) => n)).size < cards.length) dupShops++;
|
||||
}
|
||||
assert.equal(dupShops, 3, '盟约之币 ×2 (matches 4, 5), 变形同构体 ×2 (match 8)');
|
||||
// the data: four fixed slots (VI, VI, V, 盟约之币) + two drawn by what the other two cards of each shop were
|
||||
assert.equal(DATA.items[COIN].name, '盟约之币');
|
||||
assert.deepEqual(SHOP.slots.slice(0, 4), [{ 6: 1 }, { 6: 1 }, { 5: 1 }, { coin: 1 }]);
|
||||
const rest = { 3: 0, 4: 0, 5: 0, coin: 0 };
|
||||
for (const { cards } of SHOPS) {
|
||||
const left = cards.map(([n, t]) => (n === '盟约之币' ? 'coin' : String(t)));
|
||||
for (const k of ['6', '6', '5', 'coin']) left.splice(left.indexOf(k), 1);
|
||||
for (const k of left) rest[k]++;
|
||||
}
|
||||
assert.deepEqual(rest, { 3: 1, 4: 2, 5: 3, coin: 2 });
|
||||
for (const s of SHOP.slots.slice(4)) assert.deepEqual(s, rest);
|
||||
assert.equal(SHOP.slots.length, 6);
|
||||
// an item's weight within its tier: 1 + the official cards it showed on
|
||||
const seen = {};
|
||||
for (const { cards } of SHOPS) for (const [n] of cards) if (n !== '盟约之币') seen[itemByName(n).id] = (seen[itemByName(n).id] || 0) + 1;
|
||||
assert.deepEqual(SHOP.itemWeights, Object.fromEntries(Object.entries(seen).map(([id, k]) => [id, 1 + k]).sort()));
|
||||
assert.equal(SHOP.itemWeights[itemByName('变形同构体').id], 5, '变形同构体 on 4 of the 8 tier-VI cards');
|
||||
});
|
||||
|
||||
test('机密商店 generated (co-op 绝境 R11): the official composition, drawn with replacement — the same item can come twice', () => {
|
||||
const gd = new GameData(DATA, 'mode_multi_hard');
|
||||
let shops = 0;
|
||||
let dup = 0;
|
||||
let dupItem = 0;
|
||||
let coins2 = 0;
|
||||
const offered = new Set();
|
||||
for (let seed = 1; shops < 2000 && seed < 20000; seed++) {
|
||||
const d = generateDraft(gd, createRng(seed * 104729 + 7), 11, { stageId: 'act2autochess_m01' });
|
||||
if (d.family !== 'shop') continue;
|
||||
shops++;
|
||||
assert.equal(d.name, '机密商店');
|
||||
assert.equal(d.cards.length, 6);
|
||||
const tiers = d.cards.map((c) => DATA.items[c.id].tier);
|
||||
assert.equal(count(tiers, (t) => t === 6), 2, 'two tier-VI items');
|
||||
assert.ok(count(tiers, (t) => t === 5) >= 1, 'a tier-V item');
|
||||
assert.ok(count(d.cards, (c) => c.id === COIN) >= 1, 'a 盟约之币');
|
||||
assert.ok(d.cards.every((c) => c.id === COIN || DATA.items[c.id].tier >= 3), 'no other tier-I / II item');
|
||||
assert.deepEqual(d.cards.map((c) => c.idx), [0, 1, 2, 3, 4, 5], 'each card its own index');
|
||||
for (const c of d.cards) {
|
||||
assert.equal(c.kind, 'item');
|
||||
assert.equal(cardView(c).price, 0, '无需消耗资金');
|
||||
assert.ok(!DATA.items[c.id].isGolden && !DATA.items[c.id].shopExcluded);
|
||||
offered.add(c.id);
|
||||
}
|
||||
const ids = d.cards.map((c) => c.id);
|
||||
if (new Set(ids).size < ids.length) dup++;
|
||||
if (new Set(ids.filter((id) => id !== COIN)).size < count(ids, (id) => id !== COIN)) dupItem++;
|
||||
if (count(ids, (id) => id === COIN) >= 2) coins2++;
|
||||
}
|
||||
assert.equal(shops, 2000);
|
||||
assert.ok(dup / shops > 0.45 && dup / shops < 0.75, `a repeated item in ${(100 * dup / shops).toFixed(0)} % (official 3 of 4)`);
|
||||
assert.ok(coins2 / shops > 0.35 && coins2 / shops < 0.55, `盟约之币 ×2 in ${(100 * coins2 / shops).toFixed(0)} % (official 2 of 4)`);
|
||||
assert.ok(dupItem / shops > 0.08, `another item twice in ${(100 * dupItem / shops).toFixed(0)} % (official: 变形同构体 ×2)`);
|
||||
// every shop item of tiers III–VI can come
|
||||
for (const t of [3, 4, 5, 6]) for (const id of gd.shopItemsByTier[t]) assert.ok(offered.has(id), `${DATA.items[id].name} (tier ${t}) offered`);
|
||||
// solo: 3 of the slots
|
||||
const solo = new GameData(DATA, 'mode_single_hard');
|
||||
for (let seed = 1, n = 0; n < 30 && seed < 5000; seed++) {
|
||||
const d = generateDraft(solo, createRng(seed * 31 + 5), 11, {});
|
||||
if (d.family !== 'shop') continue;
|
||||
n++;
|
||||
assert.equal(d.cards.length, 3);
|
||||
assert.ok(d.cards.every((c) => c.id === COIN || DATA.items[c.id].tier >= 3));
|
||||
}
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user