Playtest #6 follow-up (v2.5.1): leader HP review — 直接乘算, one debuff instance, 999 layer cap, 限伤; elite to the board; 假想敌:胄 kit

- co-op leaders died within seconds: leader HP is the official data (40 values pinned); bond / strategy / item /
  机变 / 特质 "+X%" bonuses now add up (PRTS 直接乘算) instead of compounding; a same-named enemy debuff keeps one
  (strongest) instance per target (奥术 across a pair field); leader parts pass damage 1:1; fixed co-op pool (the user's call)
- official limits read from the client: each bond stops at 999 layers (MAX_GARRISON_STACK); a leader hit of 300000+ in
  boss / hidden battles is cancelled (MAX_BATTLE_DAMAGE, 限伤) — shared/constants.js switches, research 11
- a merge that consumes a deployed copy puts the elite on that tile and facing (user-confirmed); gold target highlight,
  promotion effect
- 假想敌:胄 per PRTS: one shell, shells within range, any drone death costs 2 % max HP, 剑/锤 sorties and 瘫痪
- DESIGN §20.10–§20.14; 2842 unit tests, all 28 browser suites green; Chrome = Node on boss fields
This commit is contained in:
sganggs committed 2026-10-02 04:19:28 +08:00
1 parent 6464e4b46a
commit 620f88c0d8
81 files changed
+4194 -447

No files matched your search

+9 -2
View File
@@ -28,11 +28,18 @@ test('no custom balance: legacy tuning multipliers are ignored; enemy scale = th
assert.deepEqual(hard.enemyScale(r), { hpMul: e.hp, atkMul: e.atk, speedMul: e.speed }, `R${r}: the config (PRTS) table`);
}
assert.equal(hard.bossHpMul('boss_1'), 1);
// co-op: one pool = bloodPoint[difficulty] whatever the number of alive players; solo: ×0.25 [ASSUMED, flagged]
// co-op: one pool = bloodPoint[difficulty] whatever the alive count (× alive / 4 only with config aliveScaling, off —
// DESIGN §20.10); solo: ×0.25 [ASSUMED, flagged]
for (const [modeId, key] of [['mode_multi_funny', 'FUNNY'], ['mode_multi_normal', 'NORMAL'], ['mode_multi_hard', 'HARD'], ['mode_multi_abyss', 'ABYSS']]) {
const gd = new GameData(RAW, modeId);
for (const b of ['boss_1', 'boss_5', 'boss_8']) assert.equal(gd.bossPoolHp(b), DATA.bosses[b].bloodPoint[key], `${modeId} ${b}`);
for (const b of ['boss_1', 'boss_5', 'boss_8']) {
assert.equal(gd.bossPoolHp(b), DATA.bosses[b].bloodPoint[key], `${modeId} ${b}`);
assert.equal(gd.bossPoolHp(b, 4), DATA.bosses[b].bloodPoint[key], `${modeId} ${b} four alive`);
assert.equal(gd.bossPoolHp(b, 2), DATA.bosses[b].bloodPoint[key], `${modeId} ${b} two alive: still the data value`);
}
}
assert.equal(DATA.config.bossHpScale.aliveScaling, false);
assert.equal(DATA.config.bossHpScale.aliveAssumed, true);
const solo = new GameData(RAW, 'mode_single_hard');
assert.equal(solo.bossPoolHp('boss_2'), Math.round(DATA.bosses.boss_2.bloodPoint.HARD * 0.25));
assert.equal(DATA.config.bossHpScale.soloAssumed, true);
+206
View File
@@ -0,0 +1,206 @@
// Final Assault leader HP vs the official game (user report after playtest #6: "终极 / 绝境 leaders fell within a few
// seconds; with our layers the official leader never dies that fast"). DESIGN §20.10.
// * Pool: one pool for every boss field (official tip "最终攻势中,所有人将一起对敌方领袖造成伤害"), bloodPoint[difficulty]
// of data/bosses.json (= activity_table bossInfoDict bloodPoint / Normal / Hard / Abyss of the current data; PRTS
// 盟约记录's leader table is the older 11月18日 revision, 铳 险境 and 胄 / 铳 / 萨米 绝境 differ, no 终极 column) whatever
// the number of alive players (× alive / 4 only with config bossHpScale.aliveScaling, off until confirmed).
// * Damage: the 卫戍 systems' "+X%" attribute bonuses are 直接乘算 — summed, not compounded (PRTS 盟约记录 / 游戏数据基础);
// v2.5 compounded them, which made stacked lineups kill the leaders 1.2–3× faster (more with more layers).
// Real bot matches to the Final Assault (real sim, server-run fields): every operator fighting the leader carries its
// bond / strategy / equipment bonuses in the additive bucket, both fields drain the one pool exactly once per hit.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { makeMatch, DATA } from './harness.js';
import { aggregateMods } from '../../server/sim/buffs.js';
import { bondBb } from '../../server/sim/content/bonds/addon/battle.js';
import { DataSource } from '../../server/sim/simdata.js';
import { createBattleFromSpec } from '../../server/sim/spec.js';
import { SharedBossPool } from '../../server/match/finalAssault.js';
/** 4 AI seats, co-op, LP 400 (they reach R14), +`layers` on every active bond at the boss round's prep. */
function toFinalAssault({ difficulty, seed, bossId, layers = 0 }) {
const seats = [0, 1, 2, 3].map((i) => ({ seat: i, playerId: `ai_${i}`, name: `AI${i}`, isBot: true, connected: true }));
// instant: false — the boss fields wait for the test to step them (server-run, real sim)
const h = makeMatch({ mode: 'coop', difficulty, seats, seed, captureFrames: false, instant: false });
const m = h.m;
m.bossId = bossId;
m.start();
let last = '';
h.run(() => {
const k = `${m.phase}:${m.round}`;
if (k !== last) {
last = k;
if (m.phase === PHASE.PREP && m.round === 1) for (const ps of m.players.values()) ps.lp = 400;
if (layers && m.phase === PHASE.PREP && m.round === m.gd.bossRound) {
for (const ps of m.alivePlayers()) {
for (const id of m.gd.bondIds) if (ps.bonds[id] && ps.bonds[id].active) ps.layers[id] = (ps.layers[id] || 0) + layers;
ps.recompute();
}
}
}
return h.ended != null || m.phase === PHASE.FINAL_ASSAULT;
}, { maxSteps: 8e6 });
assert.equal(m.phase, PHASE.FINAL_ASSAULT, 'reached the Final Assault');
return h;
}
// activity_table act2autochess.bossInfoDict [bloodPoint, bloodPointNormal, bloodPointHard, bloodPointAbyss] — the local
// cache and upstream ArknightsGameData master (zh_CN) were identical on 2026-10-01. PRTS 盟约记录's leader table is the
// older 11月18日 revision (铳 险境 450 000, 胄 / 铳 / 萨米 绝境 1 600 000 / 870 000 / 1 800 000, no 终极, no 卢西恩).
const OFFICIAL_BLOOD_POINT = {
boss_1: [247500, 675000, 1800000, 3600000], boss_2: [225000, 400000, 800000, 3000000],
boss_3: [285000, 708750, 2000000, 4000000], boss_4: [307500, 708750, 2100000, 4200000],
boss_5: [200000, 390000, 780000, 3000000], boss_6: [285000, 705000, 1990000, 3980000],
boss_7: [277500, 787500, 2000000, 4000000], boss_8: [750000, 937500, 3600000, 7200000],
boss_9: [675000, 900000, 2800000, 3950000], boss_10: [825000, 1012500, 3800000, 7600000],
};
test('leader HP data = the current official bossInfoDict for every leader and difficulty', () => {
assert.deepEqual(Object.keys(DATA.bosses).sort(), Object.keys(OFFICIAL_BLOOD_POINT).sort());
for (const [id, want] of Object.entries(OFFICIAL_BLOOD_POINT)) {
const bp = DATA.bosses[id].bloodPoint;
assert.deepEqual([bp.FUNNY, bp.NORMAL, bp.HARD, bp.ABYSS], want, id);
}
});
const SYSTEM_KEY = /^(bond|item|band|choice):/;
const MUL_STATS = ['atkMul', 'defMul', 'hpMul'];
test('终极 Final Assault (+200 layers per active bond): one bloodPoint pool for both fields, bonuses additive, each hit once', () => {
const h = toFinalAssault({ difficulty: 'ABYSS', seed: 2, bossId: 'boss_1', layers: 200 });
const m = h.m;
const pool = m.bossPool;
assert.equal(m.alivePlayers().length, 4);
assert.equal(pool.maxHp, DATA.bosses.boss_1.bloodPoint.ABYSS, 'full team: the data value (3 600 000)');
const fields = m.fields.filter((f) => f.battle);
assert.equal(fields.length, 2, 'two pair fields');
// one second into the fight: every operator's stats
for (const f of fields) for (let i = 0; i < 30; i++) f.battle.step();
let ops = 0, withBonds = 0;
for (const f of fields) {
for (const u of f.battle.allyUnits) {
if (u.kind !== 'op' || !u.alive) continue;
ops++;
const sys = u.buffs.filter((b) => SYSTEM_KEY.test(String(b.key)) && b.mods);
if (sys.length) withBonds++;
for (const b of sys) {
for (const k of MUL_STATS) assert.ok(!(k in b.mods), `${u.name}: ${b.key} is a 直接乘算 bonus, not ${k} (${JSON.stringify(b.mods)})`);
}
const { add, mul } = aggregateMods(u.buffs);
const want = Math.max(0, (u.base.atk + (add.atkFlat ?? 0)) * Math.max(0, 1 + (add.atkPct ?? 0)) * (mul.atkMul ?? 1));
assert.ok(Math.abs(u.s.atk - want) <= 1e-6 * Math.max(1, want), `${u.name}: ATK = (base + flat) × (1 + Σ%) × Π(提升至/runes)`);
}
}
assert.ok(ops >= 12 && withBonds >= 8, `the bots' lineups carry bond / item bonuses (${withBonds} of ${ops})`);
m.dispose();
});
test('绝境 Final Assault: both pair fields drain the one pool, every hit exactly once', () => {
const h = toFinalAssault({ difficulty: 'HARD', seed: 3, bossId: 'boss_5' });
const m = h.m;
assert.equal(m.bossPool.maxHp, DATA.bosses.boss_5.bloodPoint.HARD, 'four alive: the data value');
assert.equal(m.gd.bossPoolHp('boss_5', 2), DATA.bosses.boss_5.bloodPoint.HARD, 'two alive: the same pool (aliveScaling off)');
const pool = m.bossPool;
const fields = m.fields.filter((f) => f.battle);
assert.equal(fields.length, 2);
assert.ok(fields.every((f) => f.battle.sharedBoss === pool || f.battle.sharedBoss.pool === pool), 'one pool behind both fields');
const before = pool.hp;
const dealt = new Map();
// (element gauge fills — type 'element', 元素损伤 — fill the leader's gauge, not its HP)
for (const f of fields) f.battle.on('damaged', (c) => { if (c.target && c.target.bossPool && c.type !== 'element') dealt.set(f.fieldId, (dealt.get(f.fieldId) || 0) + c.amount); }, { priority: -1e9 });
for (let i = 0; i < 600; i++) for (const f of fields) if (!f.battle.finished) f.battle.step(); // 20 game s
const sum = [...dealt.values()].reduce((a, b) => a + b, 0);
assert.ok(dealt.get('b1') > 0 && dealt.get('b2') > 0, `both fields hit the leader (${[...dealt]})`);
assert.ok(Math.abs((before - pool.hp) - Math.min(before, sum)) <= 1e-6 * before, `the pool lost exactly the hits (${before - pool.hp} vs ${sum})`);
m.dispose();
});
test('终极 Final Assault vs 假想敌:胄 (seed 12): both players\' 奥术 never multiply on the leader; a drone costs it 2 % of the pool', () => {
// DESIGN §20.10: one 奥术 instance per target (the strongest — PRTS 作战机制 同名buff, 巴哈姆特 12316 "共享型buff會跟對面搶");
// 死亡集群's "最大生命值2%" = the leader's shown max HP, the pool (DRONE_LINK_BASE 'pool' [ASSUMED]): 72 000 at 终极
// seed 12: since the elite-to-board merge (DESIGN §20.11) the bots' boards differ; seed 7 no longer pairs two 奥术 players
const h = toFinalAssault({ difficulty: 'ABYSS', seed: 12, bossId: 'boss_1' });
const m = h.m;
const fields = m.fields.filter((f) => f.battle);
const bb = bondBb('arcaneShip');
const both = fields.find((f) => f.players.length === 2 && f.players.every((pid) => f.battle.getPlayer(pid).bonds.arcaneShip?.active));
assert.ok(both, 'precondition: a pair field where both players run 奥术 (bot lineups of seed 12)');
const allowed = [];
for (const pid of both.players) {
const b = both.battle.getPlayer(pid).bonds.arcaneShip;
const v = bb.base_damage_scale + bb.damage_scale_per_stack * b.layers;
allowed.push(v, v * bb.power_weak_scale);
}
const links = [];
for (const f of fields) {
f.battle.on('damaged', (c) => {
if (c.target && c.target.bossPool && c.type === 'true' && c.dmg && !c.dmg.origin && c.dmg.tags && c.dmg.tags.includes('hpLoss')) links.push(c.amount);
}, { priority: -1e9 });
}
let withArcane = 0;
for (let i = 0; i < 1200; i++) { // 40 game s
for (const f of fields) if (!f.battle.finished) f.battle.step();
for (const e of both.battle.enemies) {
if (!e.alive || !e.isBoss) continue;
const arc = e.buffs.filter((x) => String(x.key).startsWith('bond:arcaneShip'));
assert.ok(arc.length <= 1, `one 奥术 instance on the leader (${arc.map((x) => x.key)})`);
if (!arc.length) continue;
withArcane++;
const v = arc[0].mods.artsTakenMul;
assert.ok(allowed.some((a) => Math.abs(a - v) < 1e-9), `the 奥术 value is one player's (${v} ∉ ${allowed}), never a product`);
}
}
assert.ok(withArcane > 100, `the leader carried 奥术 (${withArcane} samples)`);
assert.ok(links.length >= 1, 'drones were shot down');
for (const x of links) assert.equal(x, DATA.bosses.boss_1.bloodPoint.ABYSS * 0.02, 'drone link = 2 % × the 3 600 000 pool');
m.dispose();
});
test('绝境 Hidden Core vs 假想敌:铳 (隐秘核心): a 碎铳之簧 passes every damage it takes to the pool 1:1, 无来源, credited', () => {
// DESIGN §20.10: PRTS 碎铳之簧 "受到伤害时令…假想敌:铳受到等量的无来源生命流失" (v2.5: half); real match → Hidden Core specs
const seats = [0, 1, 2, 3].map((i) => ({ seat: i, playerId: `ai_${i}`, name: `AI${i}`, isBot: true, connected: true }));
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', seats, seed: 7, captureFrames: false, instant: false, clientCombat: true });
const m = h.m;
m.bossId = 'boss_5';
m.hiddenBossId = 'boss_9';
const specs = [];
const orig = m._ccField.bind(m);
m._ccField = (o) => { const f = orig(o); if (f.kind === 'hidden') specs.push(f.spec); return f; };
m.start();
let last = '';
h.run(() => {
const k = `${m.phase}:${m.round}`;
if (k !== last) {
last = k;
if (m.phase === PHASE.PREP && m.round === 1) for (const ps of m.players.values()) ps.lp = 400;
if (m.phase === PHASE.PREP && m.round === m.gd.bossRound) {
for (const ps of m.alivePlayers()) {
for (const id of m.gd.bondIds) if (ps.bonds[id] && ps.bonds[id].active) ps.layers[id] = (ps.layers[id] || 0) + 100;
ps.recompute();
}
}
}
return h.ended != null || m.phase === PHASE.HIDDEN_CORE;
}, { maxSteps: 8e6 });
assert.equal(m.phase, PHASE.HIDDEN_CORE, 'reached the Hidden Core');
assert.ok(specs.length >= 1);
const pool = new SharedBossPool(m.bossPool.maxHp);
assert.equal(pool.maxHp, DATA.bosses.boss_9.bloodPoint.HARD, 'the hidden 铳 pool = bloodPoint');
const b = createBattleFromSpec(specs[0], new DataSource(DATA, null), { sharedBoss: pool, recordEvents: false, quiet: true });
// the springs stand from the start, 铳 enters ≈ 10 game s later
for (let i = 0; i < 1200 && !b.enemies.some((e) => e.alive && e.isBoss); i++) b.step();
const springs = b.enemies.filter((e) => e.alive && /enemy_90(18|19|20)_actrp/.test(e.defId));
const gun = b.enemies.find((e) => e.alive && e.isBoss);
const op = b.allyUnits.find((u) => u.kind === 'op' && u.alive);
assert.ok(springs.length >= 1 && gun && op, 'springs, 铳 and an operator on the field');
const seen = [];
b.on('damaged', (c) => { if (c.target === gun) seen.push(c); }, { priority: -1e9 });
const sp = springs.find((x) => x.defId === 'enemy_9019_actrpb') || springs[0];
const pool0 = pool.hp;
const dealt = b.dealDamage(op, sp, { amount: 50000, type: 'true', canDodge: false });
assert.ok(dealt > 0, 'the spring took damage');
assert.ok(Math.abs((pool0 - pool.hp) - dealt) < 1e-6, `the pool lost exactly what the spring took (${pool0 - pool.hp} vs ${dealt})`);
assert.ok(seen.length === 1 && seen[0].source === null && seen[0].credit === op, '无来源, credited to the operator');
m.dispose();
});
+48
View File
@@ -507,6 +507,54 @@ test('Final Assault: an early boss b.result (forced at t≈0) does not end the p
m.dispose();
});
test('Final Assault: a 999-layer kill faster than the budget — the \'cleared\' b.result whose report covers the pool waits for the budget, no takeover (QA 6b)', () => {
const { h, m, f } = faWithForger(9122);
const max = m.bossPool.maxHp;
h.sched.advance(500); // 1 game s on the field clock: the budget credits ≤ 20 % of the pool
const gt = m._fieldElapsed(f);
const by = { [f.players[0]]: max * 0.6, [f.players[1]]: max * 0.4 + 5 };
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: f.battleId, gt, killed: 0, total: 0, bossDmg: max + 5, by, leaks: 0 }), { ok: true });
const res = { reason: 'cleared', time: gt, killed: 0, total: 0, errors: 0,
perPlayer: Object.fromEntries(f.players.map((pid) => [pid, { killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: by[pid], bossDamage: by[pid], healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] }])) };
assert.equal(isBattleResult(res), true);
const rejected0 = m.verifyStats.rejected;
const takeovers0 = m.verifyStats.takeovers; // the muted client's earlier normal fields went to the server
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: f.battleId, result: res }), { ok: true });
assert.ok(m.bossPool.hp > 0.7 * max, 'still only the budget is credited');
assert.equal(f.done, false, 'the field waits for the budget');
assert.equal(f.authority, 'p_0', 'no takeover');
assert.equal(f.mode, 'client');
assert.equal(m.verifyStats.rejected, rejected0, 'not counted as rejected');
assert.ok(!h.sent.some(([pid, x]) => pid === 'p_0' && x.t === 'b.end' && x.battleId === f.battleId), 'the sender is not told to stop');
assert.equal(m._finalEnding, null);
// a re-sent copy of the result changes nothing; the silence watchdog never hands the waiting field over
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: f.battleId, result: res }), { ok: true });
h.drive(() => m._finalEnding != null || m.phase !== PHASE.FINAL_ASSAULT, { maxSteps: 2e5 });
assert.equal(m._finalEnding, 'cleared', 'the budget credited the report: the pool is empty → victory');
assert.equal(m.bossPool ? m.bossPool.hp : 0, 0);
assert.ok(m._fieldElapsed(f) <= 5 * (max + 5) / max + 1, 'within the budget\'s 5 game s');
assert.equal(f.done, true);
assert.equal(f.resultSource, 'client', 'the held client result completed the field');
assert.equal(m.verifyStats.takeovers, takeovers0);
assert.equal(m.verifyStats.rejected, rejected0);
for (const pid of f.players) assert.ok(Math.abs((f.bossBy[pid] || 0) - Math.min(by[pid], max)) <= max * 1e-9 + 5, `${pid} credited as reported (${f.bossBy[pid]} vs ${by[pid]})`);
m.dispose();
});
test('Final Assault: a \'cleared\' b.result whose report does NOT cover the pool is still handed over (the partner takes the field)', () => {
const { h, m, f } = faWithForger(9123);
const max = m.bossPool.maxHp;
h.sched.advance(500);
const res = { reason: 'cleared', time: 1, killed: 0, total: 0, errors: 0,
perPlayer: Object.fromEntries(f.players.map((pid) => [pid, { killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, bossDamage: pid === 'p_0' ? max * 0.3 : 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] }])) };
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: f.battleId, result: res }), { ok: true });
assert.equal(f.heldResult, null);
assert.equal(f.authority, 'p_1', 'the partner\'s replica is promoted');
assert.equal(h.lastTo('p_0', 'b.end').reason, 'takeover');
assert.ok(m.verifyStats.rejected >= 1);
m.dispose();
});
test('Final Assault run on the server (nobody connected at its start): a human who reconnects gets b.pool with the field\'s own damage acknowledged', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9110, fake: true, clientCombat: true, instant: false,
script: (b) => (b.kind === 'boss' ? { bossDps: 3000 } : { duration: 2 }) }).start();
+2 -1
View File
@@ -182,7 +182,8 @@ test('onLeave: a quitter has no place in the Final Assault pairing or the boss p
assert.ok(!alive.includes(first));
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
assert.deepEqual(m.fields.map((f) => f.players), [alive], 'one boss field for the two players left');
assert.equal(m.bossPool.maxHp, bossPoolHp(m.gd, m.bossId, 2), 'the co-op boss pool is bloodPoint (no alive-player factor)');
assert.equal(m.bossPool.maxHp, bossPoolHp(m.gd, m.bossId, 2), 'the co-op boss pool of two alive players');
assert.equal(m.bossPool.maxHp, m.gd.boss(m.bossId).bloodPoint[m.gd.difficulty], 'bloodPoint, no alive-player factor (DESIGN §20.10)');
const end = h.runToEnd();
assert.equal(end.victory, true);
assert.equal(end.players.find((p) => p.playerId === first).roundsPassed, 0);
+1 -1
View File
@@ -142,7 +142,7 @@ test('effect-placed pieces: a transformation / a merge elite taking a consumed c
const np = ps.transformChess(a, other);
assert.ok(np && ps.board.get(tileKey(9, 3)) === np, 'kept the tile');
assert.equal(np.dir, 'DOWN');
// merge with a full hand and temp: the elite takes a consumed copy's freed board tile, with its dir
// merge of deployed copies (PRTS: the elite goes to the consumed copy's board position), with its dir
ps.board.clear();
ps.hand.fill(null);
ps.temp.fill(null);
+15 -5
View File
@@ -17,13 +17,23 @@ test('pairing by seat: (1,2), (3,4); an odd player alone', () => {
assert.deepEqual(pairPlayers([p(3), p(1), p(0), p(2)]).map((g) => g.map((x) => x.seat)), [[0, 1], [2, 3]]);
});
test('boss pool = bloodPoint[difficulty] in co-op whatever the alive count (solo × 0.25) × tuning; shared and never negative', () => {
// research numbers (data/tuning.json left out); research 08 §6 #7: "敌方领袖的总生命值不变" — no alive-player factor
test('boss pool = bloodPoint[difficulty] in co-op whatever the alive count (× alive / 4 only with aliveScaling; solo × 0.25) × tuning; shared and never negative', () => {
// research numbers (data/tuning.json left out); DESIGN §20.10: notice 5114's "敌方领袖的总生命值不变" is about the
// mirrored copies, the one note on player count (巴哈姆特 12294 "聯機隊友(撤退/死掉)變少,最後boss血條也會變少") has no
// proportion — config bossHpScale.aliveScaling (off) would apply × alive / 4
const { tuning, ...RAW } = DATA; // eslint-disable-line no-unused-vars
const gd = new GameData(RAW, 'mode_multi_hard');
assert.equal(bossPoolHp(gd, 'boss_1', 4), 1800000);
assert.equal(bossPoolHp(gd, 'boss_1', 2), 1800000);
assert.equal(bossPoolHp(gd, 'boss_1', 1), 1800000);
for (const n of [4, 3, 2, 1, undefined, 9]) assert.equal(bossPoolHp(gd, 'boss_1', n), 1800000, `${n} alive`);
assert.equal(gd.bossPoolHp('boss_1', 2), bossPoolHp(gd, 'boss_1', 2), 'GameData agrees');
// the flip: config bossHpScale.aliveScaling true scales the pool by alive / 4
const scaled = new GameData({ ...RAW, config: { ...RAW.config, bossHpScale: { ...RAW.config.bossHpScale, aliveScaling: true },
modes: { ...RAW.config.modes, mode_multi_hard: { ...RAW.config.modes.mode_multi_hard, bossHpScale: { ...RAW.config.modes.mode_multi_hard.bossHpScale, aliveScaling: true } } } } }, 'mode_multi_hard');
assert.equal(bossPoolHp(scaled, 'boss_1', 4), 1800000);
assert.equal(bossPoolHp(scaled, 'boss_1', 3), 1350000);
assert.equal(bossPoolHp(scaled, 'boss_1', 2), 900000);
assert.equal(bossPoolHp(scaled, 'boss_1', 1), 450000);
assert.equal(bossPoolHp(scaled, 'boss_1'), 1800000, 'no count given: a full team');
assert.equal(bossPoolHp(scaled, 'boss_1', 9), 1800000, 'never above the data value');
assert.equal(bossPoolHp(new GameData(RAW, 'mode_single_abyss'), 'boss_5', 1), 750000);
assert.equal(bossPoolHp(new GameData(RAW, 'mode_single_funny'), 'boss_2', 1), 56250);
// the balance layer multiplies the pool (docs/BALANCE.md)
+3 -2
View File
@@ -67,7 +67,7 @@ test('PREP: a player who un-readies right after everybody was ready keeps the pr
m.dispose();
});
test('merge with a full hand and a full temp: the elite takes a freed board tile and keeps the returned items', () => {
test('merge with a full hand and a full temp: the elite takes its deployed copy\'s tile (PRTS) and keeps the returned items', () => {
const { m, ps } = prepSolo({ seed: 903 });
const id = chessOfTier(1, (c) => c.position === 'MELEE').find((c) => m.pool.has(c) && m.gd.mergeCount(c) === 3);
const t1 = legalTileFor(m, ps, id);
@@ -87,7 +87,8 @@ test('merge with a full hand and a full temp: the elite takes a freed board tile
checkInvariants(m);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true }, 'a purchase that completes a merge is allowed with a full hand');
const elite = [...ps.board.values()].find((p) => p.id === m.gd.goldenIdOf(id));
assert.ok(elite, 'the elite stands on a freed tile');
assert.ok(elite, 'the elite stands on a consumed copy\'s tile');
assert.equal(ps.find(elite.uid).key, `${t1[0]},${t1[1]}`, 'the copy that deploys first (legalTileFor walks the deploy order)');
assert.equal(elite.poolCopies, 3, 'it holds all three copies');
assert.deepEqual(elite.items.map((x) => x.id).sort(), ['chess_item_1_01_e_a', 'chess_item_2_04_e_a'], 'returned items are kept on it');
checkInvariants(m);
+46
View File
@@ -0,0 +1,46 @@
// Bond layer cap (user report after playtest #6, DESIGN §20.10 / §20.12): a bond holds at most BOND_LAYER_CAP — 999, the
// client's AutoChessBattleConst.MAX_GARRISON_STACK (research 11 §1; the community also never names a count above 999:
// 巴哈姆特 12534 "每把都能999层", 12316 "999謝"). v2.5 had no cap, so per-layer bonuses kept growing past anything the
// official game reaches. Prep-side gains (PlayerState.addLayers: items, choices, bands, garrisons) and the IN_BATTLE gains
// merged at settlement both stop there (shared/constants.js layerGainRoom, the one implementation).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE, BOND_LAYER_CAP } from '../../shared/constants.js';
import { MetaRegistry } from '../../server/match/effectsMeta.js';
import { registerBuiltins } from '../../server/match/builtinMeta.js';
import { DATA, makeMatch, checkInvariants } from './harness.js';
test('prep-side layer gains stop at 999; a gain at the cap adds nothing and fires no onLayers', () => {
assert.equal(BOND_LAYER_CAP, 999);
const reg = registerBuiltins(new MetaRegistry(), DATA);
const seen = [];
reg.global('watch', { onLayers: (ctx, ev) => { seen.push([ev.bondId, ev.from, ev.to]); } });
const h = makeMatch({ mode: 'solo', seed: 81, registry: reg, fake: true }).start();
h.toPrep(1);
const ps = h.ps('p_0');
ps.layers.yanShip = 990;
assert.equal(ps.addLayers('yanShip', 20, { reason: 'test' }), 9, 'only the room left under the cap');
assert.equal(ps.layers.yanShip, 999);
assert.equal(ps.addLayers('yanShip', 5, { reason: 'test' }), 0, 'at the cap: nothing');
assert.equal(ps.layers.yanShip, 999);
assert.deepEqual(seen, [['yanShip', 990, 999]], 'one onLayers, with the clamped value');
assert.equal(ps.addLayers('sargonShip', 40, { reason: 'test' }), 40, 'other bonds unaffected');
checkInvariants(h.m);
h.m.dispose();
});
test('IN_BATTLE gains merged at settlement stop at 999 too; below the cap they apply in full', () => {
const h = makeMatch({
mode: 'coop', humans: 2, seed: 82, fake: true,
script: (b) => (b.round === 1 ? { layerGains: { p_0: { yanShip: 50 }, p_1: { yanShip: 50 } } } : {}),
}).start();
const m = h.m;
h.toPrep(1);
const [a, b] = [h.ps('p_0'), h.ps('p_1')];
a.layers.yanShip = 980;
h.drive(() => m.phase === PHASE.SETTLE);
assert.equal(a.layers.yanShip, 999, '980 + 50 stops at 999');
assert.equal(b.layers.yanShip, 50);
checkInvariants(m);
m.dispose();
});
+65 -26
View File
@@ -3,6 +3,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js';
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier, legalTileFor } from './harness.js';
import { canPlace, positionClass } from '../../server/match/board.js';
function prep(seed = 21, o = {}) {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed, ...o }).start();
@@ -22,11 +23,12 @@ function stock(m, ps, id, slot = 0) {
ps.shop.slots[slot] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), frozen: false, sold: false };
}
test('3 copies (board + hand + bought) merge into 1 elite in the hand; equipment returns; reward offer of tier level+1', () => {
test('3 copies (board + hand + bought) merge into 1 elite on the board copy\'s tile; equipment returns; reward offer of tier level+1', () => {
const { m, ps } = prep();
const id = chessOfTier(1, (c) => c.position === 'MELEE').find((x) => m.pool.has(x));
const cap = m.pool.cap(id);
const a = give(m, ps, id, 'board', [9, 3]);
a.dir = 'UP';
const b = give(m, ps, id);
const it1 = giveItem(m, ps, 'chess_item_1_01_e_a');
const it2 = giveItem(m, ps, 'chess_item_1_02_e_a');
@@ -35,9 +37,13 @@ test('3 copies (board + hand + bought) merge into 1 elite in the hand; equipment
stock(m, ps, id);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
const golden = DATA.chess[id].goldenId;
assert.ok(!ps.board.has('9,3'), 'the board copy was consumed');
const elite = ps.hand.find((p) => p && p.id === golden);
assert.ok(elite, 'elite in the hand');
// PRTS 卫戍协议/帮助: "若消耗已部署至作战区的干员,则发送至作战区对应位置"
const elite = ps.board.get('9,3');
assert.ok(elite && elite.id === golden, 'the elite took the consumed board copy\'s tile');
assert.notEqual(elite.uid, a.uid, 'a new piece (the copy was destroyed)');
assert.equal(elite.dir, 'UP', 'with that copy\'s facing');
assert.ok(!ps.hand.some((p) => p && p.kind === 'chess'), 'no chess left in the hand');
assert.equal(ps.deployCount, 1, 'the deploy count is unchanged');
assert.equal(elite.poolCopies, 3);
assert.equal(m.pool.left(id), cap - 3);
assert.equal(ps.countCopies(id), 0);
@@ -97,18 +103,44 @@ test('elites never merge; 风丸 merges with 2 copies; a full hand still buys th
m.dispose();
});
test('merge from two board copies with a full hand overflows the elite into temp (blocks ready until resolved)', () => {
test('merge from two board copies with a full hand: the elite takes the copy that deploys first (no overflow, one tile freed)', () => {
const { m, ps } = prep(23);
const ids = chessOfTier(1, (c) => c.position === 'MELEE').filter((c) => m.pool.has(c));
const id = ids[0];
give(m, ps, id, 'board', [9, 3]);
give(m, ps, id, 'board', [9, 4]);
const a = give(m, ps, id, 'board', [9, 4]);
const b = give(m, ps, id, 'board', [9, 3]);
a.dir = 'LEFT';
b.dir = 'DOWN';
const others = chessOfTier(2).filter((c) => m.pool.has(c));
for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, others[i]);
stock(m, ps, id);
assert.equal(ps.deployCount, 2);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
// deploy order = row desc, then col asc: (9,3) before (9,4) — [ASSUMED] the copy the battle deploys first
const elite = ps.board.get('9,3');
assert.ok(elite && elite.id === DATA.chess[id].goldenId, 'the elite stands on (9,3)');
assert.equal(elite.dir, 'DOWN', 'with the facing of the copy that stood there');
assert.ok(!ps.board.has('9,4'), 'the other copy\'s tile is free');
assert.equal(ps.deployCount, 1, 'two deployed copies became one deployed elite');
assert.ok(ps.tempEmpty, 'nothing overflowed');
assert.equal(ps.hand.filter(Boolean).length, 10, 'the full hand is untouched');
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true });
checkInvariants(m);
m.dispose();
});
test('merge whose copies are not deployed (temp) with a full hand overflows the elite into temp (blocks ready until resolved)', () => {
const { m, ps } = prep(23);
const ids = chessOfTier(1, (c) => c.position === 'MELEE').filter((c) => m.pool.has(c));
const id = ids[0];
give(m, ps, id, 'temp', 0);
give(m, ps, id, 'temp', 1);
const others = chessOfTier(2).filter((c) => m.pool.has(c));
for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, others[i]);
stock(m, ps, id);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
assert.ok(ps.temp.some((p) => p && p.id === DATA.chess[id].goldenId), 'elite overflowed into temp');
assert.equal(ps.board.size, 0);
assert.equal(ps.board.size, 0, 'nothing was deployed');
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { error: ERR.TEMP_NOT_EMPTY });
checkInvariants(m);
m.dispose();
@@ -264,10 +296,12 @@ test('item merge with a full hand AND a full temp: the golden item takes the equ
m.dispose();
});
test('a merge completed during SETTLE (突变细胞) keeps its reward offer for the next prep; with a full hand the elite waits in temp for that prep', () => {
test('a merge completed during SETTLE (突变细胞) keeps its reward offer for the next prep; the elite takes a deployed copy\'s tile (the carrier\'s counts) or goes to the hand', () => {
const X = 'chess_char_2_04_a';
const fillers = Object.values(DATA.items).filter((i) => i.itemType === 'EQUIP' && !i.isGolden && !String(i.kind || '').startsWith('consume')).map((i) => i.itemId ?? i.id).filter(Boolean);
for (const onBoard of [false, true]) {
// where the carrier of 突变细胞 and the two copies of X are: hand/hand, board/hand, board/board
for (const [carrierAt, copiesAt] of [['hand', 'hand'], ['board', 'hand'], ['board', 'board']]) {
const label = `carrier ${carrierAt}, copies ${copiesAt}`;
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 3, fake: true }).start();
const m = h.m;
h.toPrep(1);
@@ -280,35 +314,40 @@ test('a merge completed during SETTLE (突变细胞) keeps its reward offer for
const saved = new Map();
for (const [base, e] of m.pool.entries) if (e.tier === 2 && base !== X) { saved.set(base, e.left); e.left = 0; }
const T = chessOfTier(1, (c) => !m.gd.placeableTokens(c.chessId).length).find((id) => m.pool.has(id));
const holder = give(m, ps, T, 'board', legalTileFor(m, ps, T));
const holder = carrierAt === 'board' ? give(m, ps, T, 'board', legalTileFor(m, ps, T)) : give(m, ps, T);
holder.items.push(ps.newPiece('item', 'chess_item_5_08_e_a'));
const copies = onBoard ? [0, 1].map(() => give(m, ps, X, 'board', legalTileFor(m, ps, X))) : [give(m, ps, X), give(m, ps, X)];
const tiles = onBoard ? copies.map((p) => ps.find(p.uid).key) : [];
const copies = copiesAt === 'board' ? [0, 1].map(() => give(m, ps, X, 'board', legalTileFor(m, ps, X))) : [give(m, ps, X), give(m, ps, X)];
const deployedTiles = [holder, ...copies].map((p) => ps.find(p.uid)).filter((l) => l.area === 'board').map((l) => l.key);
const deployed0 = ps.deployCount;
for (let i = 0; ps.hand.some((x) => x == null); i++) giveItem(m, ps, fillers[i]);
const fillers0 = ps.hand.filter((p) => p && p.kind === 'item').length;
let atSettle = null;
const settle = m.settle.bind(m);
m.settle = (...a) => { const r = settle(...a); atSettle = { offers: ps.offers.map((o) => o.source), merges: ps.stats.merges }; return r; };
h.toPrep(2);
for (const [base, left] of saved) m.pool.entries.get(base).left = left;
assert.deepEqual(atSettle, { offers: ['merge'], merges: 1 }, `${onBoard ? 'board' : 'hand'} copies: the transform completed a merge in SETTLE`);
assert.deepEqual(atSettle, { offers: ['merge'], merges: 1 }, `${label}: the transform completed a merge in SETTLE`);
const elite = ps.allChess().find((p) => p.id === m.gd.goldenIdOf(X));
assert.ok(elite, 'the elite survived the round start');
assert.deepEqual(ps.offers.map((o) => o.source), ['merge'], 'the promotion reward waits for this prep');
assert.ok(ps.privateView().shop.rewardOffer, 'and is shown');
if (onBoard) {
// research 01 A1: the elite goes to the hand, never straight to a board tile — the full hand overflows it into
// temp, where it stays (not wiped at the round start) and blocks Ready until the player places it
assert.equal(ps.find(elite.uid).area, 'temp', 'full hand outside PREP: the elite overflowed into temp');
assert.ok(ps.privateView().temp.some((v) => v && v.uid === elite.uid), 'shown in the temp row');
assert.equal(ps.privateView().canReady, false);
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { error: ERR.TEMP_NOT_EMPTY });
const [r, c] = tiles[0].split(',').map(Number);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: elite.uid, to: { area: 'board', row: r, col: c } }), { ok: true }, 'placed on a freed tile');
assert.ok(ps.tempEmpty && ps.privateView().canReady);
const loc = ps.find(elite.uid);
if (deployedTiles.length) {
// PRTS 卫戍协议/帮助: a merge consuming a deployed copy sends the elite to that copy's tile — outside PREP too; the
// transformed carrier stood on the board, so its tile counts (when legal for the elite); of several, the first
// in deploy order (row desc, col asc)
const map = ps.deployMap();
const pos = positionClass(m.gd.chess(elite.id));
const legal = deployedTiles.map((k) => k.split(',').map(Number)).filter(([r, c]) => canPlace(map, pos, r, c)).sort((a, b) => b[0] - a[0] || a[1] - b[1]);
assert.ok(legal.length, `${label}: a legal deployed tile exists`);
assert.equal(loc.area, 'board', `${label}: the elite stands on the board`);
assert.equal(loc.key, `${legal[0][0]},${legal[0][1]}`, `${label}: on the first deployed tile in deploy order`);
assert.ok(ps.deployCount <= deployed0, `${label}: the deploy count did not grow (${ps.deployCount} ≤ ${deployed0})`);
} else {
assert.equal(ps.find(elite.uid).area, 'hand', 'the consumed hand copies freed the slots');
assert.equal(loc.area, 'hand', `${label}: the consumed hand pieces freed the slots`);
}
assert.ok(ps.tempEmpty);
assert.equal(ps.hand.filter((p) => p && p.kind === 'item').length, fillers0, `${label}: the hand's equipment stays`);
assert.ok(ps.tempEmpty && ps.privateView().canReady, `${label}: nothing waits in temp`);
checkInvariants(m);
// it expires at the end of that prep like any other offer
h.drive(() => m.phase === PHASE.COMBAT && m.round === 2);
+504
View File
@@ -0,0 +1,504 @@
// User playtest #6 follow-up — a merge's elite goes to the board when it consumed a deployed copy. The user (first-hand,
// official game): "官方就是合成精锐时,如果消耗了场上的干员,精锐会出现在场上那个位置". PRTS 卫戍协议/帮助 §干员的获得与精锐化:
// "不获得第3名干员,销毁已有的2名初始干员,发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员,则发送至作战区
// 对应位置)". Remake (server/match/PlayerState.js _mergeChess, board.js mergeTile): the elite takes the tile and facing of the
// consumed copy that deploys first (row desc, col asc — [ASSUMED] when several stood on the board); a transformed deployed
// piece's own tile counts; with no deployed copy it goes to the hand (overflow temp). Equipment returns to the hand
// ("干员晋级后已配发装备会回收至整备区"), the copies' summons are removed and the elite on the board gets its own stack
// (its loadout), the deploy count never grows, the reward offer is unchanged — for buys, rewards, effect grants, SETTLE
// merges and the boss-field prep alike. audit.js checks the rule in every audited match.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js';
import { checkLoadout } from '../../shared/protocol.js';
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier, legalTileFor } from './harness.js';
import { canPlace, positionClass, tileKey, mergeTile } from '../../server/match/board.js';
import { makeCtx } from '../../server/match/effectsMeta.js';
import { attachAudit } from '../../server/match/audit.js';
import { botPrep } from '../../server/match/bot.js';
const chess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
const SILENCE = 'chess_char_2_02_a'; // 赫默: S2 (default) makes the 医疗探机 hand card, S1 none
const DRONE = 'token_10000_silent_healrb';
const VIGIL = 'chess_char_3_19_a'; // 伺夜: talent summon 狼群 (a hand card)
const WOLF = 'token_10028_vigil_wolf';
const KAZEMARU = 'chess_char_2_11_a'; // 风丸: merges with 2 copies
function prep({ seed = 41, loadout = null, stageId = 'act2autochess_m04' } = {}) {
const seats = [{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, loadout }];
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats, seed }).start();
h.toPrep(1);
h.setStage(stageId);
const ps = h.ps('p_0');
clean(ps);
ps.funds = 100;
return { h, m: h.m, ps };
}
function clean(ps) {
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.recompute();
}
/** Put chess `id` into shop slot `slot` at its normal price. */
function stock(m, ps, id, slot = 0) {
ps.shop.slots[slot] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), frozen: false, sold: false };
}
/** Deploy a copy of `id` on its next legal tile (reading order, skipping `skip`) with facing `dir`. */
function deploy(m, ps, id, dir = 'RIGHT', skip = new Set()) {
const at = legalTileFor(m, ps, id, skip);
assert.ok(at, `a legal tile for ${id}`);
const p = give(m, ps, id, 'board', at);
p.dir = dir;
return p;
}
const meleeTier1 = (m) => chessOfTier(1, (c) => c.position === 'MELEE' && !m.gd.placeableTokens(c.chessId).length).filter((id) => m.pool.has(id) && m.gd.mergeCount(id) === 3);
const keyOf = (ps, p) => ps.find(p.uid)?.key ?? null;
const eliteOf = (ps, id) => ps.allChess().find((p) => p.id === chess(id).goldenId) ?? null;
const tokens = (ps, id) => [...ps.hand, ...ps.temp, ...ps.board.values()].filter((p) => p && p.kind === 'token' && (!id || p.id === id));
test('buy: one deployed copy + one hand copy → the elite takes the deployed copy\'s tile and facing; deploy count and reward offer as before', () => {
const { m, ps } = prep();
const [id] = meleeTier1(m);
const a = deploy(m, ps, id, 'DOWN');
const tile = keyOf(ps, a);
give(m, ps, id);
stock(m, ps, id);
assert.equal(ps.deployCount, 1);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
const elite = ps.board.get(tile);
assert.ok(elite && elite.id === chess(id).goldenId, 'the elite stands where the deployed copy stood');
assert.equal(elite.dir, 'DOWN', 'with its facing');
assert.equal(elite.poolCopies, 3, 'it holds the three copies');
assert.equal(ps.deployCount, 1, 'the deploy count is unchanged');
assert.ok(!ps.hand.some(Boolean) && ps.tempEmpty, 'hand and temp are empty');
assert.equal(ps.stats.merges, 1);
const offer = ps.privateView().shop.rewardOffer;
assert.ok(offer && offer.slots.length === 3 && offer.tier === Math.min(ps.shop.level + 1, 6), 'the promotion reward is offered as before');
const view = ps.privateView().board.find((v) => v.uid === elite.uid);
assert.deepEqual([view.row, view.col, view.dir, view.golden], [...tile.split(',').map(Number), 'DOWN', true], 'm.private shows it on the tile');
checkInvariants(m);
m.dispose();
});
test('buy: two deployed copies → the copy that deploys first (row desc, then col asc) gives its tile and facing, in either placement order', () => {
for (const order of ['first-placed-first', 'first-placed-last']) {
const { m, ps } = prep({ seed: 42 });
const [id] = meleeTier1(m);
// two legal tiles, the first of them in deploy order
const t1 = legalTileFor(m, ps, id);
const t2 = legalTileFor(m, ps, id, new Set([tileKey(...t1)]));
const [early, late] = order === 'first-placed-first' ? [t1, t2] : [t2, t1];
const a = give(m, ps, id, 'board', early);
const b = give(m, ps, id, 'board', late);
a.dir = 'UP';
b.dir = 'LEFT';
stock(m, ps, id);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
const want = mergeTile([t1, t2].map((t) => ({ key: tileKey(...t) })));
assert.equal(want.key, tileKey(...t1), 'legalTileFor walks the deploy order');
const elite = ps.board.get(want.key);
assert.ok(elite && m.gd.isGolden(elite.id), `${order}: the elite on ${want.key}`);
assert.equal(elite.dir, want.key === tileKey(...early) ? 'UP' : 'LEFT', `${order}: the facing of the copy that stood there`);
assert.ok(!ps.board.has(tileKey(...t2)), `${order}: the other copy's tile is free`);
assert.equal(ps.deployCount, 1, `${order}: two deployed copies → one deployed elite`);
checkInvariants(m);
m.dispose();
}
});
test('three deployed copies (a deployed operator transformed into the third, 突变细胞-style) → the first of the three tiles in deploy order', () => {
const { m, ps } = prep({ seed: 43 });
const [id, other] = meleeTier1(m);
const carrier = deploy(m, ps, other, 'UP');
const c1 = deploy(m, ps, id, 'LEFT');
const c2 = deploy(m, ps, id, 'DOWN');
const tiles = [carrier, c1, c2].map((p) => keyOf(ps, p));
const dirOf = { [tiles[0]]: 'UP', [tiles[1]]: 'LEFT', [tiles[2]]: 'DOWN' };
const elite = ps.transformChess(carrier, id);
assert.ok(elite && elite.id === chess(id).goldenId, 'the transformation completed the merge');
const want = mergeTile(tiles.map((key) => ({ key })));
assert.equal(keyOf(ps, elite), want.key, 'the elite takes the first deployed tile');
assert.equal(elite.dir, dirOf[want.key]);
assert.equal(ps.deployCount, 1, 'three deployed → one');
assert.equal(ps.stats.merges, 1);
checkInvariants(m);
m.dispose();
});
test('only hand copies → the elite goes to the hand (a free board slot does not pull it onto the board); 风丸 merges 2 the same way', () => {
const { m, ps } = prep({ seed: 44 });
const [id, filler] = meleeTier1(m);
deploy(m, ps, filler);
give(m, ps, id);
give(m, ps, id);
stock(m, ps, id);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
const elite = eliteOf(ps, id);
assert.equal(ps.find(elite.uid).area, 'hand', 'no copy was deployed: the hand');
assert.equal(ps.deployCount, 1, 'the board is unchanged');
// 风丸: one deployed copy + the bought one
if (m.pool.has(KAZEMARU) && m.gd.mergeCount(KAZEMARU) === 2) {
const k = deploy(m, ps, KAZEMARU, 'UP');
const tile = keyOf(ps, k);
stock(m, ps, KAZEMARU);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
const ke = ps.board.get(tile);
assert.ok(ke && ke.id === chess(KAZEMARU).goldenId && ke.dir === 'UP', '风丸\'s elite takes its deployed copy\'s tile');
}
checkInvariants(m);
m.dispose();
});
test('equipment: the consumed copies\' items return to the hand, the elite on the board starts with empty slots', () => {
const { m, ps } = prep({ seed: 45 });
const [id] = meleeTier1(m);
const a = deploy(m, ps, id);
const b = give(m, ps, id);
const i1 = giveItem(m, ps, 'chess_item_1_01_e_a');
const i2 = giveItem(m, ps, 'chess_item_1_02_e_a');
const i3 = giveItem(m, ps, 'chess_item_2_04_e_a');
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: i1.uid, targetUid: a.uid }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: i2.uid, targetUid: a.uid }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: i3.uid, targetUid: b.uid }), { ok: true });
const tile = keyOf(ps, a);
stock(m, ps, id);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
const elite = ps.board.get(tile);
assert.ok(elite && m.gd.isGolden(elite.id));
assert.deepEqual(elite.items, [], '"干员晋级后已配发装备会回收至整备区,需要重新配置"');
for (const it of [i1, i2, i3]) assert.equal(ps.find(it.uid)?.area, 'hand', `${it.id} is back in the hand`);
// and can be equipped again on the deployed elite
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: i1.uid, targetUid: elite.uid }), { ok: true });
checkInvariants(m);
m.dispose();
});
test('equipment with the hand and temp full: the elite keeps up to its 2 equip slots, any further item is destroyed (logged)', () => {
const { m, ps } = prep({ seed: 47 });
const [id] = meleeTier1(m);
const a = deploy(m, ps, id, 'UP');
const b = deploy(m, ps, id, 'DOWN');
const ids = ['chess_item_1_01_e_a', 'chess_item_1_02_e_a', 'chess_item_1_05_e_a', 'chess_item_2_04_e_a'];
const its = ids.map((x) => giveItem(m, ps, x));
its.forEach((it, i) => assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: (i < 2 ? a : b).uid }), { ok: true }));
const fill = [2, 3].flatMap((t) => chessOfTier(t)).filter((x) => m.pool.has(x));
let n = 0;
while (ps.hand.some((p) => p == null)) give(m, ps, fill[n++]);
while (ps.temp.some((p) => p == null)) give(m, ps, fill[n++], 'temp');
const want = mergeTile([a, b].map((p) => ({ key: keyOf(ps, p) }))).key;
const warns = [];
const warn = m.log.warn;
m.log.warn = (s) => { warns.push(String(s)); };
stock(m, ps, id);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true }, 'a merge-completing buy needs no hand slot');
m.log.warn = warn;
const elite = ps.board.get(want);
assert.ok(elite && m.gd.isGolden(elite.id), 'the elite took the first deployed copy\'s tile');
assert.equal(elite.items.length, m.gd.equipPerChess, 'it keeps 2 of the 4 returned items');
const kept = new Set(elite.items.map((it) => it.uid));
assert.ok([...kept].every((u) => its.some((it) => it.uid === u)), 'the kept items are returned ones');
assert.equal(its.filter((it) => !kept.has(it.uid) && ps.find(it.uid)).length, 0, 'the other two are nowhere (hand, temp, board)');
assert.equal(warns.filter((s) => /returned item .* destroyed \(no space\)/.test(s)).length, ids.length - m.gd.equipPerChess, 'the other two are destroyed with a log warning');
checkInvariants(m);
m.dispose();
});
test('summons: the copies\' placed summons and stacks are removed; the elite on the board gets its own full stack (伺夜 狼群)', () => {
const { m, ps } = prep({ seed: 46 });
assert.ok(m.pool.has(VIGIL), '伺夜 is in this match\'s pool');
const a = give(m, ps, VIGIL);
const at = legalTileFor(m, ps, VIGIL);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: at[0], col: at[1] }, dir: 'UP' }), { ok: true });
const stack = ps.hand.find((p) => p && p.kind === 'token' && p.ownerUid === a.uid);
assert.ok(stack && stack.id === WOLF, 'the deployed copy\'s 狼群 card');
// one wolf placed, the rest of the stack in the hand
const wt = legalTileFor(m, ps, VIGIL);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: stack.uid, to: { area: 'board', row: wt[0], col: wt[1] } }), { ok: true });
assert.ok(tokens(ps, WOLF).some((t) => ps.find(t.uid).area === 'board'), 'a wolf is placed');
give(m, ps, VIGIL);
stock(m, ps, VIGIL);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
const elite = ps.board.get(tileKey(...at));
assert.ok(elite && elite.id === chess(VIGIL).goldenId && elite.dir === 'UP', 'the elite took the copy\'s tile');
const left = tokens(ps, WOLF);
assert.ok(left.every((t) => t.ownerUid === elite.uid), 'no summon of a consumed copy is left (placed or stacked)');
assert.ok(!ps.board.has(tileKey(...wt)), 'the placed wolf left the board');
const [{ count }] = m.gd.placeableTokens(elite.id, ps.loadoutFor(chess(elite.id)));
assert.equal(left.length, 1, 'one fresh stack');
assert.equal(ps.find(left[0].uid).area, 'hand');
assert.equal(left[0].count, count, 'deploy-limit copies of the elite');
checkInvariants(m);
m.dispose();
});
test('loadout: the deployed elite\'s summon card and battle unit follow the player\'s loadout (赫默 S2 default → drone card, S1 → none)', () => {
for (const [label, loadout, drone] of [['default S2', null, true], ['S1', checkLoadout({ [SILENCE]: { skill: 0 } }, chess).loadout, false]]) {
const { m, ps } = prep({ seed: 47, loadout });
assert.ok(m.pool.has(SILENCE), '赫默 is in this match\'s pool');
const a = deploy(m, ps, SILENCE, 'LEFT');
const tile = keyOf(ps, a);
give(m, ps, SILENCE);
stock(m, ps, SILENCE);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
const elite = ps.board.get(tile);
assert.ok(elite && elite.id === chess(SILENCE).goldenId, `${label}: the elite took the tile`);
const cards = tokens(ps, DRONE);
assert.equal(cards.length, drone ? 1 : 0, `${label}: drone card`);
if (drone) assert.equal(cards[0].ownerUid, elite.uid);
const u = ps.battleInput({ side: 'L', colOffset: 0 }).units.find((x) => x.uid === elite.uid);
const lo = ps.loadoutFor(chess(elite.id));
assert.ok(u, `${label}: the elite fights`);
assert.deepEqual([u.skillIndex, u.moduleId, u.dir], [lo.skillIndex, lo.moduleId, 'LEFT'], `${label}: with the loadout's skill / module and the copy's facing`);
assert.equal(u.skillIndex, drone ? 1 : 0);
checkInvariants(m);
m.dispose();
}
});
test('deploy cap: a merge with a deployed copy at the cap is never refused and never grows the count', () => {
const { m, ps } = prep({ seed: 48 });
const ids = meleeTier1(m);
const id = ids[0];
const ranged = [1, 2].flatMap((t) => chessOfTier(t, (c) => c.position !== 'MELEE' && !m.gd.placeableTokens(c.chessId).length)).filter((x) => m.pool.has(x));
const a = deploy(m, ps, id, 'UP');
const b = deploy(m, ps, id, 'DOWN');
for (let i = 0; ps.deployCount < ps.deployCap; i++) deploy(m, ps, ranged[i]);
assert.equal(ps.deployCount, ps.deployCap, 'the board is full');
const first = mergeTile([keyOf(ps, a), keyOf(ps, b)].map((key) => ({ key })));
stock(m, ps, id);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true }, 'no BOARD_FULL: the elite replaces a deployed copy');
assert.ok(m.gd.isGolden(ps.board.get(first.key).id));
assert.equal(ps.deployCount, ps.deployCap - 1, 'two deployed copies → one elite');
// one deployed copy at the cap: unchanged
const id2 = ids[1];
const extra = deploy(m, ps, id2, 'RIGHT');
assert.equal(ps.deployCount, ps.deployCap);
give(m, ps, id2);
stock(m, ps, id2);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
assert.ok(!ps.find(extra.uid));
assert.equal(ps.deployCount, ps.deployCap, 'still at the cap');
checkInvariants(m);
m.dispose();
});
test('rewards and effect grants (g.reward, a band\'s ctx.grantChess) complete merges the same way', () => {
const { m, ps } = prep({ seed: 49 });
const [id, id2] = meleeTier1(m);
// reward pick
const a = deploy(m, ps, id, 'UP');
const tile = keyOf(ps, a);
give(m, ps, id);
ps.pushRewardOffer('effect', { ids: [id] });
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }), { ok: true });
assert.ok(m.gd.isGolden(ps.board.get(tile)?.id), 'g.reward: the elite on the copy\'s tile');
// a band grant (ctx.grantChess → acquireChess)
const b = deploy(m, ps, id2, 'LEFT');
const tile2 = keyOf(ps, b);
give(m, ps, id2);
const ctx = makeCtx(m, ps, { kind: 'band', key: 'band:test' }, 'onRoundStart');
const got = ctx.grantChess(id2);
assert.ok(got && got.id === chess(id2).goldenId, 'the grant returned the elite');
assert.equal(ps.board.get(tile2)?.uid, got.uid, 'band grant: the elite on the copy\'s tile');
assert.equal(ps.board.get(tile2).dir, 'LEFT');
checkInvariants(m);
m.dispose();
});
test('after the prep: a SETTLE grant completing a merge puts the elite on the deployed copy\'s tile; the offer waits for the next prep', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 50, fake: true }).start();
const m = h.m;
h.toPrep(1);
h.setStage('act2autochess_m04');
const ps = h.ps('p_0');
clean(ps);
const [id] = meleeTier1(m);
const a = deploy(m, ps, id, 'DOWN');
const tile = keyOf(ps, a);
give(m, ps, id);
// fill the hand: no room is needed for the elite
const fillers = ['chess_item_1_01_e_a', 'chess_item_1_02_e_a', 'chess_item_1_03_e_a', 'chess_item_1_04_e_a', 'chess_item_1_05_e_a', 'chess_item_2_01_e_a', 'chess_item_2_02_e_a', 'chess_item_2_03_e_a', 'chess_item_2_04_e_a'];
for (let i = 0; ps.hand.some((x) => x == null); i++) giveItem(m, ps, fillers[i]);
h.drive(() => m.phase === PHASE.SETTLE);
const elite = ps.acquireChess(id, { source: 'test' });
assert.ok(elite && ps.board.get(tile) === elite, 'SETTLE: the elite took the tile at once');
assert.equal(elite.dir, 'DOWN');
assert.deepEqual(ps.offers.map((o) => o.source), ['merge']);
h.toPrep(2);
assert.equal(ps.board.get(tile)?.uid, elite.uid, 'still there in the next prep');
assert.ok(ps.privateView().shop.rewardOffer, 'the promotion reward is shown in that prep');
assert.ok(ps.tempEmpty && ps.privateView().canReady, 'nothing waits in temp');
checkInvariants(m);
m.dispose();
});
test('boss-field prep (R14, act2 m01): the elite takes a copy\'s tile that is legal only on the player\'s boss half', () => {
const STAGE = 'act2autochess_m01';
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 21, fake: true }).start();
h.setStage(STAGE);
h.toPrep(14);
const m = h.m;
assert.equal(m.round, m.gd.bossRound);
for (const [pid, field] of [['p_0', 'bossL'], ['p_1', 'bossR']]) {
const ps = h.ps(pid);
clean(ps);
assert.equal(m.deployFieldOf(ps), field);
const id = chessOfTier(1, (c) => c.position === 'MELEE' && !m.gd.placeableTokens(c.chessId).length).find((x) => m.pool.has(x) && m.pool.left(x) >= 3 && m.gd.mergeCount(x) === 3);
// board (11, 8): a tile_fence_bound tile on the boss half, forbidden on the normal field (DESIGN §19.7)
const normal = m.deployMapFor(ps, 'normal');
assert.equal(canPlace(normal, 'melee', 11, 8), false, 'illegal on the normal field');
const a = give(m, ps, id);
assert.deepEqual(m.handle(pid, { t: 'g.move', uid: a.uid, to: { area: 'board', row: 11, col: 8 }, dir: 'UP' }), { ok: true }, `${pid}: placed on the boss half`);
give(m, ps, id);
ps.funds = 50;
stock(m, ps, id);
assert.deepEqual(m.handle(pid, { t: 'g.buy', slot: 0 }), { ok: true });
const elite = ps.board.get('11,8');
assert.ok(elite && elite.id === chess(id).goldenId && elite.dir === 'UP', `${pid} (${field}): the elite on board (11,8) of its boss half`);
assert.ok(canPlace(ps.deployMap(), positionClass(chess(elite.id)), 11, 8));
}
checkInvariants(m);
m.dispose();
});
test('bots: a bot prep that completes a merge keeps the invariants (the elite is deployed)', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 51, fake: true }).start();
const m = h.m;
h.toPrep(1);
h.setStage('act2autochess_m04');
const bot = h.ps('ai_0');
// botPrep only runs while the bot has not readied yet
bot.ready = false;
clean(bot);
const [id] = meleeTier1(m);
deploy(m, bot, id, 'RIGHT');
give(m, bot, id);
bot.shop.slots = bot.shop.slots.map(() => null);
stock(m, bot, id);
bot.funds = 10;
botPrep(m, bot);
const elite = eliteOf(bot, id);
assert.ok(elite, 'the bot bought the third copy');
assert.equal(bot.find(elite.uid).area, 'board', 'its elite is deployed');
checkInvariants(m);
m.dispose();
});
test('audit.js flags an elite that misses the deployed copy\'s tile (and passes the engine\'s merges)', () => {
const { m, ps } = prep({ seed: 52 });
const [id, id2] = meleeTier1(m);
// a deliberately wrong merge (the pre-follow-up rule: always the hand) installed before the audit wraps it
const real = ps._mergeChess;
let broken = false;
ps._mergeChess = function (baseId, incoming, opts) {
const elite = real.call(this, baseId, incoming, opts);
if (broken && elite) {
const loc = this.find(elite.uid);
if (loc && loc.area === 'board') { this.board.delete(loc.key); this.stow(elite, { allowTemp: true }); }
}
return elite;
};
const audit = attachAudit(m, { invariants: false });
deploy(m, ps, id);
give(m, ps, id);
stock(m, ps, id);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
assert.deepEqual(audit.violations, [], 'the engine\'s merge passes');
broken = true;
deploy(m, ps, id2);
give(m, ps, id2);
stock(m, ps, id2);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
assert.equal(audit.violations.length, 1);
assert.match(audit.violations[0], /expected the deployed copy's tile/);
m.dispose();
});
test('audit.js passes transformations that complete a merge (突变细胞 ctx.transform; a deployed carrier is detached first), in PREP and SETTLE', () => {
// carrier / the two copies: 'board' or 'hand'
const layouts = [['board', 'hand', 'hand'], ['board', 'board', 'hand'], ['board', 'board', 'board'], ['hand', 'board', 'board'], ['hand', 'hand', 'hand']];
for (const phase of ['PREP', 'SETTLE']) {
for (const [i, [carrierAt, ...copiesAt]] of layouts.entries()) {
const label = `${phase} carrier ${carrierAt}, copies ${copiesAt.join('+')}`;
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 60 + i, fake: true }).start();
const m = h.m;
h.toPrep(1);
h.setStage('act2autochess_m04');
const ps = h.ps('p_0');
clean(ps);
const audit = attachAudit(m);
const [id, other] = meleeTier1(m);
const place = (cid, at, dir) => (at === 'board' ? deploy(m, ps, cid, dir) : give(m, ps, cid));
const carrier = place(other, carrierAt, 'UP');
const copies = copiesAt.map((at, j) => place(id, at, j ? 'DOWN' : 'LEFT'));
const dirOf = new Map([carrier, ...copies].filter((p) => keyOf(ps, p)).map((p) => [keyOf(ps, p), p.dir]));
const before = ps.deployCount;
if (phase === 'SETTLE') h.drive(() => m.phase === PHASE.SETTLE);
assert.equal(m.phase, PHASE[phase]);
const ctx = makeCtx(m, ps, { kind: 'item', key: 'item:test' }, phase === 'SETTLE' ? 'onSettle' : 'onBuy');
const got = ctx.transform(carrier.uid, id);
assert.ok(got && got.id === chess(id).goldenId, `${label}: the transformation completed the merge`);
const want = mergeTile([...dirOf.keys()].map((key) => ({ key })));
const loc = ps.find(got.uid);
if (want) {
assert.equal(loc.key, want.key, `${label}: the elite on the first deployed tile`);
assert.equal(loc.piece.dir, dirOf.get(want.key), `${label}: with that copy's facing`);
assert.equal(ps.deployCount, 1, `${label}: ${before} deployed → 1`);
} else {
assert.equal(loc.area, 'hand', `${label}: no deployed copy → the hand`);
assert.equal(ps.deployCount, 0);
}
assert.deepEqual(audit.violations, [], `${label}: no audit violation`);
m.dispose();
}
}
});
test('a deployed copy on a tile its elite may not use (terrain changed, not re-checked yet) is skipped: hand, or the tile when nothing has room', () => {
for (const full of [false, true]) {
const label = full ? 'hand and temp full' : 'room';
const { m, ps } = prep({ seed: 54 });
const [id] = meleeTier1(m);
// both owned copies deployed, both tiles then made undeployable (a content tile override, not re-checked yet)
const a = deploy(m, ps, id, 'UP');
const b = deploy(m, ps, id, 'DOWN');
const tiles = [keyOf(ps, a), keyOf(ps, b)];
if (full) {
const fill = [2, 3].flatMap((t) => chessOfTier(t)).filter((x) => m.pool.has(x));
for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, fill[i]);
for (let i = 0; ps.temp.some((p) => p == null); i++) give(m, ps, fill[ps.hand.length + i], 'temp');
}
for (const k of tiles) ps.tileOverrides[k] = 'none';
ps.invalidateDeployMap();
const elite = ps.acquireChess(id, { source: 'test' });
assert.ok(elite && m.gd.isGolden(elite.id), `${label}: merged`);
const loc = ps.find(elite.uid);
if (!full) {
assert.equal(loc.area, 'hand', `${label}: the illegal tiles are skipped, the elite goes to the hand`);
} else {
// never lost: it stays on the first deployed copy's tile, like a piece the eviction finds no room for
assert.equal(loc.area, 'board', `${label}: kept on the board`);
assert.equal(loc.key, mergeTile(tiles.map((key) => ({ key }))).key);
assert.equal(elite.poolCopies, 3);
}
m.dispose();
}
});
test('ERR codes unchanged: a full hand still refuses a purchase that completes no merge', () => {
const { m, ps } = prep({ seed: 53 });
const [id, other] = meleeTier1(m);
deploy(m, ps, id);
const fill = chessOfTier(2).filter((x) => m.pool.has(x));
for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, fill[i]);
stock(m, ps, other);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { error: ERR.HAND_FULL });
checkInvariants(m);
m.dispose();
});
+233
View File
@@ -0,0 +1,233 @@
// Official limits on the match side (docs/research/11-limits-official.md): bond layers stop at 999 per bond
// (shared/constants.js BOND_LAYER_CAP: PlayerState.addLayers, the settle of the in-battle gains, the client-result
// check, the invariants) and the boss-hit limit 限伤 (BOSS_HIT_LIMIT 300000) in a real Final Assault / Hidden Core with a
// 999-layer board.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE, BOND_LAYER_CAP, BOSS_HIT_LIMIT } from '../../shared/constants.js';
import { Battle } from '../../server/sim/Battle.js';
import { validateClientResult } from '../../server/match/fields.js';
import { collectViolations } from '../../server/match/invariants.js';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { makeMatch, give, legalTileFor, DATA } from './harness.js';
import { planBoard, applyBoard, makeMatch as balanceMatch, withoutTuning } from '../../tools/balance.mjs';
import { buildNormalWave } from '../../server/match/waves.js';
import { createRng } from '../../server/sim/rng.js';
/** A solo match (fake battles) at PREP R1 with an empty layer table; onLayers dispatches are recorded. */
function prepMatch(seed = 71, o = {}) {
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', seed, fake: true, ...o }).start();
h.toPrep(1);
const m = h.m;
const ps = h.ps('p_0');
ps.layers = {};
ps.recompute();
const onLayers = [];
const dispatch = m.dispatch.bind(m);
m.dispatch = (p, hook, ev, opts) => { if (hook === 'onLayers') onLayers.push({ ...ev }); return dispatch(p, hook, ev, opts); };
return { h, m, ps, onLayers };
}
test('PlayerState.addLayers: a gain stops at 999; onLayers reports the clamped count; a gain at the cap adds 0 and dispatches nothing', () => {
const { m, ps, onLayers } = prepMatch();
assert.equal(ps.addLayers('yanShip', 1500), BOND_LAYER_CAP);
assert.equal(ps.layers.yanShip, 999);
assert.deepEqual(onLayers.map((e) => [e.bondId, e.from, e.to]), [['yanShip', 0, 999]]);
assert.equal(ps.bonds.yanShip.layers, 999, 'the bond view shows the capped count');
assert.equal(ps.addLayers('yanShip', 5), 0);
assert.equal(onLayers.length, 1, 'no onLayers at the cap');
// each bond on its own
assert.equal(ps.addLayers('kjeragShip', 40), 40);
assert.equal(ps.layers.kjeragShip, 40);
ps.layers.lateranoShip = 996;
assert.equal(ps.addLayers('lateranoShip', 10), 3);
assert.equal(ps.layers.lateranoShip, 999);
// what the client sees: m.private bonds and the public bond list
const priv = ps.privateView();
assert.equal(priv.bonds.find((b) => b.bondId === 'yanShip').layers, 999);
assert.deepEqual(collectViolations(m), []);
m.dispose();
});
test('a real prep 特质 at the cap: 烛煌 获得时 炎 / 维多利亚 +5 adds 2 to a 997-layer 炎 and 5 to 维多利亚', () => {
const { m, ps, onLayers } = prepMatch(72);
ps.layers.yanShip = 997;
ps.recompute();
ps.acquireChess('chess_char_5_03_a', { source: 'test' });
assert.equal(ps.layers.yanShip, 999);
assert.equal(ps.layers.victoriaShip, 5);
assert.deepEqual(onLayers.filter((e) => e.bondId === 'yanShip').map((e) => [e.from, e.to]), [[997, 999]]);
ps.acquireChess('chess_char_5_03_a', { source: 'test' });
assert.equal(ps.layers.yanShip, 999, 'nothing more');
assert.equal(ps.layers.victoriaShip, 10);
assert.equal(onLayers.filter((e) => e.bondId === 'yanShip').length, 1);
m.dispose();
});
test('settle: in-battle layer gains are clamped at 999 (once at the line, then nothing — no onLayers for a capped bond)', () => {
const { h, m, ps, onLayers } = prepMatch(73, { script: (b) => (b.kind === 'normal' ? { duration: 1, layerGains: { p_0: { yanShip: 50, kjeragShip: 7 } } } : {}) });
ps.layers.yanShip = 980;
ps.recompute();
onLayers.length = 0;
h.toPrep(2);
assert.equal(ps.layers.yanShip, 999, '980 + 50 → 999');
assert.equal(ps.layers.kjeragShip, 7);
assert.deepEqual(onLayers.filter((e) => e.reason === 'battle').map((e) => [e.bondId, e.from, e.to]), [['yanShip', 980, 999], ['kjeragShip', 0, 7]]);
onLayers.length = 0;
h.toPrep(3);
assert.equal(ps.layers.yanShip, 999);
assert.equal(ps.layers.kjeragShip, 14);
assert.deepEqual(onLayers.filter((e) => e.reason === 'battle').map((e) => e.bondId), ['kjeragShip'], 'the capped bond dispatches nothing');
h.invariants();
m.dispose();
});
test('client-result check: a reported layer gain may not pass 999 from the bond\'s starting layers', () => {
const { m } = prepMatch(74);
const spec = {
kind: 'normal', round: 5, timeLimit: 60, spawns: [], flags: { layerGainsEnabled: true },
players: [{ playerId: 'p_0', units: [], bonds: { yanShip: { count: 3, active: true, tier: 1, layers: 990 }, kjeragShip: { count: 2, active: true, tier: 1, layers: 0 } } }],
};
const res = (gains) => ({ reason: 'cleared', time: 10, perPlayer: { p_0: { killed: 0, total: 0, leaked: [], perfect: true, layerGains: gains, coins: 0, unitsEnd: [], unitStats: [] } } });
const ok = validateClientResult(spec, res({ yanShip: 9, kjeragShip: 80 }), { gd: m.gd });
assert.equal(ok.ok, true, ok.reason);
assert.deepEqual(ok.result.perPlayer.p_0.layerGains, { yanShip: 9, kjeragShip: 80 });
assert.deepEqual(validateClientResult(spec, res({ yanShip: 10 }), { gd: m.gd }), { ok: false, reason: 'layer bound' }, '990 + 10 > 999');
assert.deepEqual(validateClientResult(spec, res({ kjeragShip: 81 }), { gd: m.gd }), { ok: false, reason: 'layer bound' }, 'the per-round bound 60 + 4·5 still holds');
const capped = { ...spec, players: [{ ...spec.players[0], bonds: { yanShip: { count: 3, active: true, tier: 1, layers: 999 } } }] };
assert.deepEqual(validateClientResult(capped, res({ yanShip: 1 }), { gd: m.gd }), { ok: false, reason: 'layer bound' }, 'a capped bond gains nothing');
assert.equal(validateClientResult(capped, res({ yanShip: 0 }), { gd: m.gd }).ok, true);
m.dispose();
});
test('invariants: a bond above 999 is a violation (no writer may pass the cap)', () => {
const { m, ps } = prepMatch(75);
ps.layers.yanShip = 1000;
ps.recompute();
assert.ok(collectViolations(m).some((v) => /yanShip layers 1000/.test(v)));
ps.layers.yanShip = 999;
ps.recompute();
assert.deepEqual(collectViolations(m), []);
m.dispose();
});
// ---- a real Final Assault (and Hidden Core) with a 999-layer board ----------------------------------------------
const YAN = ['chess_char_6_15_a', 'chess_char_5_12_a', 'chess_char_4_17_a', 'chess_char_3_03_a'];
/** Every enemy takes ×K damage — a stand-in for the 999-layer debuffs of a real board (谢拉格 ×11.34 vs cold / frozen,
* 奥术 ×11.19 arts taken): with 炎 at 999 (ATK ×10.22) the hits land far past 300000. Once `relentAfter` leader hits
* were cancelled the leaders lose the stand-in so the fight can finish. */
const K = 2000;
function limitBattleClass(log, relentAfter = 6) {
return class extends Battle {
constructor(opts) {
const user = opts.setup;
super({ ...opts, setup: (b) => {
if (typeof user === 'function') user(b);
if (b.kind !== 'boss' && b.kind !== 'hidden') return;
const rec = { kind: b.kind, battle: b, caps: [], leaderHits: [], credits: [], relented: false };
log.push(rec);
const fx0 = b.fx.bind(b);
b.fx = (k, p) => {
if (k === 'hitCap') {
rec.caps.push(p.n);
if (!rec.relented && rec.caps.length >= relentAfter) {
rec.relented = true;
b.after(0, () => { for (const e of b.enemies) if (e.isBoss) b.removeBuff(e, 'test:999debuffs'); });
}
}
return fx0(k, p);
};
b.on('enemySpawn', ({ enemy }) => { if (!(enemy.isBoss && rec.relented)) b.addBuff(enemy, { key: 'test:999debuffs', mods: { dmgTakenMul: K } }); });
b.on('damaged', (c) => {
if (!c.target || !c.target.isBoss || c.type === 'element') return;
rec.leaderHits.push(c.amount);
}, { priority: -1000 });
if (b.sharedBoss) {
const pool = b.sharedBoss;
const dmg = pool.damage.bind(pool);
pool.damage = (pid, amount) => { rec.credits.push(amount); return dmg(pid, amount); };
}
} });
}
};
}
test('Final Assault + Hidden Core with a 999-layer 炎 board (real sim, server-run): every leader hit of ≥ 300000 deals 0 and nothing of it reaches the pool; the hits under the line win', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', seed: 76, fake: true, captureFrames: false, script: (b) => (b.kind === 'normal' ? { duration: 1 } : {}) });
const m = h.m;
m.bossId = 'boss_6'; // 阿利斯泰尔 walks into the board
m.hiddenBossId = 'boss_9'; // 假想敌:铳 (完全形态) with its three springs
m.gd.bossHpMul = () => 0.1; // pools the 4-operator board clears once the stand-in debuff leaves the leaders
h.start();
h.toPrep(14);
const ps = h.ps('p_0');
for (const p of [...ps.board.values()]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
const used = new Set();
for (const id of YAN) {
const at = legalTileFor(m, ps, id, used);
used.add(`${at[0]},${at[1]}`);
give(m, ps, id, 'board', at);
}
const before = ps.layers.yanShip || 0;
assert.equal(ps.addLayers('yanShip', 5000), 999 - before);
assert.equal(ps.layers.yanShip, 999);
ps.recompute();
assert.ok(ps.bonds.yanShip.active, '炎 is active');
ps.lp = 200;
const log = [];
m.BattleClass = limitBattleClass(log);
h.drive(() => m.phase === PHASE.FINAL_ASSAULT || h.ended != null);
assert.equal(m.phase, PHASE.FINAL_ASSAULT);
h.drive(() => h.ended != null, { maxSteps: 8e6 }); // the Final Assault, the Hidden Core's prep (999 activated layers > 350) and fight
const end = h.ended;
assert.ok(end, `the match ended (${m.phase} R${m.round})`);
assert.ok(log.length >= 1, 'a real boss field ran');
const fa = log[0];
assert.equal(fa.kind, 'boss');
const input = fa.battle.getPlayer('p_0');
assert.equal(input.bonds.yanShip.layers, 999, 'the battle got the capped count');
const op = fa.battle.allyUnits.find((u) => u.kind === 'op' && u.defId === YAN[0]);
assert.ok(op, 'the operator fought');
for (const rec of log) {
assert.ok(rec.caps.length > 0, `${rec.kind}: hits past the line were cancelled (${rec.caps.length})`);
assert.ok(rec.caps.every((n) => n >= BOSS_HIT_LIMIT));
assert.ok(rec.leaderHits.every((a) => Math.ceil(a) < BOSS_HIT_LIMIT), `${rec.kind}: no leader hit of ≥ 300000 landed (max ${Math.max(0, ...rec.leaderHits)})`);
assert.ok(rec.credits.every((a) => a < BOSS_HIT_LIMIT), `${rec.kind}: nothing ≥ 300000 reached the shared pool`);
assert.ok(rec.leaderHits.length > 0, `${rec.kind}: hits under the line landed`);
assert.equal(rec.battle.sharedBoss.hp, 0, `${rec.kind}: the pool was emptied by those`);
assert.equal(rec.battle.reason, 'cleared');
}
assert.equal(end.victory, true, 'the board still won once its hits stayed under the line');
assert.equal(m.errorCount, 0);
m.dispose();
});
// ---- the dev tools write layers too: they stop at the cap like every gain ---------------------------------------
test('tools/balance.mjs applyBoard: any --profile keeps every bond ≤ 999 (the R14 core bond at ×20 sits at the cap)', () => {
const m = balanceMatch({ data: withoutTuning(DATA), mode: 'coop', difficulty: 'HARD', seed: 9, players: 1, rehearsal: 0 });
const r = 14;
m.round = r;
m.wave = buildNormalWave(m.gd, m.rngWaves, m.factions, r);
const rng = createRng(314);
const plan = planBoard(m, r, rng, { profile: 20 });
const ps = m.order[0];
applyBoard(m, ps, plan, r, rng, { profile: 20 });
assert.ok(plan.core, 'a core bond');
assert.equal(ps.layers[plan.core], BOND_LAYER_CAP, 'core curve 150 × 20 → 999');
for (const [id, v] of Object.entries(ps.layers)) assert.ok(v > 0 && v <= BOND_LAYER_CAP, `${id} ${v}`);
assert.deepEqual(collectViolations(m).filter((v) => /layers/.test(v)), []);
m.dispose();
});
test('tools/matchrun.mjs --layers 2000 --check: the boost stops at 999 (no invariant violation through the Hidden Core)', () => {
const tool = fileURLToPath(new URL('../../tools/matchrun.mjs', import.meta.url));
const r = spawnSync(process.execPath, [tool, '--mode', 'solo', '--difficulty', 'HARD', '--lp', '500', '--layers', '2000', '--check', '--rehearsal', '0', '--seed', '1'], { encoding: 'utf8', timeout: 120000 });
assert.equal(r.status, 0, r.stdout.slice(-2000) + r.stderr.slice(-2000));
assert.match(r.stdout, /no violations/);
assert.doesNotMatch(r.stdout, /layers \d{4}/);
});
+2 -1
View File
@@ -97,7 +97,8 @@ test('g.unitStats follows the prep state: another item changes the numbers; prep
assert.deepEqual(carrier.items.map((x) => x.id).sort(), [ARM, BLADE].sort());
m.handle('p_0', { t: 'g.unitStats', seq: 2 });
const after = h.lastTo('p_0', 'm.unitStats').units.find((x) => x.uid === carrier.uid);
assert.ok(Math.abs(after.atk - Math.round(after.base.atk * 1.4 * 1.3)) <= 1, `each item its own multiplier: ATK ${before.atk} → ${after.atk}`);
// equipment percentages are 直接乘算 — they add up (PRTS 盟约记录 / 游戏数据基础; DESIGN §20.10): +40 % and +30 % = +70 %
assert.ok(Math.abs(after.atk - Math.round(after.base.atk * (1 + 0.4 + 0.3))) <= 1, `the items' percentages add up: ATK ${before.atk} → ${after.atk}`);
// outside the prep phases: WRONG_PHASE, nothing pushed
const n = h.allTo('p_0', 'm.unitStats').length;
assert.ok(h.drive(() => m.phase === PHASE.COMBAT));