feedback3: merge WH — ground operators are melee-position, 叙拉古 rolls on every normal hit, off bonds show as disabled, prep-end items merge next prep
This commit is contained in:
commit
1005754bf3
28 files changed
+746
-98
No files matched your search
@@ -0,0 +1,91 @@
|
||||
// Mode-off bonds stay out of the battle (computeBonds) and show in the views when the player has members
|
||||
// (offBondCounts → bondList `off: true`, the strip's grey 本局禁用 discs). 标准模拟's inactiveBondIdList
|
||||
// (activity_table mode_single_funny): 投资人 and 奇迹 count the hand (BOARD_AND_DECK); 突袭 counts the board.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { GameData } from '../../server/match/gamedata.js';
|
||||
import { computeBonds, bondList, bondSnapshot, offBondCounts } from '../../server/match/bondsMeta.js';
|
||||
import { DATA } from './harness.js';
|
||||
|
||||
const funny = new GameData(DATA, 'mode_single_funny');
|
||||
const open = new GameData(DATA, 'mode_multi_normal');
|
||||
let uid = 1;
|
||||
const piece = (id) => ({ uid: uid++, kind: 'chess', id, items: [] });
|
||||
const state = ({ board = [], hand = [], layers = {}, bondCountBonus } = {}) => {
|
||||
const b = new Map();
|
||||
board.forEach((p, i) => b.set(`${9 + (i % 4)},${2 + Math.floor(i / 4)}`, p));
|
||||
const h = new Array(10).fill(null);
|
||||
hand.forEach((p, i) => { h[9 - i] = p; });
|
||||
return { board: b, hand: h, layers, bondCountBonus };
|
||||
};
|
||||
const members = (bond, n) => Object.values(DATA.chess)
|
||||
.filter((c) => c.visible && !c.isGolden && c.bonds.includes(bond))
|
||||
.slice(0, n)
|
||||
.map((c) => c.chessId);
|
||||
|
||||
const INVESTOR = members('investShip', 2);
|
||||
const RAIDER = members('raidShip', 1);
|
||||
const YAN = members('yanShip', 1);
|
||||
|
||||
test('标准模拟: an 投资人 member in hand and a 突袭 member on the board are off entries, never in computeBonds', () => {
|
||||
assert.equal(INVESTOR.length, 2);
|
||||
assert.equal(RAIDER.length, 1);
|
||||
const ps = state({
|
||||
board: [piece(RAIDER[0]), piece(YAN[0])],
|
||||
hand: [piece(INVESTOR[0])],
|
||||
layers: { investShip: 12, raidShip: 4, lateranoShip: 9 },
|
||||
});
|
||||
const bonds = computeBonds(funny, ps);
|
||||
assert.ok(!('investShip' in bonds), '投资人 stays out of the battle input');
|
||||
assert.ok(!('raidShip' in bonds), '突袭 stays out of the battle input');
|
||||
assert.ok(!('lateranoShip' in bonds));
|
||||
assert.equal(bondSnapshot(bonds).investShip, undefined);
|
||||
assert.ok(bonds.yanShip, 'an enabled bond is unchanged');
|
||||
|
||||
const off = offBondCounts(funny, ps);
|
||||
assert.equal(off.investShip.count, 1, 'a hand member counts for 投资人');
|
||||
assert.equal(off.investShip.layers, 12);
|
||||
assert.equal(off.raidShip.count, 1);
|
||||
assert.equal(off.raidShip.layers, 4);
|
||||
assert.ok(!('lateranoShip' in off), 'an inactive bond with zero members is omitted, layers or not');
|
||||
|
||||
const list = bondList(funny, bonds, { full: true, off });
|
||||
const invest = list.find((x) => x.bondId === 'investShip');
|
||||
const raid = list.find((x) => x.bondId === 'raidShip');
|
||||
assert.deepEqual(
|
||||
{ count: invest.count, active: invest.active, tier: invest.tier, layers: invest.layers, off: invest.off, countsHand: invest.countsHand },
|
||||
{ count: 1, active: false, tier: 0, layers: 12, off: true, countsHand: true },
|
||||
);
|
||||
assert.equal(raid.off, true);
|
||||
assert.equal(raid.active, false);
|
||||
assert.equal(raid.tier, 0);
|
||||
assert.equal(raid.countsHand, false);
|
||||
assert.ok(list.findIndex((x) => x.off) > list.findIndex((x) => x.bondId === 'yanShip'), 'off entries follow the live bonds');
|
||||
assert.ok(list.slice(list.findIndex((x) => x.off)).every((x) => x.off), 'the off entries are a suffix');
|
||||
const pub = bondList(funny, bonds, { off });
|
||||
assert.equal(pub.find((x) => x.bondId === 'investShip').off, true);
|
||||
assert.equal('countsHand' in pub.find((x) => x.bondId === 'investShip'), false, 'm.public omits countsHand');
|
||||
});
|
||||
|
||||
test('a BOARD_AND_DECK off bond counts the hand, not the temporary slots, and not twice', () => {
|
||||
const [a, b] = INVESTOR;
|
||||
const handOnly = offBondCounts(funny, state({ hand: [piece(a), piece(b)] }));
|
||||
assert.equal(handOnly.investShip.count, 2);
|
||||
assert.ok(!('raidShip' in (handOnly || {})), '突袭 in nobody\'s hand: a board bond with no board member is omitted');
|
||||
const raidHand = offBondCounts(funny, state({ hand: [piece(RAIDER[0])] }));
|
||||
assert.equal(raidHand, null, '突袭 does not count a hand member');
|
||||
const both = offBondCounts(funny, state({ board: [piece(a)], hand: [piece(a), piece(DATA.chess[a].goldenId)] }));
|
||||
assert.equal(both.investShip.count, 1, 'normal and elite of one operator, board and hand, count once');
|
||||
const tempOnly = { board: new Map(), hand: new Array(10).fill(null), layers: {}, temp: [piece(a)] };
|
||||
assert.equal(offBondCounts(funny, tempOnly), null, 'the 5 temporary slots never count');
|
||||
});
|
||||
|
||||
test('a mode with an empty inactive set returns null, and those bonds stay in computeBonds', () => {
|
||||
assert.equal(open.modeInactiveBonds.size, 0);
|
||||
const ps = state({ board: [piece(RAIDER[0])], hand: [piece(INVESTOR[0])] });
|
||||
assert.equal(offBondCounts(open, ps), null);
|
||||
const bonds = computeBonds(open, ps);
|
||||
assert.equal(bonds.investShip.count, 1);
|
||||
assert.equal(bonds.raidShip.count, 1);
|
||||
assert.equal(bondList(open, bonds).some((x) => x.off), false);
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
// An item that arrives at 休整期结束 waits in the hand and merges at the next prep start (owner's decision 2026-10-04).
|
||||
// 维多利亚 pays 战栗维式重锤 on onPrepEnd; merging in that call used to detach the equipped copy before the fight.
|
||||
// [ASSUMED] every grant while that dispatch is on the stack, not only this hammer.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRegistry } from '../../server/match/effectsMeta.js';
|
||||
import { registerMeta as coreRegisterMeta } from '../../server/sim/content/bonds/core.js';
|
||||
import { makeMatch, give, giveItem, legalTileFor } from './harness.js';
|
||||
import { FakeBattle } from './fakeBattle.js';
|
||||
import { tileKey } from '../../server/match/board.js';
|
||||
|
||||
const HAMMER = 'chess_item_2_03_e_a'; // 战栗维式重锤
|
||||
const GOLDEN = 'chess_item_2_03_e_b';
|
||||
const FILLER = 'chess_item_1_01_e_a';
|
||||
const OPS = ['chess_char_1_06_a', 'chess_char_2_05_a', 'chess_char_3_08_a'];
|
||||
|
||||
function copies(ps, id) {
|
||||
let n = 0;
|
||||
const see = (p) => { if (p && p.kind === 'item' && p.id === id) n++; };
|
||||
for (const p of ps.hand) see(p);
|
||||
for (const p of ps.temp) see(p);
|
||||
for (const c of ps.board.values()) if (c && c.kind === 'chess') for (const it of c.items || []) see(it);
|
||||
return n;
|
||||
}
|
||||
|
||||
function fresh() {
|
||||
const reg = createRegistry({ content: false });
|
||||
coreRegisterMeta(reg);
|
||||
const h = makeMatch({ mode: 'solo', seed: 5, registry: reg, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const orig = h.m.rollItemId.bind(h.m);
|
||||
h.m.rollItemId = (opts = {}) => (opts.pool === 'pool_equip_vict' ? HAMMER : orig(opts));
|
||||
return h;
|
||||
}
|
||||
|
||||
/** Bond inactive while the layers land, then three 维多利亚 operators. One wears the hammer when `equip`. */
|
||||
function arm(h, { layers, equip = true, fill = false } = {}) {
|
||||
const ps = h.ps('p_0');
|
||||
assert.equal(ps.setReady(false).ok, true);
|
||||
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)]) {
|
||||
if (p && p.kind === 'chess') ps.returnCopies(p);
|
||||
}
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
ps.temp.fill(null);
|
||||
ps._tempDue.clear();
|
||||
ps.recompute();
|
||||
assert.equal(ps.bonds.victoriaShip?.active ?? false, false);
|
||||
if (layers > 0) assert.equal(ps.addLayers('victoriaShip', layers), layers);
|
||||
assert.equal(ps.counters['bond:victoria:hammers'] || 0, 0, 'inactive: the milestones are owed, not paid');
|
||||
const used = new Set();
|
||||
const ops = OPS.map((id) => {
|
||||
const t = legalTileFor(h.m, ps, id, used);
|
||||
used.add(tileKey(t[0], t[1]));
|
||||
return give(h.m, ps, id, 'board', t);
|
||||
});
|
||||
assert.equal(ps.bonds.victoriaShip.active, true);
|
||||
let equipped = null;
|
||||
if (equip) {
|
||||
equipped = giveItem(h.m, ps, HAMMER);
|
||||
const eq = ps.equip(equipped.uid, ops[0].uid);
|
||||
assert.equal(eq.ok, true, eq.error || eq.detail);
|
||||
assert.equal(ops[0].items[0].uid, equipped.uid);
|
||||
}
|
||||
if (fill) {
|
||||
for (let i = 0; i < ps.hand.length; i++) if (!ps.hand[i]) giveItem(h.m, ps, FILLER, 'hand', i);
|
||||
for (let i = 0; i < ps.temp.length; i++) if (!ps.temp[i]) giveItem(h.m, ps, FILLER, 'temp', i);
|
||||
assert.ok(ps.hand.every(Boolean) && ps.temp.every(Boolean));
|
||||
}
|
||||
return { ps, carrier: ops[0], equipped };
|
||||
}
|
||||
|
||||
test('维多利亚 pays a 战栗维式重锤 at prep end: the equipped copy stays for the fight, and they merge at the next prep', () => {
|
||||
const h = fresh();
|
||||
const { ps, carrier, equipped } = arm(h, { layers: 25 });
|
||||
assert.equal(ps.setReady(true).ok, true);
|
||||
h.m.endPrep();
|
||||
assert.equal(h.m.phase, 'COMBAT');
|
||||
assert.equal(carrier.items.length, 1);
|
||||
assert.equal(carrier.items[0].uid, equipped.uid, 'the equipped hammer was not taken off');
|
||||
assert.equal(carrier.items[0].id, HAMMER);
|
||||
assert.equal(copies(ps, HAMMER), 2, 'the new copy is in hand or temp');
|
||||
assert.equal(copies(ps, GOLDEN), 0, 'no golden yet');
|
||||
const input = FakeBattle.instances.at(-1).opts.players[0];
|
||||
const unit = input.units.find((u) => u.uid === carrier.uid);
|
||||
assert.ok(unit && unit.items.includes(HAMMER), 'the fight is built with the hammer still equipped');
|
||||
h.toPrep(2);
|
||||
assert.equal(h.m.phase, 'PREP');
|
||||
assert.equal(copies(ps, GOLDEN), 1, 'the pair merged when the next prep started');
|
||||
assert.equal(copies(ps, HAMMER), 0);
|
||||
h.invariants();
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('two milestones owed at prep end both wait: no golden before the fight; the next prep merges the hand copies and leaves the equipped one', () => {
|
||||
const h = fresh();
|
||||
const { ps, carrier, equipped } = arm(h, { layers: 50 });
|
||||
assert.equal(ps.setReady(true).ok, true);
|
||||
h.m.endPrep();
|
||||
assert.equal(carrier.items[0].uid, equipped.uid);
|
||||
assert.equal(copies(ps, HAMMER), 3, 'equipped + two stowed, the nested onGain grant included');
|
||||
assert.equal(copies(ps, GOLDEN), 0);
|
||||
h.toPrep(2);
|
||||
assert.equal(copies(ps, GOLDEN), 1);
|
||||
assert.equal(copies(ps, HAMMER), 1);
|
||||
assert.equal(carrier.items[0] && carrier.items[0].uid, equipped.uid, 'the hand copies merged with each other; the equipped one stayed');
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('hand and temp full at prep end: the new hammer is destroyed, the equipped one stays, and the next prep does not put a golden in the slot', () => {
|
||||
const h = fresh();
|
||||
const { ps, carrier, equipped } = arm(h, { layers: 50, fill: true });
|
||||
h.m.endPrep();
|
||||
assert.equal(carrier.items.length, 1);
|
||||
assert.equal(carrier.items[0].uid, equipped.uid);
|
||||
assert.equal(carrier.items[0].id, HAMMER);
|
||||
assert.equal(copies(ps, GOLDEN), 0);
|
||||
assert.equal(copies(ps, HAMMER), 1);
|
||||
const toasts = h.sent.filter(([, msg]) => msg.t === 'm.toast').map(([, msg]) => msg.text);
|
||||
assert.equal(toasts.filter((t) => t === '整备区已满,获得的装备已销毁').length, 2);
|
||||
const input = FakeBattle.instances.at(-1).opts.players[0];
|
||||
assert.ok(input.units.find((u) => u.uid === carrier.uid).items.includes(HAMMER));
|
||||
h.toPrep(2);
|
||||
assert.equal(carrier.items[0].uid, equipped.uid, 'nothing was merged into the slot');
|
||||
assert.equal(copies(ps, GOLDEN), 0);
|
||||
assert.equal(copies(ps, HAMMER), 1);
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('a buy during prep still merges at once', () => {
|
||||
const h = fresh();
|
||||
const ps = h.ps('p_0');
|
||||
assert.equal(ps.setReady(false).ok, true);
|
||||
giveItem(h.m, ps, HAMMER);
|
||||
ps.funds = 50;
|
||||
ps.shop.slots[0] = { kind: 'item', id: HAMMER, price: 1, basePrice: 1, sold: false };
|
||||
assert.equal(ps.buy(0).ok, true);
|
||||
assert.equal(h.m.phase, 'PREP');
|
||||
assert.equal(copies(ps, HAMMER), 0);
|
||||
assert.equal(copies(ps, GOLDEN), 1);
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('a grant after the prep has ended (onLayers during combat) still merges at once', () => {
|
||||
const h = fresh();
|
||||
const { ps, carrier, equipped } = arm(h, { layers: 0 });
|
||||
assert.equal(ps.setReady(true).ok, true);
|
||||
h.m.endPrep();
|
||||
assert.equal(h.m.phase, 'COMBAT');
|
||||
assert.equal(carrier.items[0].uid, equipped.uid);
|
||||
assert.equal(ps.addLayers('victoriaShip', 25), 25);
|
||||
assert.equal(copies(ps, HAMMER), 0, 'the equipped copy was merged immediately');
|
||||
assert.equal(copies(ps, GOLDEN), 1);
|
||||
assert.equal(h.m.phase, 'COMBAT');
|
||||
h.m.dispose();
|
||||
});
|
||||
Reference in new issue
Block a user