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:
sganggs committed 2026-10-04 21:26:10 +08:00
commit 1005754bf3
28 files changed
+746 -98

No files matched your search

+11 -2
View File
@@ -412,7 +412,7 @@ test('突袭 #51: the most advanced enemy out of reach → the jump goes to the
checkInvariants(h.b);
});
test('不屈: knocked-out ground operator redeploys (p=1 at high L); tier 2 every operator +5 SP; inactive / elevated → no', () => {
test('不屈: knocked-out 地面干员 (melee position) redeploys (p=1 at high L); tier 2 every operator +5 SP; inactive / ranged → no', () => {
const bb = bondBb('indomShip');
const sk = { spCost: 50, initSp: 0 };
const defs = { chess: { i_g: op('i_g', ['indomShip']), i_o: chessRec({ id: 'i_o', bonds: [], skill: sk }) } };
@@ -441,12 +441,21 @@ test('不屈: knocked-out ground operator redeploys (p=1 at high L); tier 2 ever
close(o1.skill.sp, s1, 'tier 1: no SP');
checkInvariants(t1.b);
// 地面干员 = the melee position, whatever the tile: up on a 高台 it still counts; a ranged operator on a melee tile never
const hi = makeBattle({ defs, units: [{ chessId: 'i_g', row: 10, col: 2 }], bonds: { indomShip: bond(1, 300) } });
hi.step(2);
const hg = hi.unit('i_g');
assert.equal(hg.ground, false, 'elevated tile');
hi.b.dealDamage(null, hg, { amount: 1e9, type: 'true' });
assert.equal(hg.alive, false, 'not a ground operator');
assert.ok(hg.alive && hg.deployed, 'a melee operator on a 高台 is a 地面干员');
const rd = { chess: { i_r: ranged('i_r', ['indomShip']), i_o: chessRec({ id: 'i_o', bonds: [], skill: sk }) } };
const lo = makeBattle({ defs: rd, units: [{ chessId: 'i_r', row: 10, col: 4 }, { chessId: 'i_o', row: 12, col: 6 }], bonds: { indomShip: bond(2, 300, 3) } });
lo.step(2);
const lr = lo.unit('i_r'), lsp = lo.unit('i_o').skill.sp;
assert.equal(lr.ground, true, 'a melee (ground) tile');
lo.b.dealDamage(null, lr, { amount: 1e9, type: 'true' });
assert.equal(lr.alive, false, 'a ranged operator on a melee tile is not a 地面干员');
close(lo.unit('i_o').skill.sp, lsp, 'no tier 2 SP either');
});
test('协防干员: all operators take ×0.8 phys/arts; members deal ×1.2 (elite ×1.4)', () => {
+98
View File
@@ -0,0 +1,98 @@
// test/content/feedback3-ground-ops.test.js — 「地面干员」 is the melee position (地面位), not the tile (0.1.3; community
// report 「不屈盟约效果高台干员也错误的吃到了」). Game data words the deploy class this way (this mode's map card
// 「可以部署高台干员和地面干员的地面」, 「可将高台干员部署于其上」, 「所有地面干员阻挡数+2」); PRTS corrected 琴柳's 地面干员 to
// 地面位干员. The three effects worded 地面干员 — 不屈 (both lines), the 战栗维式重锤 proc and 休谟斯 回收利用 — read
// support isGroundOp = def.position MELEE: 歌蕾蒂娅 on a 高台 counts, a ranged operator on a melee tile does not.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { isGroundOp } from '../../server/sim/content/support/index.js';
const close = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${msg}: ${a} ≠ ${b}`);
const RANGE5 = [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4]];
const op = (id, o = {}) => chessRec({
id, profession: o.profession ?? 'WARRIOR', bonds: o.bonds ?? [], rangeGrid: RANGE5,
stats: { maxHp: 2000, atk: 500, def: 200, res: 10, aspd: 100, bat: 1, respawnTime: 20, spRecovery: 1, blockCnt: 2 },
skill: { spCost: 60, duration: 5, initSp: 0 },
});
const DUMMY = enemyRec({ key: 'e_d', hp: 1e7, speed: 0 });
function forceRng(h, v) { const orig = h.b.rng; h.b.rng = Object.assign(() => v, orig); }
// act2autochess_m01 (战场#01(下半)): (9,4) a melee tile on the road, (10,4) a 高台
const PROVENCE = 'chess_char_1_07_a'; // 普罗旺斯 狙击 RANGED, 不屈
const GLADIIA = 'chess_char_4_12_a'; // 歌蕾蒂娅 钩索师 MELEE (placement 'all': may stand on a 高台)
const CUORA = 'chess_char_3_16_a'; // 蛇屠箱: takes the tier-2 SP
const INDOM = { indomShip: { count: 3, active: true, tier: 2, layers: 210 } }; // p = min(1, 0.18 + 0.004 × 210) = 1
test('不屈 (real chess): 歌蕾蒂娅 knocked out on a 高台 is back at once with the tier-2 SP; 普罗旺斯 on a melee tile stays down', () => {
const run = (id, row, col) => {
const h = makeBattle({
stageId: 'act2autochess_m01', seed: 4, timeLimit: 60, autoFinish: false, bonds: INDOM,
units: [{ chessId: id, row, col, dir: 'RIGHT' }, { chessId: CUORA, row: 12, col: 6, dir: 'RIGHT' }],
});
h.step(2);
const u = h.unit(id), other = h.unit(CUORA);
const sp0 = other.skill.sp;
h.b.kill(u, null);
const back = u.alive && u.deployed;
const sp = other.skill.sp - sp0;
checkInvariants(h.b);
return { u, back, sp };
};
const g = run(GLADIIA, 10, 4);
assert.equal(g.u.ground, false, '歌蕾蒂娅 stands on the 高台');
assert.equal(g.u.def.position, 'MELEE');
assert.ok(isGroundOp(g.u), 'a 地面干员 up there');
assert.ok(g.back, '不屈 brings her back at once');
close(g.sp, 5, '不屈 tier 2: every operator on the field +5 SP');
const p = run(PROVENCE, 9, 4);
assert.equal(p.u.ground, true, '普罗旺斯 stands on a melee tile');
assert.ok(!isGroundOp(p.u), 'not a 地面干员');
assert.ok(!p.back, '不屈 does not bring a ranged operator back');
close(p.sp, 0, 'no tier-2 SP for her knock-out');
const g2 = run(GLADIIA, 9, 4);
assert.ok(g2.back && g2.sp === 5, '歌蕾蒂娅 on the ground: as before');
});
test('战栗维式重锤: the 战栗 proc takes a melee carrier on a 高台, never a ranged carrier on a melee tile', () => {
const ops = { t_m: op('t_m', { bonds: ['victoriaShip'] }), t_r: op('t_r', { bonds: ['victoriaShip'], profession: 'SNIPER' }) };
const tremble = (id, row, col) => {
const h = makeBattle({
defs: { chess: ops, enemies: { e_d: DUMMY } }, timeLimit: 999, autoFinish: false,
units: [{ chessId: id, row, col, items: ['chess_item_2_03_e_a'] }], enemies: [{ key: 'e_d', pos: [10, 5] }],
});
forceRng(h, 0.05); // under the 10 % chance
h.runUntil(() => h.hooksOf('attack').length >= 1, 5);
assert.ok(h.hooksOf('attack').length >= 1, `${id} attacked`);
const u = h.unit(id);
return { ground: u.ground, hit: !!h.b.enemies[0].findBuff('tremble') };
};
const m = tremble('t_m', 10, 2);
assert.equal(m.ground, false, 'the melee carrier on the 高台');
assert.ok(m.hit, '战栗 from a melee carrier on a 高台');
const r = tremble('t_r', 10, 4);
assert.equal(r.ground, true, 'the ranged carrier on a melee tile');
assert.ok(!r.hit, 'no 战栗 from a ranged carrier');
});
test('休谟斯 回收利用: a melee operator\'s skill end on a 高台 feeds a neighbour 3 SP; a ranged one\'s on a melee tile does not', () => {
const ops = { t_m: op('t_m'), t_r: op('t_r', { profession: 'SNIPER' }), t_n: op('t_n') };
const recycle = (id, row, col, nRow, nCol) => {
const h = makeBattle({
defs: { chess: ops, enemies: { e_d: DUMMY } }, bandId: 'band_humus', timeLimit: 999, autoFinish: false,
units: [{ chessId: id, row, col }, { chessId: 't_n', row: nRow, col: nCol }],
});
h.step(1);
const u = h.unit(id), n = h.unit('t_n');
const n0 = n.skill.sp;
u.skill.activate('test', { free: true });
u.skill.end('test');
return { ground: u.ground, gain: n.skill.sp - n0 };
};
const m = recycle('t_m', 10, 2, 10, 3);
assert.equal(m.ground, false);
close(m.gain, 3, 'melee on the 高台: neighbour +3');
const r = recycle('t_r', 10, 4, 11, 4);
assert.equal(r.ground, true);
close(r.gain, 0, 'ranged on a melee tile: nothing');
});
@@ -0,0 +1,93 @@
// test/content/feedback3-siracusa-rolls.test.js — 叙拉古 6's true-damage proc rolls on every 普通伤害 hit of a member
// (0.1.3; community report 「叙拉古盟约真伤概率数值没有正确递增成长」, GitHub #79 「咬了一整个技能也没出」). PRTS 盟约记录
// 叙拉古: "※仅在造成普通伤害时尝试…", "每次造成普通伤害时尝试触发"; PRTS 伤害分类: 普通伤害 = attack type NORMAL, the
// default (group damage too unless it is 溅射); 溅射 / 持续 (BUFF) / 附加 (ADDITION) are other types. With the battle rng
// forced to 0 every roll hits, so the procs count the hits that rolled (the PRD rule and the amounts are unchanged).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
const L = 20;
const SIRA6 = { siracusaShip: { count: 6, active: true, tier: 2, layers: L } };
const dummy = (key) => enemyRec({ key, hp: 1e9, speed: 0, def: 0, res: 0 });
const ENEMIES = { e_dummy: dummy('e_dummy'), e_dummy2: dummy('e_dummy2') };
function forceRng(h, v) { const orig = h.b.rng; h.b.rng = Object.assign(() => v, orig); }
const isProc = (c) => !!(c.dmg && c.dmg.tags && c.dmg.tags.includes('bond:siracusa'));
const hasTag = (c, t) => !!(c.dmg && c.dmg.tags && c.dmg.tags.includes(t));
test('叙拉古 6: which damage rolls — attacks, skill hits, drone attacks yes; 溅射, 持续, 附加, item procs, element, 无来源 no', () => {
const h = makeBattle({
defs: { chess: { r0_a: chessRec({ id: 'r0_a', bonds: ['siracusaShip'], skill: null }) }, enemies: ENEMIES },
units: [{ chessId: 'r0_a', row: 10, col: 3 }], enemies: [{ key: 'e_dummy', pos: [12, 9] }], // out of her range
bonds: SIRA6, autoFinish: false, timeLimit: 300,
});
h.step(1);
const a = h.unit('r0_a'), e = h.enemies()[0];
assert.ok(a.s.flags.stealth, '隐匿 after the deployment');
forceRng(h, 0);
let procs = 0;
h.b.on('damaged', (c) => { if (isProc(c)) procs++; });
const rolls = (d) => { const n0 = procs; h.b.dealDamage(a, e, { amount: 1, ...d }); return procs - n0; };
const cases = [
['a normal attack', { type: 'phys', isAttack: true }, 1],
['a skill hit (剑雨-like)', { type: 'arts', isSkill: true, tags: ['skill'] }, 1],
['a drone attack (neither attack nor skill)', { type: 'arts', tags: ['droneAttack'] }, 1],
['a module / talent hit with no class', { type: 'arts', tags: ['talent'] }, 1],
['溅射', { type: 'phys', isAttack: true, isSplash: true }, 0],
['持续 (the S3 pulse, 缄默德克萨斯 S1)', { type: 'arts', isSkill: true, tags: ['skill', 'drone', 'dot'] }, 0],
['持续 (periodic)', { type: 'true', canDodge: false, tags: ['dot', 'periodic'] }, 0],
['附加 (拉普兰德 module, 忍冬 追凶)', { type: 'arts', tags: ['module', 'addition'] }, 0],
['an item proc', { type: 'true', canDodge: false, tags: ['item', 'item:pegasus'] }, 0],
['another bond\'s rider', { type: 'true', canDodge: false, tags: ['bond:kazimierz'] }, 0],
['element damage', { type: 'element', element: 'burn' }, 0],
['无来源 damage', { type: 'arts', canDodge: false, sourceless: true }, 0],
];
for (const [label, d, want] of cases) assert.equal(rolls(d), want, label);
checkInvariants(h.b);
});
test('叙拉古 6: 荒芜拉普兰德 S3 — every drone attack rolls, the per-second pulse (持续法术伤害) does not', () => {
const log = [];
const h = makeBattle({
defs: { enemies: ENEMIES }, seed: 7, timeLimit: 120, autoFinish: false, bonds: SIRA6,
units: [{ chessId: 'chess_char_6_18_a', row: 10, col: 3, carryState: { sp: 69 } }],
enemies: [{ key: 'e_dummy', pos: [12, 9] }],
setup(b) { b.on('damaged', (c) => log.push(c), { priority: -999 }); },
});
const u = h.unit('chess_char_6_18_a');
assert.ok(h.runUntil(() => u.skill.active, 10), 'S3 cast');
forceRng(h, 0);
const t0 = h.b.time, from = log.length;
h.run(10);
assert.ok(u.skill.active && u.s.flags.stealth, 'still in S3 and 隐匿');
const mine = log.slice(from).filter((c) => c.source === u);
const drones = mine.filter((c) => hasTag(c, 'droneAttack')).length;
const pulses = mine.filter((c) => hasTag(c, 'drone'));
const procs = mine.filter(isProc).length;
assert.ok(drones >= 5, `drone attacks ${drones}`);
assert.ok(pulses.length >= 5 && pulses.every((c) => hasTag(c, 'dot')), `pulses ${pulses.length}, 持续伤害`);
assert.equal(mine.filter((c) => c.dmg?.isAttack).length, 0, 'no attack of her own during S3');
assert.equal(procs, drones, `one roll per drone attack, none per pulse (t ${t0.toFixed(1)}–${h.b.time.toFixed(1)})`);
checkInvariants(h.b);
});
test('叙拉古 6: 德克萨斯 S2 剑雨 — both hits on every enemy around roll', () => {
const log = [];
const h = makeBattle({
defs: { enemies: ENEMIES }, seed: 3, timeLimit: 60, autoFinish: false, bonds: SIRA6,
units: [{ chessId: 'chess_char_1_08_a', row: 10, col: 3, carryState: { sp: 40 } }],
// inside the skill's diamond, outside her attack range (her tile and the one in front)
enemies: [{ key: 'e_dummy', pos: [10, 5] }, { key: 'e_dummy2', pos: [11, 4] }],
setup(b) { b.on('damaged', (c) => log.push({ c, t: b.time }), { priority: -999 }); },
});
forceRng(h, 0);
const u = h.unit('chess_char_1_08_a');
const rainHit = (x) => x.c.source === u && x.c.dmg?.isSkill && !isProc(x.c);
assert.ok(h.runUntil(() => log.some(rainHit), 5), '剑雨 cast');
const T = log.find(rainHit).t;
const mine = log.filter((x) => x.c.source === u && x.t === T).map((x) => x.c);
assert.equal(mine.filter((c) => c.dmg?.isSkill && !isProc(c)).length, 4, 'two hits on each of the two enemies');
assert.equal(mine.filter((c) => c.dmg?.isAttack).length, 0, 'no attack hit in that tick');
assert.equal(mine.filter(isProc).length, 4, 'every 剑雨 hit rolled (rng 0: each roll hits)');
checkInvariants(h.b);
});
+91
View File
@@ -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);
});
+156
View File
@@ -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();
});
+10 -7
View File
@@ -3,8 +3,9 @@
// of the modes whose official modeDataDict inactiveBondIdList switches 奥术 off (with 拉特兰 阿戈尔 卡西米尔 灵巧 奇迹 投资人
// 突袭 独行 绝技), yet 深靛 洛洛 阿罗玛 夕 圣聆初雪 stay in the pool through their other bond (server pool.js) and their cards
// showed 奥术 like any bond: the detail card's chip read "奥术 0/2 未激活" with three 奥术 operators deployed (the server
// leaves an inactive bond out of m.private.bonds) and the bond popup "在场 0/2 未激活". The cards, chips and popup now say
// 本局禁用 for a bond the mode never activates (gameLogic modeOffBonds over config.modes[modeId].inactiveBondIds).
// used to leave an inactive bond out of m.private.bonds) and the bond popup "在场 0/2 未激活". The cards, chips and popup
// say 本局禁用 for a bond the mode never activates (gameLogic modeOffBonds over config.modes[modeId].inactiveBondIds);
// since 0.1.3 the view also lists it (`off: true`) so the strip can draw the grey disc — still never in ps.bonds.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -63,13 +64,15 @@ test('a real 标准 match: 奥术 operators are buyable, three on the board neve
const used = new Set();
for (const id of members.slice(0, 3)) { const t = legalTileFor(m, ps, id, used); used.add(tileKey(t[0], t[1])); give(m, ps, id, 'board', t); }
ps.recompute();
assert.equal(ps.bonds.arcaneShip, undefined, 'official: the mode never activates 奥术');
assert.equal(ps.bonds.arcaneShip, undefined, 'official: the mode never activates 奥术 — not in the battle state');
const priv = ps.privateView();
assert.equal(priv.bonds.find((b) => b.bondId === 'arcaneShip'), undefined, 'no 奥术 entry in m.private.bonds');
const arc = priv.bonds.find((b) => b.bondId === 'arcaneShip');
assert.ok(arc && arc.off === true && arc.active === false && arc.tier === 0 && arc.count === 3, 'the view lists the members as a grey 本局禁用 disc');
assert.ok(priv.bonds.slice(priv.bonds.findIndex((b) => b.off)).every((b) => b.off), 'off entries come last');
const off = modeOffBonds(getMode(m.modeId));
// before: the chip computed 0 members from the missing entry — "奥术:在场 0/2,未激活" with 3 deployed
const before = chipOf(BondChips({ bondIds: DATA.chess[members[0]].bonds, bonds: priv.bonds }), 'arcaneShip');
assert.match(before.props.title, /在场 0\/2,未激活/);
// without the mode's off set the chip would read the new entry as an ordinary inactive bond (在场 3/3,未激活)
const bare = chipOf(BondChips({ bondIds: DATA.chess[members[0]].bonds, bonds: priv.bonds }), 'arcaneShip');
assert.match(bare.props.title, /在场 3\/3,未激活/);
const chip = chipOf(BondChips({ bondIds: DATA.chess[members[0]].bonds, bonds: priv.bonds, off }), 'arcaneShip');
assert.ok(hasClass(chip, 'is-off') && !hasClass(chip, 'is-active'));
assert.equal(chip.props.title, '奥术:本局禁用(该盟约不会激活)');
+38 -1
View File
@@ -9,7 +9,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
phaseMode, phaseBanner, isCombatPhase, isBossPhase, countdownState, phaseTotalSeconds, sortBonds, bondTier, nextThreshold,
bondMembers, bannedPerBond, priceTone, mergeProgress, shopBlockReason, deploySets, indexPieces, placementContext, canPlace,
bondMembers, memberHeadCount, bannedPerBond, priceTone, mergeProgress, shopBlockReason, deploySets, indexPieces, placementContext, canPlace,
boardTargets, dropIntent, normalizeDraft, normalizeSp, groupEnemies, factionTypes, snapHud, bossFrac, attackInterval, fmtNum,
rangeGridBox, shortcutFor, sanitizeSettings, DEFAULT_SETTINGS, normalizeResult, cycleField, fieldLabel, homeFieldId,
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, dropFailureReason,
@@ -138,9 +138,46 @@ describe('bonds', () => {
assert.equal(byId.get(m2).owned, true, 'golden in hand counts as owned base');
assert.equal(byId.get(m2).onBoard, false);
assert.equal(byId.get(m3).banned, true);
assert.equal(byId.get(m2).inHand, true, 'a golden copy in the hand marks the base in hand');
assert.equal(rows[0].id, m1, 'on-board first');
assert.equal(rows.length, b.visibleMembers.length);
});
test('memberHeadCount: the hand counts only for a bond that counts the hand, once per row', () => {
const rows = [
{ id: 'a', onBoard: true, inHand: true },
{ id: 'b', onBoard: false, inHand: true },
{ id: 'c', onBoard: false, inHand: false },
null,
];
assert.equal(memberHeadCount(rows, false), 1, 'board only');
assert.equal(memberHeadCount(rows, true), 2, 'board or hand, one row once');
assert.equal(memberHeadCount(null, true), 0);
assert.equal(memberHeadCount(undefined), 0);
const invest = bonds.investShip;
assert.equal(invest.countsHand, true);
const [m1, m2] = invest.visibleMembers;
const priv = privWith({
board: [{ ...piece(m1), row: 9, col: 3 }],
hand: [piece(m2)],
temp: [piece(invest.visibleMembers[2] || m2)],
});
const got = bondMembers(invest, priv, [], getChess);
const byId = new Map(got.map((r) => [r.id, r]));
assert.equal(byId.get(m1).onBoard, true);
assert.equal(byId.get(m2).inHand, true);
assert.equal(byId.get(m2).onBoard, false);
if (invest.visibleMembers[2]) assert.equal(byId.get(invest.visibleMembers[2]).inHand, false, 'a temporary-slot copy is not in hand');
assert.equal(memberHeadCount(got, true), 2, 'the board member and the hand member, not the temporary slot');
assert.equal(memberHeadCount(got, false), 1, 'without countsHand the hand member is left out');
});
test('sortBonds puts a mode-off bond after every live bond', () => {
const sorted = sortBonds([
{ bondId: 'investShip', off: true, active: false, layers: 99, count: 3, tier: 0 },
{ bondId: 'yanShip', active: true, layers: 1, count: 3, tier: 1 },
{ bondId: 'raidShip', off: true, layers: 0, count: 1 },
], (id) => bonds[id]).map((b) => b.bondId);
assert.deepEqual(sorted, ['yanShip', 'investShip', 'raidShip']);
});
test('bannedPerBond counts banned visible members', () => {
const b = bonds.deputShip;
const per = bannedPerBond(Object.values(bonds), [b.visibleMembers[0], b.visibleMembers[1], 'nope']);