Files
ark-stronghold-protocol/test/match/pool.test.js
T
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
2026-09-29 16:21:31 +08:00

160 lines
7.0 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('pool caps follow config (12/14/18/16/8/5, 缪尔赛思 4) and 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 = id === 'chess_char_6_11_a' ? 4 : 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, id);
assert.ok(it.tier <= level, `L${level} item tier ${it.tier}`);
}
}
});
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();
});