227 lines
12 KiB
JavaScript
227 lines
12 KiB
JavaScript
// Shared pool accounting, copy-weighted odds, per-match bans (research 00-INDEX §3, §6).
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { GameData } from '../../server/match/gamedata.js';
|
||
import { SharedPool, drawDisabledBonds } from '../../server/match/pool.js';
|
||
import { createRng } from '../../server/sim/rng.js';
|
||
import { DATA, makeMatch, give, checkInvariants, chessOfTier } from './harness.js';
|
||
|
||
const gdOf = (modeId = 'mode_multi_normal') => new GameData(DATA, modeId);
|
||
|
||
test('co-op pool doubles the measured single-player capacities; only visible, unbanned chess enter', () => {
|
||
const gd = gdOf();
|
||
const pool = new SharedPool(gd, { banned: [] });
|
||
const caps = { 1: 12, 2: 14, 3: 18, 4: 16, 5: 8, 6: 5 };
|
||
assert.equal(pool.entries.size, gd.visibleChess.length);
|
||
assert.equal(pool.entries.size, 112);
|
||
for (const [id, e] of pool.entries) {
|
||
const expect = 2 * (DATA.config.economy.poolCopiesOverrides[id] ?? caps[e.tier]);
|
||
assert.equal(e.cap, expect, id);
|
||
assert.equal(e.left, e.cap);
|
||
assert.ok(DATA.chess[id].visible && !DATA.chess[id].isGolden);
|
||
}
|
||
const banned = [gd.visibleChess[0], gd.visibleChess[5]];
|
||
const p2 = new SharedPool(gd, { banned });
|
||
assert.equal(p2.entries.size, 110);
|
||
assert.ok(!p2.has(banned[0]) && p2.left(banned[0]) === 0 && p2.take(banned[0]) === 0);
|
||
});
|
||
|
||
test('take/give never go below 0 or above the cap', () => {
|
||
const pool = new SharedPool(gdOf());
|
||
const id = chessOfTier(6)[0];
|
||
const cap = pool.cap(id);
|
||
assert.equal(pool.take(id, 3), 3);
|
||
assert.equal(pool.left(id), cap - 3);
|
||
assert.equal(pool.take(id, 100), cap - 3, 'take clamps at what is left');
|
||
assert.equal(pool.left(id), 0);
|
||
assert.equal(pool.take(id, 1), 0);
|
||
assert.equal(pool.give(id, 100), cap, 'give clamps at the cap');
|
||
assert.equal(pool.left(id), cap);
|
||
assert.equal(pool.give(id, 1), 0);
|
||
assert.equal(pool.take('nope', 1), 0);
|
||
assert.equal(pool.give('nope', 1), 0);
|
||
assert.equal(pool.take(id, -1), 0);
|
||
assert.equal(pool.take(id, NaN), 0);
|
||
});
|
||
|
||
test('odds sanity: level L rolls only tiers ≤ L; level 1 only tier 1; shares ≈ research table', () => {
|
||
const pool = new SharedPool(gdOf());
|
||
const rng = createRng(12345);
|
||
for (let level = 1; level <= 6; level++) {
|
||
const seen = {};
|
||
for (let i = 0; i < 4000; i++) {
|
||
const id = pool.roll(rng, { maxTier: level });
|
||
const t = DATA.chess[id].tier;
|
||
assert.ok(t <= level, `level ${level} rolled tier ${t}`);
|
||
seen[t] = (seen[t] || 0) + 1;
|
||
}
|
||
if (level === 1) assert.deepEqual(Object.keys(seen), ['1']);
|
||
const shares = pool.tierShares(level);
|
||
for (const [t, n] of Object.entries(seen)) assert.ok(Math.abs(n / 4000 - shares[t]) < 0.035, `L${level} T${t} ${n / 4000} vs ${shares[t]}`);
|
||
}
|
||
// research table (full pools, no bans): L6 ≈ 14.0 / 17.4 / 25.0 / 25.7 / 11.1 / 6.9 %
|
||
const s6 = pool.tierShares(6);
|
||
const want = { 1: 0.14, 2: 0.174, 3: 0.25, 4: 0.257, 5: 0.111, 6: 0.069 };
|
||
for (const t of Object.keys(want)) assert.ok(Math.abs(s6[t] - want[t]) < 0.01, `T${t} ${s6[t]}`);
|
||
const s2 = pool.tierShares(2);
|
||
assert.ok(Math.abs(s2[1] - 0.447) < 0.01 && Math.abs(s2[2] - 0.553) < 0.01);
|
||
});
|
||
|
||
test('rolls are copy-weighted: an exhausted chess never rolls; tier/filter options work', () => {
|
||
const pool = new SharedPool(gdOf());
|
||
const rng = createRng(7);
|
||
const t1 = chessOfTier(1);
|
||
for (const id of t1.slice(1)) pool.take(id, 100);
|
||
for (let i = 0; i < 200; i++) assert.equal(pool.roll(rng, { maxTier: 1 }), t1[0]);
|
||
pool.take(t1[0], 100);
|
||
assert.equal(pool.roll(rng, { maxTier: 1 }), null, 'empty tier → null');
|
||
for (let i = 0; i < 100; i++) assert.equal(DATA.chess[pool.roll(rng, { tier: 3 })].tier, 3);
|
||
const f = pool.roll(rng, { maxTier: 6, filter: (id) => id === chessOfTier(5)[2] });
|
||
assert.equal(f, chessOfTier(5)[2]);
|
||
});
|
||
|
||
test('item slot: tier ≤ level, shop-eligible normal equipment only', () => {
|
||
const gd = gdOf();
|
||
const pool = new SharedPool(gd);
|
||
const rng = createRng(99);
|
||
for (let level = 1; level <= 6; level++) {
|
||
for (let i = 0; i < 300; i++) {
|
||
const id = pool.rollItem(rng, level);
|
||
const it = DATA.items[id];
|
||
assert.ok(it && it.itemType === 'EQUIP' && !it.isGolden && !it.hideInShop && !it.shopExcluded, id);
|
||
assert.ok(it.tier <= level, `L${level} item tier ${it.tier}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
// user playtest #4 item 5: the special 维式重锤 (维多利亚 25-layer reward / 洛洛's 定制品) and 突变细胞 (strategy 昆图斯)
|
||
// are never sold although the official shop table lists them (tools/build-data.mjs SHOP_EXCLUDED_ITEMS)
|
||
const EFFECT_ONLY = ['chess_item_2_03_e_a', 'chess_item_3_09_e_a', 'chess_item_3_10_e_a', 'chess_item_4_09_e_a', 'chess_item_5_08_e_a'];
|
||
|
||
test('effect-only items are never shop items: not in shopItemsByTier, never in the item slot, the pools or the 机变 supply', () => {
|
||
for (const id of EFFECT_ONLY) {
|
||
assert.equal(DATA.items[id].shopExcluded, true, `${id} (${DATA.items[id].name}) marked`);
|
||
assert.equal(DATA.items[id].hideInShop, false, `${id}: the official table does not hide it`);
|
||
assert.ok(DATA.items[id.replace(/_a$/, '_b')].shopExcluded, `${id}: the golden too`);
|
||
}
|
||
assert.equal(DATA.items.chess_item_1_01_e_a.shopExcluded, false, 'the plain 维式重锤 is sold');
|
||
const gd = gdOf();
|
||
const listed = new Set(Object.values(gd.shopItemsByTier).flat());
|
||
assert.equal(listed.size, 51, '56 normal equipment − 5 effect-only');
|
||
for (const id of EFFECT_ONLY) assert.ok(!listed.has(id), `${id} not a shop item`);
|
||
assert.ok(listed.has('chess_item_1_01_e_a'));
|
||
// the shop item slot at every level
|
||
const pool = new SharedPool(gd);
|
||
const rng = createRng(4);
|
||
for (let level = 1; level <= 6; level++) for (let i = 0; i < 400; i++) assert.ok(!EFFECT_ONLY.includes(pool.rollItem(rng, level)));
|
||
// every shop-eligible pool (凯瑟琳 / 列装 / 定向投放 / 见者有份) and the plain "random item" roll of effects
|
||
const m = makeMatch({ mode: 'coop', seed: 3, fake: true }).m;
|
||
for (const pid of ['pool_equip_normal', 'pool_equip_shop_1', 'pool_equip_kathe', 'pool_equip_narant']) {
|
||
for (let i = 0; i < 300; i++) assert.ok(!EFFECT_ONLY.includes(m.rollItemId({ pool: pid, shopLevel: 6 })), pid);
|
||
}
|
||
for (let i = 0; i < 300; i++) assert.ok(!EFFECT_ONLY.includes(m.rollItemId({ maxTier: 6 })));
|
||
m.dispose();
|
||
});
|
||
|
||
test('维多利亚 25-layer reward: the 4 special 维式重锤 (and nothing else)', () => {
|
||
const special = ['chess_item_2_03_e_a', 'chess_item_3_09_e_a', 'chess_item_3_10_e_a', 'chess_item_4_09_e_a'];
|
||
const m = makeMatch({ mode: 'coop', seed: 3, fake: true }).m;
|
||
const pid = 'pool_equip_vict';
|
||
assert.deepEqual([...DATA.choices.pools[pid].items].sort(), special, pid);
|
||
const seen = new Set();
|
||
for (let i = 0; i < 200; i++) seen.add(m.rollPool(pid).id);
|
||
assert.deepEqual([...seen].sort(), special, `${pid}: every special hammer can come, nothing else`);
|
||
m.dispose();
|
||
});
|
||
|
||
test('洛洛的定制品: the 8-item weighted pool of 路标月报#2 item 6 (10/2/2/2/2/1/4/4)', () => {
|
||
const m = makeMatch({ mode: 'coop', seed: 3, fake: true }).m;
|
||
const weighted = DATA.choices.pools.pool_equip_rockr.weighted;
|
||
assert.deepEqual(weighted, [
|
||
['chess_item_1_01_e_a', 10], ['chess_item_2_03_e_a', 2], ['chess_item_3_09_e_a', 2], ['chess_item_3_10_e_a', 2],
|
||
['chess_item_4_09_e_a', 2], ['chess_item_4_03_e_a', 1], ['chess_item_4_11_e_a', 4], ['chess_item_3_03_e_a', 4],
|
||
]);
|
||
const seen = new Map();
|
||
for (let i = 0; i < 400; i++) { const id = m.rollPool('pool_equip_rockr').id; seen.set(id, (seen.get(id) || 0) + 1); }
|
||
assert.deepEqual([...seen.keys()].sort(), [...weighted.map(([id]) => id)].sort(), 'every entry can come, nothing else');
|
||
// the weight-10 plain hammer clearly dominates the weight-2 special hammers (the four special ones stay exclusive
|
||
// to this pool and 维多利亚's); the shop-sold entries route through the item stock (canGain filter)
|
||
assert.ok(seen.get('chess_item_1_01_e_a') > 3 * Math.max(...['chess_item_2_03_e_a', 'chess_item_3_09_e_a', 'chess_item_3_10_e_a', 'chess_item_4_09_e_a'].map((id) => seen.get(id) || 0)));
|
||
m.dispose();
|
||
});
|
||
|
||
test('焰尾的干员子池 draws 野鬃/灰毫/远牙 by 10/10/3 (路标月报#2 item 6)', () => {
|
||
const m = makeMatch({ mode: 'coop', seed: 3, fake: true }).m;
|
||
assert.deepEqual(DATA.choices.pools.pool_char_pinus.weighted, [['chess_char_1_19_a', 10], ['chess_char_2_18_a', 10], ['chess_char_4_20_a', 3]]);
|
||
const seen = new Map();
|
||
for (let i = 0; i < 460; i++) { const id = m.rollPool('pool_char_pinus').id; seen.set(id, (seen.get(id) || 0) + 1); }
|
||
assert.deepEqual([...seen.keys()].sort(), ['chess_char_1_19_a', 'chess_char_2_18_a', 'chess_char_4_20_a']);
|
||
assert.ok(Math.abs(seen.get('chess_char_1_19_a') - seen.get('chess_char_2_18_a')) <= 0.15 * 460, '野鬃 and 灰毫 draw even (weight 10 each)');
|
||
assert.ok(seen.get('chess_char_4_20_a') < seen.get('chess_char_1_19_a') / 2, '远牙 stays the rare one');
|
||
m.dispose();
|
||
});
|
||
|
||
test('per-match disabled bonds: 3 core + 4 add-on (NORMAL+), FUNNY static + 0 + 1; weight-0 never drawn; subset ban rule', () => {
|
||
for (const [modeId, core, addon] of [['mode_multi_hard', 3, 4], ['mode_single_abyss', 3, 4], ['mode_multi_funny', 0, 1], ['mode_single_normal', 3, 4]]) {
|
||
const gd = new GameData(DATA, modeId);
|
||
for (let seed = 1; seed <= 20; seed++) {
|
||
const { drawn, staticOff, banned } = drawDisabledBonds(gd, createRng(seed));
|
||
const nCore = drawn.filter((b) => DATA.bonds[b].isCore).length;
|
||
assert.equal(nCore, core, `${modeId} core`);
|
||
assert.equal(drawn.length - nCore, addon, `${modeId} addon`);
|
||
for (const b of drawn) {
|
||
assert.ok(DATA.bonds[b].weight > 0, `${b} has weight 0`);
|
||
assert.ok(!staticOff.includes(b));
|
||
}
|
||
const off = new Set([...drawn, ...staticOff]);
|
||
for (const id of gd.visibleChess) {
|
||
const bonds = DATA.chess[id].bonds;
|
||
assert.equal(banned.includes(id), bonds.length > 0 && bonds.every((b) => off.has(b)), id);
|
||
}
|
||
}
|
||
}
|
||
// FUNNY: the static list alone removes many operators
|
||
const f = drawDisabledBonds(new GameData(DATA, 'mode_multi_funny'), createRng(1));
|
||
assert.equal(f.staticOff.length, 10);
|
||
assert.ok(f.banned.length >= 20);
|
||
});
|
||
|
||
test('the match pool excludes banned chess; m.public lists disabled bonds and banned chess', () => {
|
||
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 1, bots: 1, seed: 3 }).start();
|
||
const pub = h.lastBc('m.public');
|
||
assert.equal(pub.drawnDisabledBonds.length, 7);
|
||
assert.ok(pub.bannedChess.length > 0);
|
||
for (const id of pub.bannedChess) assert.ok(!h.m.pool.has(id), `${id} should not be in the pool`);
|
||
assert.equal(h.m.pool.entries.size + pub.bannedChess.length, 112);
|
||
checkInvariants(h.m);
|
||
h.m.dispose();
|
||
});
|
||
|
||
test('selling and elimination return copies; elites return 3', () => {
|
||
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed: 5 }).start();
|
||
h.toPrep(1);
|
||
const m = h.m;
|
||
const ps = h.ps('p_0');
|
||
const id = chessOfTier(2).find((c) => m.pool.has(c));
|
||
const cap = m.pool.cap(id);
|
||
const a = give(m, ps, id);
|
||
assert.equal(m.pool.left(id), cap - 1);
|
||
assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: a.uid }), { ok: true });
|
||
assert.equal(m.pool.left(id), cap);
|
||
const golden = DATA.chess[id].goldenId;
|
||
const e = give(m, ps, golden);
|
||
assert.equal(e.poolCopies, 3);
|
||
assert.equal(m.pool.left(id), cap - 3);
|
||
checkInvariants(m);
|
||
m.handle('p_0', { t: 'g.sell', uid: e.uid });
|
||
assert.equal(m.pool.left(id), cap);
|
||
// elimination returns everything
|
||
const ids = chessOfTier(1).filter((c) => m.pool.has(c)).slice(0, 4);
|
||
for (const c of ids) give(m, ps, c);
|
||
const before = ids.map((c) => m.pool.left(c));
|
||
ps.eliminate(1);
|
||
ids.forEach((c, i) => assert.equal(m.pool.left(c), before[i] + 1));
|
||
checkInvariants(m);
|
||
m.dispose();
|
||
});
|