212 lines
11 KiB
JavaScript
212 lines
11 KiB
JavaScript
// Community report relayed by the owner (2026-10-07): 「有的时候在备战区的干员不能100%叠层…小拉和赫默有概率在备战区叠不上叙拉古
|
||
// 和远见」 — 拉普兰德 (小拉, Ⅱ) in the 整备区 sometimes adds no 【叙拉古】 layers, 赫默 (Ⅱ) no 【远见】 layers. Reproduced over
|
||
// whole matches (scratchpad notes, R19F): every non-firing case is a rule of the official data, nothing random —
|
||
// * 拉普兰德 garrison_123 「<刷新时>若为本回合首次主动刷新,使已激活的【叙拉古】层数+4,此干员在整备区时也有效」: she works in
|
||
// the 整备区, but 【叙拉古】 must be active (activeCondition BOARD: 3 different 叙拉古 deployed — her bench copy is not one
|
||
// of them), and her trait fires on the first manual refresh she sees this round: a refresh made while 叙拉古 is
|
||
// inactive spends it. The 临时整备区 is not the 整备区 (as for every 整备区 rule): she fires once she is in it.
|
||
// * 赫默 garrison_80 「<进入休整期时>使自身已激活的盟约层数+2」 has no 「此干员在整备区时也有效」 and carries the official
|
||
// blackboard conditionkey character_target_inboard: deployed only. 【远见】's 「整备区的【远见】干员也可用于激活盟约」 counts
|
||
// bench members for the bond, it does not move traits to the bench.
|
||
// Through the real match paths (g.refresh / g.buy / g.move, the round loop's ROUND_START dispatch). Pins; no change.
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { makeMatch, give, legalTileFor, checkInvariants, chessOfTier, DATA } from './harness.js';
|
||
import { createRegistry } from '../../server/match/effectsMeta.js';
|
||
|
||
const QUIET = { warn() {}, error() {}, info() {} };
|
||
const REG = createRegistry({ log: QUIET });
|
||
const OK = { ok: true };
|
||
const LAP = 'chess_char_2_16_a'; // 拉普兰德 (Ⅱ, 叙拉古) — garrison_123_a
|
||
const LAP_B = 'chess_char_2_16_b';
|
||
const PROVENCE = 'chess_char_1_07_a'; // 普罗旺斯 (Ⅰ, 叙拉古; <获得时>获得1次免费刷新)
|
||
const TEXAS = 'chess_char_1_08_a'; // 德克萨斯 (Ⅰ, 叙拉古)
|
||
const SIYE = 'chess_char_3_18_a'; // 伺夜 (Ⅲ, 叙拉古)
|
||
const SILENCE = 'chess_char_2_02_a'; // 赫默 (Ⅱ, 远见) — garrison_80_a
|
||
const SILENCE_B = 'chess_char_2_02_b';
|
||
const SWIRE = 'chess_char_3_03_a'; // 诗怀雅 (Ⅲ, 远见)
|
||
const FILLER = 'chess_char_1_01_a';
|
||
/** distinct fillers for a full 整备区: no 叙拉古 / 远见, no prep-side 特质 (one copy each, so nothing merges) */
|
||
const FILLERS = [1, 2].flatMap((t) => chessOfTier(t, (c) => !c.bonds.includes('siracusaShip') && !c.bonds.includes('visiShip')
|
||
&& (c.garrisonIds || []).every((g) => DATA.garrisons[g].eventType === 'IN_BATTLE')));
|
||
const LAP_KEY = 'garrison:SERVER_GAIN_BOND_LAYER_BY_REFRESH_CNT';
|
||
const SIL_KEY = 'garrison:SERVER_ADD_BOND_CHESS_ALL';
|
||
|
||
function setup({ mode = 'solo', bots = 0, round = 1, pid = 'p_0', seed = 70 } = {}) {
|
||
const h = makeMatch({ mode, bots, seed, registry: REG, fake: true }).start();
|
||
h.toPrep(round);
|
||
const m = h.m;
|
||
const ps = h.ps(pid);
|
||
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
|
||
ps.board.clear();
|
||
ps.hand.fill(null);
|
||
ps.temp.fill(null);
|
||
ps.offers.length = 0;
|
||
ps.funds = 50;
|
||
ps.layers = {};
|
||
ps.pendingFunds = 0;
|
||
ps.shop.freeRefreshes = 0;
|
||
ps.bandId = null; // isolate from the band's own prep effects
|
||
ps.ready = false;
|
||
ps.recompute();
|
||
/** layers each trait added (the dispatcher's reason = the registry key) */
|
||
const got = { lap: 0, sil: 0 };
|
||
const addLayers = ps.addLayers.bind(ps);
|
||
ps.addLayers = (b, n, o = {}) => {
|
||
const a = addLayers(b, n, o);
|
||
if (o.reason === LAP_KEY && b === 'siracusaShip') got.lap += a;
|
||
if (o.reason === SIL_KEY && b === 'visiShip') got.sil += a;
|
||
return a;
|
||
};
|
||
const refresh = () => assert.deepEqual(m.handle(pid, { t: 'g.refresh' }), OK);
|
||
const deploy = (id) => give(m, ps, id, 'board', legalTileFor(m, ps, id));
|
||
const place = (piece) => {
|
||
const [row, col] = legalTileFor(m, ps, piece.id);
|
||
assert.deepEqual(m.handle(pid, { t: 'g.move', uid: piece.uid, to: { area: 'board', row, col }, dir: 'RIGHT' }), OK);
|
||
};
|
||
const buy = (id) => {
|
||
ps.shop.slots[0] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), frozen: false, sold: false };
|
||
const before = new Set(ps.allChess().map((p) => p.uid));
|
||
assert.deepEqual(m.handle(pid, { t: 'g.buy', slot: 0 }), OK, `buy ${id}`);
|
||
return ps.allChess().find((p) => !before.has(p.uid));
|
||
};
|
||
const fillHand = () => { let i = 0; while (ps.hand.some((x) => x == null)) give(m, ps, FILLERS[i++], 'hand'); };
|
||
const nextRound = () => { ps.ready = false; h.toPrep(m.round + 1); ps.funds = 50; };
|
||
const siracusa = () => ({ active: !!ps.bonds.siracusaShip?.active, count: ps.bonds.siracusaShip?.count ?? 0 });
|
||
return { h, m, ps, got, refresh, deploy, place, buy, fillHand, nextRound, siracusa };
|
||
}
|
||
|
||
test('official data: a 进入休整期时 / 休整期结束时 / 刷新时 特质 works in the 整备区 exactly when its text says so', () => {
|
||
const PREP = new Set(['SERVER_PREP_START', 'SERVER_PREP_FIN', 'SERVER_REFRESH_SHOP']);
|
||
const bench = [];
|
||
for (const g of Object.values(DATA.garrisons)) {
|
||
if (!PREP.has(g.eventType)) continue;
|
||
const key = g.bbStr && g.bbStr.conditionkey;
|
||
if (/整备区时也有效|整备区也有效/.test(g.desc)) {
|
||
assert.equal(key, undefined, `${g.garrisonId}: a bench trait carries no condition key`);
|
||
bench.push(g.garrisonId);
|
||
} else if (!/^身前一格干员若为/.test(g.desc)) {
|
||
// the rest: deployed only (character_target_inboard), or a row condition that only a deployed operator can meet;
|
||
// the two copy traits (白面鸮 / 塞雷娅 "身前一格…") read the tile in front and are positional by nature
|
||
assert.ok(key === 'character_target_inboard' || key === 'character_same_row', `${g.garrisonId}: ${key} — ${g.desc}`);
|
||
}
|
||
}
|
||
// 拉普兰德 123, 调香师 81, 波登可 102, 琳琅诗怀雅 98 (若已激活【炎】/【投资人】) — normal and elite records
|
||
assert.deepEqual(bench.sort(), ['garrison_102_a', 'garrison_102_b', 'garrison_123_a', 'garrison_123_b', 'garrison_81_a', 'garrison_81_b', 'garrison_98_a', 'garrison_98_b']);
|
||
for (const id of ['garrison_80_a', 'garrison_80_b']) {
|
||
const g = DATA.garrisons[id];
|
||
assert.equal(g.bbStr.conditionkey, 'character_target_inboard', `${id} (赫默): deployed only`);
|
||
assert.doesNotMatch(g.desc, /整备区/);
|
||
}
|
||
assert.equal(DATA.bonds.siracusaShip.countMode, 'BOARD', '叙拉古 counts deployed operators only');
|
||
assert.equal(DATA.bonds.visiShip.countMode, 'BOARD_AND_DECK', '远见 also counts the 整备区');
|
||
});
|
||
|
||
test('拉普兰德 in the 整备区 with 3 different 叙拉古 deployed: +4 on her first manual refresh (+8 elite), whatever else holds', () => {
|
||
const cases = {
|
||
plain: () => {},
|
||
'hand full (10/10)': (s) => s.fillHand(),
|
||
'shop frozen': (s) => assert.deepEqual(s.m.handle('p_0', { t: 'g.freeze' }), OK),
|
||
'a free refresh': (s) => { s.ps.shop.freeRefreshes = 1; },
|
||
'a reward offer pending': (s) => { s.ps.offers.push({ source: 'merge', kind: 'chess', ids: [FILLER], tier: 2 }); },
|
||
'a reconnect': (s) => { s.m.onDisconnect('p_0'); s.m.onReconnect('p_0'); },
|
||
};
|
||
for (const [name, before] of Object.entries(cases)) {
|
||
for (const [id, n] of [[LAP, 4], [LAP_B, 8]]) {
|
||
const s = setup();
|
||
for (const x of [PROVENCE, TEXAS, SIYE]) s.deploy(x);
|
||
give(s.m, s.ps, id, 'hand');
|
||
before(s);
|
||
assert.deepEqual(s.siracusa(), { active: true, count: 3 }, name);
|
||
s.refresh();
|
||
assert.equal(s.got.lap, n, `${name} ${id}`);
|
||
s.refresh();
|
||
assert.equal(s.got.lap, n, `${name}: once per round`);
|
||
s.m.dispose();
|
||
}
|
||
}
|
||
// a 机变 round (solo R6) and a bot seat (its own ps.refresh, bot.js) work the same
|
||
const r6 = setup({ round: 6 });
|
||
for (const x of [PROVENCE, TEXAS, SIYE]) r6.deploy(x);
|
||
give(r6.m, r6.ps, LAP, 'hand');
|
||
r6.refresh();
|
||
assert.equal(r6.got.lap, 4, 'a 机变 round');
|
||
r6.m.dispose();
|
||
const bot = setup({ mode: 'coop', bots: 1, pid: 'ai_0' });
|
||
for (const x of [PROVENCE, TEXAS, SIYE]) bot.deploy(x);
|
||
give(bot.m, bot.ps, LAP, 'hand');
|
||
assert.deepEqual(bot.ps.refresh(), OK);
|
||
assert.equal(bot.got.lap, 4, 'a bot');
|
||
bot.m.dispose();
|
||
});
|
||
|
||
test('拉普兰德 in the 整备区 is not one of the 3 叙拉古: inactive bond, nothing — and that refresh was her first of the round', () => {
|
||
const s = setup();
|
||
s.deploy(PROVENCE);
|
||
s.deploy(TEXAS);
|
||
const lap = give(s.m, s.ps, LAP, 'hand');
|
||
assert.deepEqual(s.siracusa(), { active: false, count: 2 }, 'two 叙拉古 deployed: 2/3 (she sits in the 整备区)');
|
||
s.refresh();
|
||
assert.equal(s.got.lap, 0, '已激活的【叙拉古】: not active, no layers');
|
||
s.place(lap);
|
||
assert.deepEqual(s.siracusa(), { active: true, count: 3 }, 'deployed she is the third');
|
||
s.refresh();
|
||
assert.equal(s.got.lap, 0, '若为本回合首次主动刷新: her first refresh of the round was the inactive one');
|
||
s.nextRound();
|
||
s.refresh();
|
||
assert.equal(s.got.lap, 4, 'the next round: her first refresh with 叙拉古 active');
|
||
checkInvariants(s.m);
|
||
s.m.dispose();
|
||
});
|
||
|
||
test('拉普兰德 in the 临时整备区: no layers there; once she is in the 整备区 her next refresh adds +4', () => {
|
||
const s = setup();
|
||
for (const x of [PROVENCE, TEXAS, SIYE]) s.deploy(x);
|
||
s.fillHand();
|
||
const lap = give(s.m, s.ps, LAP, 'temp');
|
||
assert.equal(s.ps.find(lap.uid).area, 'temp');
|
||
s.refresh();
|
||
assert.equal(s.got.lap, 0, 'the 临时整备区 is not the 整备区');
|
||
assert.deepEqual(s.m.handle('p_0', { t: 'g.sell', uid: s.ps.hand[0].uid }), OK); // a free slot pulls her in
|
||
assert.equal(s.ps.find(lap.uid).area, 'hand');
|
||
s.refresh();
|
||
assert.equal(s.got.lap, 4, 'her first refresh in the 整备区');
|
||
s.refresh();
|
||
assert.equal(s.got.lap, 4, 'once');
|
||
checkInvariants(s.m);
|
||
s.m.dispose();
|
||
});
|
||
|
||
test('拉普兰德 bought after the round\'s first refresh, and a free refresh from 普罗旺斯: both stack (each copy counts its own refreshes)', () => {
|
||
const s = setup();
|
||
s.deploy(TEXAS);
|
||
s.deploy(SIYE);
|
||
give(s.m, s.ps, LAP, 'hand');
|
||
const prov = s.buy(PROVENCE); // <获得时>获得1次免费刷新
|
||
assert.equal(s.ps.shop.freeRefreshes, 1);
|
||
s.place(prov);
|
||
s.refresh(); // the free one is a manual refresh too
|
||
assert.equal(s.ps.shop.freeRefreshes, 0);
|
||
assert.equal(s.got.lap, 4);
|
||
s.buy(LAP); // a second copy, bought after the round's first refresh, stays in the 整备区
|
||
s.refresh();
|
||
assert.equal(s.got.lap, 8, 'her own first refresh');
|
||
s.m.dispose();
|
||
});
|
||
|
||
test('赫默: +2 / +4 at the round start only when deployed; in the 整备区 nothing even with 远见 active (character_target_inboard)', () => {
|
||
for (const [id, where, partner, n] of [
|
||
[SILENCE, 'board', 'hand', 2], [SILENCE, 'board', 'board', 2], [SILENCE_B, 'board', 'hand', 4],
|
||
[SILENCE, 'hand', 'board', 0], [SILENCE, 'hand', 'hand', 0], [SILENCE_B, 'hand', 'board', 0],
|
||
]) {
|
||
const s = setup();
|
||
if (where === 'board') s.deploy(id); else give(s.m, s.ps, id, 'hand');
|
||
if (partner === 'board') s.deploy(SWIRE); else give(s.m, s.ps, SWIRE, 'hand');
|
||
assert.ok(s.ps.bonds.visiShip.active, '远见 counts the 整备区: active');
|
||
s.nextRound();
|
||
assert.equal(s.got.sil, n, `${id} ${where}, partner ${partner}`);
|
||
assert.equal(s.ps.layers.visiShip || 0, n);
|
||
s.m.dispose();
|
||
}
|
||
});
|