- R11 悬赏决策: the list is one of the 7 seen lists, uniform (slots 7, taken as the events bounty_hunter_1..7, one block of effectChoiceInfoDict). No list is built from nothing any more; the rule only completes the 4 lists seen with 6 cards, so 12_8 / 16_2 / 16_6 come in about 1 R11 bounty draft in 15 (14 drafts without one: about 39 %). - R9: the groups hold 9, 9, 9, 9, 8 and 6 cards (comments, docs, test); match 4's base-only group may be a draft of another group (open). 58 -> 59 bounty drafts in the build-data note. - 机密商店: shopDraft.rounds [11]. The official composition (VI, VI, V, 盟约之币 + 2) applies at R11 (绝境 / 终极, where the screenshots are); the earlier 标准 / 险境 shops keep the previous draw, any tier I-VI per card with replacement (repeats still possible, about 26 %). - Tests: R11 slots / block order / never-shown rate, R9 group sizes, the non-R11 shop draw.
143 lines
9.0 KiB
JavaScript
143 lines
9.0 KiB
JavaScript
// 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 %)`);
|
||
}
|
||
}
|
||
});
|