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:
1 parent
6464e4b46a
commit
620f88c0d8
81 files changed
+4194
-447
No files matched your search
@@ -142,6 +142,47 @@ test('奥术: member arts damage → target arts taken ×(1.2+0.01·L) for 3 s;
|
||||
checkInvariants(h2.b);
|
||||
});
|
||||
|
||||
test('奥术 with two players on one field: one instance per target, the strongest wins (never multiplied); a weaker one resumes after it', () => {
|
||||
// DESIGN §20.10: PRTS 作战机制 "同名buff的默认叠加策略buff只能表现出一个"; 巴哈姆特 12316 "共享型buff會跟對面搶 如果對面層數比你高
|
||||
// 就不需要再特別激活直接吃他的奧術buff". v2.5 kept one instance per player: ×(1.3)·×(1.7) here.
|
||||
const bb = bondBb('arcaneShip');
|
||||
const caster = (id) => chessRec({ id, bonds: ['arcaneShip'], profession: 'CASTER', skill: null, rangeGrid: [[0, 0]] });
|
||||
const defs = { chess: { a_1: caster('a_1'), a_2: caster('a_2'), a_x: op('a_x', []) }, enemies: DUMMY };
|
||||
const h = makeBattle({
|
||||
kind: 'boss', defs,
|
||||
players: [
|
||||
{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: 'a_1', row: 12, col: 3 }], bonds: { arcaneShip: bond(1, 10) } },
|
||||
{ playerId: 'p2', seat: 1, side: 'R', colOffset: 0, units: [{ uid: 2, kind: 'chess', chessId: 'a_2', row: 12, col: 3 }, { uid: 3, kind: 'chess', chessId: 'a_x', row: 11, col: 3 }], bonds: { arcaneShip: bond(1, 50) } },
|
||||
],
|
||||
enemies: [{ key: 'enemy_addon_dummy', pos: [9, 9] }],
|
||||
});
|
||||
h.step(2);
|
||||
const e = h.enemy('enemy_addon_dummy');
|
||||
const u1 = h.b.allyUnits.find((u) => u.defId === 'a_1'), u2 = h.b.allyUnits.find((u) => u.defId === 'a_2');
|
||||
const m1 = bb.base_damage_scale + bb.damage_scale_per_stack * 10, m2 = bb.base_damage_scale + bb.damage_scale_per_stack * 50;
|
||||
h.b.dealDamage(u1, e, { amount: 100, type: 'arts' });
|
||||
close(e.s.artsTakenMul, m1, 'p1 alone');
|
||||
h.b.dealDamage(u2, e, { amount: 100, type: 'arts' });
|
||||
close(e.s.artsTakenMul, m2, 'the stronger p2 instance takes over — not m1 × m2');
|
||||
h.b.dealDamage(u1, e, { amount: 100, type: 'arts' });
|
||||
close(e.s.artsTakenMul, m2, 'a weaker application never overrides it');
|
||||
assert.equal(e.buffs.filter((b) => String(b.key).startsWith('bond:arcaneShip')).length, 1, 'one buff on the target');
|
||||
// the partner's non-member arts damage benefits too (it is a debuff on the target)
|
||||
const x = h.b.allyUnits.find((u) => u.defId === 'a_x');
|
||||
const hp0 = e.hp;
|
||||
h.b.dealDamage(x, e, { amount: 100, type: 'arts' });
|
||||
close(hp0 - e.hp, 100 * m2, 'any arts damage on the target');
|
||||
// p2 stops: its instance expires at 3 s, p1's later application (≈ 3 s too) resumes for the rest
|
||||
h.run(1);
|
||||
h.b.dealDamage(u1, e, { amount: 100, type: 'arts' });
|
||||
close(e.s.artsTakenMul, m2, 'still the stronger one');
|
||||
h.run(bb.weak_duration - 1 + 0.1);
|
||||
close(e.s.artsTakenMul, m1, 'the stronger expired, the weaker (applied later) resumes');
|
||||
h.run(1.1);
|
||||
close(e.s.artsTakenMul, 1, 'both expired');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('坚守: all operators HP ×(1.25+0.012·L); tier 2 redirect 40% to members, thorns + fragile, cooldown, no loops', () => {
|
||||
const bb = bondBb('steadShip');
|
||||
const defs = { chess: { d_1: op('d_1', ['steadShip']), d_2: op('d_2', ['steadShip']), d_n: op('d_n', []) }, enemies: DUMMY };
|
||||
@@ -180,6 +221,30 @@ test('坚守: all operators HP ×(1.25+0.012·L); tier 2 redirect 40% to members
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('坚守 thorns: 无来源 (no attacker bonus, hooks see no source) but credited to the member — its stats and the shared-pool tally', async () => {
|
||||
// DESIGN §20.10: v2.5 dealt them with no source at all, so no player's boss damage counted them (19 % of a co-op pool)
|
||||
const { SharedBossPool } = await import('../../server/match/finalAssault.js');
|
||||
const bb = bondBb('steadShip');
|
||||
const defs = { chess: { d_1: op('d_1', ['steadShip']), d_2: op('d_2', ['steadShip']) }, enemies: DUMMY };
|
||||
const pool = new SharedBossPool(1e6);
|
||||
const h = makeBattle({ defs, kind: 'boss', sharedBoss: pool, units: [{ chessId: 'd_1', row: 10, col: 3 }, { chessId: 'd_2', row: 10, col: 5 }],
|
||||
bonds: { steadShip: bond(2, 10) }, enemies: [{ key: 'enemy_addon_dummy', pos: [9, 9], tag: 'boss' }], hooks: ['damaged'], captureNoisy: true });
|
||||
h.step(2);
|
||||
const e = h.enemy('enemy_addon_dummy'), d1 = h.unit('d_1');
|
||||
assert.ok(e.bossPool, 'the leader drains the pool');
|
||||
h.b.addBuff(d1, { key: 'test:rage', mods: { dmgDealtMul: 2 } });
|
||||
const p0 = pool.hp, dmg0 = d1.stats.dmg;
|
||||
h.b.dealDamage(e, d1, { amount: 100, type: 'true' });
|
||||
const thorn = bb.base_damage_value + bb.damage_value_per_stack * 10;
|
||||
close(p0 - pool.hp, thorn, 'thorns hit the pool, the member\'s ×2 does not apply');
|
||||
const ev = h.hooksOf('damaged').filter((c) => c.target === e);
|
||||
assert.equal(ev.length, 1);
|
||||
assert.equal(ev[0].source, null, '无来源: hooks see no source');
|
||||
assert.equal(ev[0].credit, d1, 'credited to the member');
|
||||
close(d1.stats.dmg - dmg0, thorn, 'member stats');
|
||||
close(h.result().perPlayer.p1.bossDamage, thorn, 'its player\'s shared-pool tally');
|
||||
});
|
||||
|
||||
test('坚守 tier 2: the redirected 40 % is the already-scaled damage — the attacker’s damage bonus is not applied twice', () => {
|
||||
const defs = { chess: { d_1: op('d_1', ['steadShip']), d_2: op('d_2', ['steadShip']), d_n: op('d_n', []) }, enemies: DUMMY };
|
||||
const units = [{ chessId: 'd_1', row: 10, col: 3 }, { chessId: 'd_2', row: 10, col: 5 }, { chessId: 'd_n', row: 12, col: 3 }];
|
||||
|
||||
@@ -782,7 +782,8 @@ test('battle plans come from the blackboards; gates as documented', () => {
|
||||
assert.deepEqual(gateOf(EFF('allybuff_select_14')), { kind: 'sameRow', count: 3 });
|
||||
assert.deepEqual(gateOf(EFF('allybuff_select_11')), { kind: 'always', count: 0 });
|
||||
assert.equal(battlePlanOf(refOf('allybuff_select_11')).heal, 50);
|
||||
assert.deepEqual(battlePlanOf(refOf('allybuff_select_18')).flawless, { atkMul: 1.3, defMul: 1.3 });
|
||||
// 无瑕 "攻击力和防御力+30%": 直接乘算 — the additive percentage bucket (constants.js DIRECT_BONUS_STACKING)
|
||||
assert.deepEqual(battlePlanOf(refOf('allybuff_select_18')).flawless, { atkPct: 0.3, defPct: 0.3 });
|
||||
assert.deepEqual(battlePlanOf(refOf('allybuff_select_14')).opMods, { redeployMul: 0.5 });
|
||||
assert.deepEqual(battlePlanOf(refOf('enemydebuff_select_8')).enemy, [{ rank: 'BOSS', mods: { defFlat: -200 } }]);
|
||||
for (const id of TEAM_PREP) assert.equal(battlePlanOf(refOf(id)), null, `${id}: prep only`);
|
||||
|
||||
@@ -14,7 +14,7 @@ import * as bossesMod from '../../server/sim/content/bosses.js';
|
||||
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
|
||||
const W = JSON.parse(fs.readFileSync(new URL('../../data/waves.json', import.meta.url), 'utf8'));
|
||||
const { KITS, STATS_ONLY, EROSION, EROSION_BURST } = enemiesMod;
|
||||
const { BOSS_KITS, PART_TRANSFER } = bossesMod;
|
||||
const { BOSS_KITS, PART_TRANSFER, BLADE_TRANSFER, DRONE_LINK_BASE, droneLinkBase } = bossesMod;
|
||||
|
||||
const BIG = [];
|
||||
for (let dr = -4; dr <= 4; dr++) for (let dc = -12; dc <= 12; dc++) BIG.push([dr, dc]);
|
||||
@@ -1912,7 +1912,7 @@ test('template overrides of talents/skills are honoured (卢西恩 evade 0.2 in
|
||||
const bossArena = (o = {}) => arena({ kind: 'boss', sharedBoss: pool(o.hp ?? 1e6), ...o });
|
||||
const setTpl = (id) => (b) => { b.opts.templateId = id; };
|
||||
|
||||
test('假想敌:胄: arts ray on a random target in range; <20 % pool: damage taken ×0.5 and two 刺胄之弹', () => {
|
||||
test('假想敌:胄: arts ray on a random target in range; <20 % pool: damage taken ×0.5, still one 刺胄之弹 (PRTS 能力修正)', () => {
|
||||
const h = bossArena({ units: [{ chessId: 't_wall', row: 10, col: 6 }, { chessId: 't_wall2', row: 12, col: 4 }] });
|
||||
h.step();
|
||||
const e = put(h, 'enemy_9013_acstmk', [3, 10], { tag: 'boss' });
|
||||
@@ -1925,7 +1925,8 @@ test('假想敌:胄: arts ray on a random target in range; <20 % pool: damage
|
||||
approx(e.s.artsTakenMul, tb('enemy_9013_acstmk', '1.damage_scale'));
|
||||
assert.equal(e.s.trueTakenMul, 1);
|
||||
h.run(skb('enemy_9013_acstmk', '1').initCooldown - 9);
|
||||
assert.equal(h.eventsOf('fx').filter((f) => f[1] === 'shell').length, 2);
|
||||
assert.equal(h.eventsOf('fx').filter((f) => f[1] === 'shell').length, 1);
|
||||
assert.equal(alive(h, 'enemy_9016_acstmr').length, 1);
|
||||
});
|
||||
|
||||
test('假想敌:胄 + 刺胄之弹: the shell flies to the highest-ATK operator; on arrival 3×3 stun + physical DoT; 8 hits shoot it down', () => {
|
||||
@@ -1954,64 +1955,189 @@ test('假想敌:胄 + 刺胄之弹: the shell flies to the highest-ATK operato
|
||||
assert.ok(!s2.alive);
|
||||
});
|
||||
|
||||
test('假想敌:胄 死亡集群: drones from the branch; a drone killed by an operator costs the leader 2 % max HP', () => {
|
||||
const h = bossArena({ hp: 675000, units: [{ chessId: 't_gun', row: 12, col: 3 }], setup: setTpl('act1autochess_h07_01') });
|
||||
test('假想敌:胄 死亡集群: drones from the branch; a drone that dies (whoever kills it) costs the leader 2 % max HP, a leak nothing', () => {
|
||||
const h = bossArena({ hp: 675000, units: [{ chessId: 't_gun', row: 12, col: 3 }], kits: { t_gun: NOATK }, setup: setTpl('act1autochess_h07_01') });
|
||||
h.step();
|
||||
put(h, 'enemy_9013_acstmk', [3, 10], { tag: 'boss' });
|
||||
const boss = put(h, 'enemy_9013_acstmk', [3, 10], { tag: 'boss' });
|
||||
h.step();
|
||||
const s2 = skb('enemy_9013_acstmk', '2');
|
||||
const loss = 675000 * s2.bb.hp_ratio;
|
||||
const d = alive(h, 'enemy_1005_yokai')[0];
|
||||
assert.ok(d, 'drone summoned at once (icd 0)');
|
||||
assert.equal(d.s.maxHp, E.enemy_1005_yokai.stats.maxHp, 'boss_1 drones: no summon.hp_ratio — data HP (max_hp is not read)');
|
||||
let before = h.b.sharedBoss.hp;
|
||||
h.b.kill(d, h.unit('t_gun'));
|
||||
approx(before - h.b.sharedBoss.hp, loss);
|
||||
const newest = () => alive(h, 'enemy_1005_yokai').sort((a, b) => b.id - a.id)[0];
|
||||
h.run(s2.cooldown + 0.5);
|
||||
const d2 = newest();
|
||||
assert.ok(d2);
|
||||
before = h.b.sharedBoss.hp;
|
||||
h.b.kill(d2, null); // 无来源: "该无人机单位死亡时"
|
||||
approx(before - h.b.sharedBoss.hp, loss);
|
||||
h.run(s2.cooldown);
|
||||
const d3 = newest();
|
||||
assert.notEqual(d3, d2);
|
||||
assert.ok(d3);
|
||||
before = h.b.sharedBoss.hp;
|
||||
h.b.leak(d3);
|
||||
assert.equal(h.b.sharedBoss.hp, before, 'a leaked drone does not die');
|
||||
approx(h.result().perPlayer.p1.bossDamage, loss, 1e-6, 'only the operator kill is credited');
|
||||
assert.ok(boss.alive);
|
||||
});
|
||||
|
||||
test('假想敌:胄 死亡集群: the 2 % reads the leader\'s shown max HP (the pool)', () => {
|
||||
// PRTS "该妖怪死亡时令假想敌:胄受到最大生命值2%的真实伤害" — which max HP is [ASSUMED] (DESIGN §20.10): default 'pool' = the
|
||||
// HP the battle shows (synced to the shared pool, the same HP the <20 % talent reads), so 2 % of the bar in every mode:
|
||||
// 72 000 with the 终极 pool (3 600 000); the 'unit' reading (data 600 000 → 12 000) stays a switch
|
||||
assert.equal(DRONE_LINK_BASE, 'pool');
|
||||
const h = bossArena({ hp: 3600000, units: [{ chessId: 't_gun', row: 12, col: 3 }], setup: setTpl('act1autochess_h07_01') });
|
||||
h.step();
|
||||
const boss = put(h, 'enemy_9013_acstmk', [3, 10], { tag: 'boss' });
|
||||
h.step();
|
||||
assert.equal(boss.s.maxHp, 3600000, 'the unit shows the pool');
|
||||
assert.equal(droneLinkBase(boss), 3600000, 'the link reads the shown max HP (the pool)');
|
||||
assert.equal(droneLinkBase(boss, 'unit'), 600000, "the 'unit' switch reads the data max HP");
|
||||
const d = alive(h, 'enemy_1005_yokai')[0];
|
||||
assert.ok(d, 'drone summoned at once (icd 0)');
|
||||
const before = h.b.sharedBoss.hp;
|
||||
h.b.kill(d, h.unit('t_gun'));
|
||||
approx(before - h.b.sharedBoss.hp, 675000 * skb('enemy_9013_acstmk', '2').bb.hp_ratio);
|
||||
assert.ok(h.result().perPlayer.p1.bossDamage >= 675000 * 0.02 - 1e-6);
|
||||
approx(before - h.b.sharedBoss.hp, 3600000 * skb('enemy_9013_acstmk', '2').bb.hp_ratio);
|
||||
approx(before - h.b.sharedBoss.hp, 72000);
|
||||
assert.ok(h.result().perPlayer.p1.bossDamage >= 72000 - 1e-6);
|
||||
// a solo-sized pool (标准 247 500 × 0.25): still 2 % of the bar, never the 19 % a fixed 12 000 would take
|
||||
const solo = bossArena({ hp: 61875, units: [{ chessId: 't_gun', row: 12, col: 3 }], setup: setTpl('act1autochess_h07_01') });
|
||||
solo.step();
|
||||
put(solo, 'enemy_9013_acstmk', [3, 10], { tag: 'boss' });
|
||||
solo.step();
|
||||
const d2 = alive(solo, 'enemy_1005_yokai')[0];
|
||||
assert.ok(d2);
|
||||
const b2 = solo.b.sharedBoss.hp;
|
||||
solo.b.kill(d2, solo.unit('t_gun'));
|
||||
approx(b2 - solo.b.sharedBoss.hp, 61875 * 0.02);
|
||||
const hidden = bossArena({ hp: 7200000 });
|
||||
hidden.step();
|
||||
const hb = put(hidden, 'enemy_9013_acstmk_2', [3, 10], { tag: 'boss' });
|
||||
hidden.step();
|
||||
assert.equal(droneLinkBase(hb), 7200000, '隐秘核心 终极: 144 000 per drone');
|
||||
assert.equal(droneLinkBase(hb, 'unit'), 1200000);
|
||||
});
|
||||
|
||||
test('假想敌:胄 (隐秘核心): shells only at operators within its range', () => {
|
||||
const hi = WALL('t_wall', { atk: 900 });
|
||||
const h = bossArena({ units: [{ chessId: 't_wall', row: 10, col: 2 }, { chessId: 't_wall2', row: 12, col: 6 }], chess: { t_wall: hi } });
|
||||
test('假想敌:胄 (隐秘核心) 死亡集群: summon.hp_ratio scales the drone HP', () => {
|
||||
const h = bossArena({ units: [{ chessId: 't_gun', row: 12, col: 3 }], setup: setTpl('act1autochess_h08_01') });
|
||||
h.step();
|
||||
const e = put(h, 'enemy_9013_acstmk_2', [3, 12], { tag: 'boss' });
|
||||
e.profile.noAttack = true;
|
||||
h.run(skb('enemy_9013_acstmk_2', '1').initCooldown + 0.1);
|
||||
const f = h.eventsOf('fx').find((x) => x[1] === 'shell');
|
||||
assert.ok(f);
|
||||
assert.equal(f[4].tx, h.unit('t_wall2').tileC, 'the high-ATK operator is out of range (col 2 vs 12, range 8)');
|
||||
put(h, 'enemy_9013_acstmk_2', [3, 10], { tag: 'boss' }).profile.noAttack = true;
|
||||
const s2 = skb('enemy_9013_acstmk_2', '2');
|
||||
h.run(s2.initCooldown + 0.1);
|
||||
const d = alive(h, 'enemy_1005_yokai')[0];
|
||||
assert.ok(d);
|
||||
approx(d.s.maxHp, E.enemy_1005_yokai.stats.maxHp * s2.bb['summon.hp_ratio']);
|
||||
});
|
||||
|
||||
for (const key of ['enemy_9014_acstma', 'enemy_9015_acstmb']) {
|
||||
test(`${nm(key)}: hovers invulnerable; dives at the lowest-ATK operator; ${skb(key, '1').bb.max_hit_cnt} hits shoot it down — then ×${skb(key, '1').bb.damage_scale} damage, ${PART_TRANSFER * 100} % passed to 胄`, () => {
|
||||
const h = bossArena({ hp: 1e6, units: [{ chessId: 't_wall', row: 12, col: 3 }, { chessId: 't_gun', row: 9, col: 4 }], chess: { t_gun: chessRec({ id: 't_gun', profession: 'SNIPER', projectile: 'none', stats: { atk: 5000, maxHp: 1e7, blockCnt: 0 }, rangeGrid: BIG, skill: null }) }, kits: { t_gun: NOATK } });
|
||||
for (const key of ['enemy_9013_acstmk', 'enemy_9013_acstmk_2']) {
|
||||
test(`${nm(key)}: shells only at operators within its range (PRTS 技能 "选择攻击范围内攻击力最高的1名我方干员")`, () => {
|
||||
const hi = WALL('t_wall', { atk: 900 });
|
||||
const h = bossArena({ units: [{ chessId: 't_wall', row: 10, col: 2 }, { chessId: 't_wall2', row: 12, col: 6 }], chess: { t_wall: hi } });
|
||||
h.step();
|
||||
const boss = put(h, 'enemy_9013_acstmk_2', [3, 10], { tag: 'boss' });
|
||||
boss.profile.noAttack = true;
|
||||
const p = put(h, key, [3, 12], { tag: 'part' });
|
||||
assert.ok(p.s.flags.invulnerable && p.s.flags.untargetable);
|
||||
const s = skb(key, '1');
|
||||
h.run(s.initCooldown + 0.1);
|
||||
assert.ok(!p.s.flags.invulnerable, 'diving');
|
||||
const g = h.unit('t_gun');
|
||||
for (let i = 0; i < s.bb.max_hit_cnt; i++) h.b.dealDamage(g, p, { amount: 1, type: 'true' });
|
||||
assert.equal(p.motion, 'WALK');
|
||||
approx(p.s.dmgTakenMul, s.bb.damage_scale);
|
||||
const before = h.b.sharedBoss.hp;
|
||||
const dealt = h.b.dealDamage(g, p, { amount: 1000, type: 'true' });
|
||||
approx(before - h.b.sharedBoss.hp, dealt * PART_TRANSFER);
|
||||
h.run(s.bb.special_stun_duration + 60);
|
||||
assert.equal(p.motion, 'FLY');
|
||||
const e = put(h, key, [3, 12], { tag: 'boss' });
|
||||
e.profile.noAttack = true;
|
||||
h.run(skb(key, '1').initCooldown + 0.1);
|
||||
const f = h.eventsOf('fx').find((x) => x[1] === 'shell');
|
||||
assert.ok(f);
|
||||
assert.equal(f[4].tx, h.unit('t_wall2').tileC, 'the high-ATK operator is out of range (col 2 vs 12, range 8)');
|
||||
});
|
||||
}
|
||||
|
||||
test('“斩胄之剑” dive that lands: 3×3 stun + physical DoT at the lowest-ATK operator', () => {
|
||||
const h = bossArena({ units: [{ chessId: 't_wall', row: 9, col: 11 }], hooks: ['statusApplied'] });
|
||||
const H08 = 'act1autochess_h08_01';
|
||||
const handRoute = (key, kind) => {
|
||||
const t = W[H08];
|
||||
const s = t.branches[`${key === 'enemy_9014_acstma' ? 'left' : 'right'}_hand_${kind}`][0][0];
|
||||
return t.extraRoutes[s.routeIndex];
|
||||
};
|
||||
|
||||
for (const key of ['enemy_9014_acstma', 'enemy_9015_acstmb']) {
|
||||
const s = skb(key, '1');
|
||||
test(`${nm(key)}: 初始模式 invulnerable; 掷 blinks to its preset route at the lowest-ATK operator, no normal attack; damage passes ${BLADE_TRANSFER * 100} % to 胄; ${s.bb.max_hit_cnt} hits → 【瘫痪】 (stun ${s.bb.stun} s, ground, ×${s.bb.damage_scale}), then a new 初始模式 copy with its HP replaces it`, () => {
|
||||
const h = bossArena({ hp: 1e7, units: [{ chessId: 't_wall', row: 12, col: 3 }, { chessId: 't_gun', row: 9, col: 4 }], chess: { t_gun: chessRec({ id: 't_gun', profession: 'SNIPER', projectile: 'none', stats: { atk: 5000, maxHp: 1e7, blockCnt: 0 }, rangeGrid: BIG, skill: null }) }, kits: { t_gun: NOATK }, setup: setTpl(H08), hooks: ['statusApplied'] });
|
||||
h.step();
|
||||
const boss = put(h, 'enemy_9013_acstmk_2', [3, 10], { tag: 'boss' });
|
||||
boss.profile.noAttack = true;
|
||||
const origin = handRoute(key, 'origin'), blink = handRoute(key, 'blink');
|
||||
const p = put(h, key, origin.start, { tag: 'part' }); // pinned: the dive never lands
|
||||
assert.ok(p.s.flags.invulnerable && p.s.flags.untargetable && p.s.flags.unblockable);
|
||||
h.run(s.initCooldown + 0.1);
|
||||
assert.ok(!p.s.flags.invulnerable && !p.s.flags.untargetable, '出击模式');
|
||||
assert.deepEqual([p.y, p.x], blink.start, 'blinked to the start of its blink route');
|
||||
assert.ok(p.s.flags.disarm, '出击模式: no normal attack');
|
||||
const g = h.unit('t_gun');
|
||||
const seen = [];
|
||||
h.b.on('damaged', (c) => { if (c.target === boss) seen.push(c); }, { priority: -1e9 });
|
||||
let pool0 = h.b.sharedBoss.hp;
|
||||
const d1 = h.b.dealDamage(g, p, { amount: 1000, type: 'true' });
|
||||
approx(pool0 - h.b.sharedBoss.hp, d1 * BLADE_TRANSFER, 1e-6, 'passed on during the dive too');
|
||||
for (let i = 1; i < s.bb.max_hit_cnt - 1; i++) h.b.dealDamage(g, p, { amount: 1, type: 'true' });
|
||||
assert.equal(p.motion, 'FLY');
|
||||
h.b.dealDamage(g, p, { amount: 1, type: 'true' }); // the 15th
|
||||
assert.equal(p.motion, 'WALK', 'a ground unit');
|
||||
approx(p.s.dmgTakenMul, s.bb.damage_scale);
|
||||
assert.ok(p.s.flags.stun);
|
||||
approx(statuses(h, p.id, 'stun')[0].duration, s.bb.stun);
|
||||
pool0 = h.b.sharedBoss.hp;
|
||||
const d2 = h.b.dealDamage(g, p, { amount: 1000, type: 'true' });
|
||||
approx(d2, 1000 * s.bb.damage_scale);
|
||||
approx(pool0 - h.b.sharedBoss.hp, d2 * BLADE_TRANSFER);
|
||||
approx(pool0 - h.b.sharedBoss.hp, 1000 * s.bb.damage_scale, 1e-6, '等量: all of the ×1.3 damage');
|
||||
assert.ok(seen.length && seen.every((c) => c.source === null && c.credit === g), '无来源 (hooks see no source), credited to the attacker');
|
||||
assert.ok(h.result().perPlayer.p1.bossDamage >= d1 + (s.bb.max_hit_cnt - 2) + 1 + d2 - 1e-6, 'the per-player pool tally');
|
||||
const hp = p.hp, killed0 = h.b.killed;
|
||||
h.run(s.bb.stun - 0.2);
|
||||
assert.ok(p.alive && p.motion === 'WALK', 'still 瘫痪');
|
||||
h.run(0.3); // the stun ends → 【瘫痪】 ends (not the 20 s)
|
||||
assert.ok(!p.alive, 'the old one is killed');
|
||||
const n = alive(h, key);
|
||||
assert.equal(n.length, 1);
|
||||
assert.notEqual(n[0].id, p.id);
|
||||
assert.deepEqual([n[0].y, n[0].x], origin.start, 'summoned on its origin route');
|
||||
approx(n[0].hp, hp, 1e-9, 'inherits the HP');
|
||||
assert.ok(n[0].s.flags.invulnerable && n[0].motion === 'FLY' && n[0].tag === 'part');
|
||||
assert.equal(h.b.killed, killed0, 'a part: not counted');
|
||||
h.run(s.initCooldown - 0.2);
|
||||
assert.ok(n[0].s.flags.invulnerable, 'a new unit: 掷 waits for its initial cooldown');
|
||||
h.run(0.3);
|
||||
assert.ok(!n[0].s.flags.invulnerable);
|
||||
});
|
||||
}
|
||||
|
||||
test('“斩胄之剑” dive that lands: 3×3 stun + physical DoT at the lowest-ATK operator, then a new copy with its HP at home', () => {
|
||||
const h = bossArena({ units: [{ chessId: 't_wall', row: 12, col: 15 }, { chessId: 't_wall2', row: 12, col: 16 }, { chessId: 't_wall3', row: 9, col: 3 }], chess: { t_wall2: WALL('t_wall2', { atk: 50 }), t_wall3: WALL('t_wall3', { atk: 100 }) }, hooks: ['statusApplied'], setup: setTpl(H08) });
|
||||
h.step();
|
||||
const p = put(h, 'enemy_9014_acstma', [3, 12], { tag: 'part', move: true });
|
||||
const origin = handRoute('enemy_9014_acstma', 'origin');
|
||||
const p = put(h, 'enemy_9014_acstma', origin.start, { tag: 'part', move: true });
|
||||
const s = skb('enemy_9014_acstma', '1');
|
||||
h.runUntil(() => statuses(h, h.unit('t_wall').id, 'stun').length > 0, s.initCooldown + 30);
|
||||
h.run(s.initCooldown + 0.1);
|
||||
assert.ok(p.alive && !p.s.flags.invulnerable, 'flying from the blink start (5,16) to t_wall (5,15)');
|
||||
p.hp -= 1000;
|
||||
h.runUntil(() => statuses(h, h.unit('t_wall').id, 'stun').length > 0, 30);
|
||||
approx(statuses(h, h.unit('t_wall').id, 'stun')[0].duration, s.bb.stun);
|
||||
assert.equal(statuses(h, h.unit('t_wall2').id, 'stun').length, 1, 'the 3×3 around the target');
|
||||
assert.equal(statuses(h, h.unit('t_wall3').id, 'stun').length, 0);
|
||||
assert.ok(h.unit('t_wall').findBuff('boss:bladeDiveDot'));
|
||||
h.run(20);
|
||||
assert.ok(p.s.flags.invulnerable, 'back to hover');
|
||||
assert.ok(!p.alive);
|
||||
const n = alive(h, 'enemy_9014_acstma')[0];
|
||||
assert.ok(n && n.id !== p.id && n.s.flags.invulnerable);
|
||||
assert.deepEqual([n.y, n.x], origin.start);
|
||||
approx(n.hp, n.s.maxHp - 1000);
|
||||
});
|
||||
|
||||
test('“斩胄之剑” / “破胄之锤” 初始模式 attack: every ally in range, 锤 150 % ATK (PRTS 天赋)', () => {
|
||||
for (const [key, scale] of [['enemy_9014_acstma', 1], ['enemy_9015_acstmb', 1.5]]) {
|
||||
const h = bossArena({ units: [{ chessId: 't_wall', row: 10, col: 9 }, { chessId: 't_wall2', row: 10, col: 7 }] });
|
||||
h.step();
|
||||
put(h, key, [3, 8], { tag: 'part' });
|
||||
h.run(1);
|
||||
approx(h.unit('t_wall').stats.taken, E[key].stats.atk * scale, 1e-6, key);
|
||||
approx(h.unit('t_wall2').stats.taken, E[key].stats.atk * scale, 1e-6, key);
|
||||
}
|
||||
});
|
||||
|
||||
test('假想敌:铳: unblockable, targets the highest DEF in range, ASPD ramps on the same target, erosion on hit', () => {
|
||||
@@ -2124,7 +2250,9 @@ test('“碎铳之簧” 频次护盾 (9020): negates 5 hits, then comes back 25
|
||||
approx(w.stats.taken, n * sp.s.atk + 10 * sp.s.atk * sk.bb.atk_scale);
|
||||
});
|
||||
|
||||
test('“碎铳之簧”: damage taken passes PART_TRANSFER to 假想敌:铳 and, split, to the other springs (never passed on twice)', () => {
|
||||
test('“碎铳之簧”: damage taken costs 假想敌:铳 as much (等量, 无来源) and, split, the other springs (never passed on twice)', () => {
|
||||
// PRTS 碎铳之簧 "受到伤害时令全场范围内仇恨值最高的1名假想敌:铳受到等量的无来源生命流失" (DESIGN §20.10; v2.5: half)
|
||||
assert.equal(PART_TRANSFER, 1);
|
||||
const h = bossArena({ units: [{ chessId: 't_gun', row: 12, col: 3 }], kits: { t_gun: NOATK } });
|
||||
h.step();
|
||||
const g = put(h, 'enemy_9017_achunt_2', [3, 18], { tag: 'boss' });
|
||||
@@ -2132,12 +2260,16 @@ test('“碎铳之簧”: damage taken passes PART_TRANSFER to 假想敌:铳 a
|
||||
const [a, b, c] = ['enemy_9018_actrpa', 'enemy_9019_actrpb', 'enemy_9020_actrpc'].map((k, i) => put(h, k, [1 + i * 2, 4], { tag: 'part' }));
|
||||
for (const x of [a, b, c]) x.profile.noAttack = true;
|
||||
h.step();
|
||||
const seen = [];
|
||||
h.b.on('damaged', (x) => { if (x.target === g) seen.push(x); }, { priority: -1e9 });
|
||||
const pool0 = h.b.sharedBoss.hp, b0 = b.hp, c0 = c.hp;
|
||||
const dealt = h.b.dealDamage(h.unit('t_gun'), a, { amount: 1e5, type: 'true' });
|
||||
assert.ok(dealt > 0);
|
||||
approx(pool0 - h.b.sharedBoss.hp, dealt * PART_TRANSFER);
|
||||
approx(b0 - b.hp, (dealt * PART_TRANSFER) / 2);
|
||||
approx(c0 - c.hp, (dealt * PART_TRANSFER) / 2);
|
||||
approx(pool0 - h.b.sharedBoss.hp, dealt);
|
||||
approx(b0 - b.hp, dealt / 2);
|
||||
approx(c0 - c.hp, dealt / 2);
|
||||
assert.ok(seen.length === 1 && seen[0].source === null && seen[0].credit === h.unit('t_gun'), '无来源, credited to the attacker');
|
||||
assert.ok(h.result().perPlayer.p1.bossDamage >= dealt - 1e-6, 'the per-player pool tally');
|
||||
});
|
||||
|
||||
test('假想敌:管: strikes dark 余音 (their pulse hurts operators); 裂管之奏 at 40 s; summons 余音 every 40 s', () => {
|
||||
|
||||
@@ -481,9 +481,10 @@ test('attrByBond (26 ids): floor(Σ layers of the listed ACTIVE bonds / divide_n
|
||||
const k = Math.floor((L * list.length) / div);
|
||||
const m = h.unit('op').findBuff(`gar:${gid}`).mods;
|
||||
const want = {};
|
||||
if (bb.atk) want.atkMul = 1 + bb.atk * k;
|
||||
if (bb.max_hp) want.hpMul = 1 + bb.max_hp * k;
|
||||
if (bb.def) want.defMul = 1 + bb.def * k;
|
||||
// "每叠加N层…+X%" is 直接乘算: the additive percentage bucket (support directMods)
|
||||
if (bb.atk) want.atkPct = bb.atk * k;
|
||||
if (bb.max_hp) want.hpPct = bb.max_hp * k;
|
||||
if (bb.def) want.defPct = bb.def * k;
|
||||
if (bb.attack_speed) want.aspd = bb.attack_speed * k;
|
||||
if (bb.hp_recovery_per_sec) want.hpRegen = bb.hp_recovery_per_sec * k;
|
||||
if (bb.sp_recovery_per_sec) want.spRecoveryFlat = bb.sp_recovery_per_sec * k;
|
||||
|
||||
@@ -77,7 +77,7 @@ const STAT_CASES = [
|
||||
[A('6_11'), { spRecovery: 1.2 }], [B('6_11'), { spRecovery: 1.35 }], // 家族徽章
|
||||
];
|
||||
|
||||
test('stat equipment: every percentage is its own multiplier (normal / golden numbers from data)', () => {
|
||||
test('stat equipment: percentages are 直接乘算 — additive with each other (normal / golden numbers from data)', () => {
|
||||
for (const [id, want] of STAT_CASES) {
|
||||
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }] });
|
||||
h.step(1);
|
||||
@@ -85,13 +85,14 @@ test('stat equipment: every percentage is its own multiplier (normal / golden nu
|
||||
for (const [k, v] of Object.entries(want)) close(s[k], v, `${id} ${k}`);
|
||||
cover(id);
|
||||
}
|
||||
// two items multiply (维式重锤 ×1.15 × 阿戈尔重刃 ×1.4), a normal + golden copy of one item both apply
|
||||
// two items add up (PRTS 盟约记录 "装备效果提供的属性加成均为直接乘算"; 直接乘算 values are summed, PRTS 游戏数据基础):
|
||||
// 维式重锤 +15 % and 阿戈尔重刃 +40 % = +55 %, not ×1.15 × 1.4; a normal + golden copy of one item both apply
|
||||
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [A('1_01'), A('3_07')] }] });
|
||||
h.step(1);
|
||||
close(h.unit('t_op').s.atk, 500 * 1.15 * 1.4, 'stacked');
|
||||
close(h.unit('t_op').s.atk, 500 * (1 + 0.15 + 0.4), 'stacked');
|
||||
const h2 = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [A('1_01'), B('1_01')] }] });
|
||||
h2.step(1);
|
||||
close(h2.unit('t_op').s.atk, 500 * 1.15 * 1.25, 'normal + golden');
|
||||
close(h2.unit('t_op').s.atk, 500 * (1 + 0.15 + 0.25), 'normal + golden');
|
||||
checkInvariants(h2.b);
|
||||
});
|
||||
|
||||
@@ -179,8 +180,8 @@ test('歌利亚头盔: HP +25 % / +45 %, and +15 % / +25 % more when nobody stan
|
||||
const ops = { t_op: op('t_op'), t_f: op('t_f') };
|
||||
const free = fight({ ops, units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }] });
|
||||
free.step(1);
|
||||
close(free.unit('t_op').s.maxHp, 2000 * base * ex, `${id} free`);
|
||||
close(free.unit('t_op').hp, 2000 * base * ex, `${id} deployed at full HP`);
|
||||
close(free.unit('t_op').s.maxHp, 2000 * (base + ex - 1), `${id} free (+25 % and +15 % add up: 直接乘算)`);
|
||||
close(free.unit('t_op').hp, 2000 * (base + ex - 1), `${id} deployed at full HP`);
|
||||
const blocked = fight({ ops, units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }, { chessId: 't_f', row: 10, col: 5 }] });
|
||||
blocked.step(1);
|
||||
close(blocked.unit('t_op').s.maxHp, 2000 * base, `${id} front occupied`);
|
||||
|
||||
@@ -625,6 +625,19 @@ test('缄默德克萨斯 S1 细雨无声: passive ATK up for its duration; hits
|
||||
}
|
||||
});
|
||||
|
||||
test('缄默德克萨斯 S2: two copies cut the RES of an enemy around both once (同名效果取最高, DESIGN §20.10)', () => {
|
||||
const id = 'chess_char_4_16_a', bb = D(id, 1).skill.bb;
|
||||
const h = battle([U(id, 10, 4, 1), U(id, 11, 4, 1)], { enemies: { enemy_res: dummy({ key: 'enemy_res', res: 50 }) } });
|
||||
h.step();
|
||||
const two = h.b.allyUnits.filter((u) => u.def.id === id);
|
||||
assert.equal(two.length, 2);
|
||||
const e = h.spawn('enemy_res', { pos: [10, 5] });
|
||||
for (const u of two) { h.b.retreat(u); assert.ok(h.b.redeploy(u)); }
|
||||
assert.equal(dmgBy(h, two[0], tagged('burst')).length + dmgBy(h, two[1], tagged('burst')).length, 2, 'both bursts hit it');
|
||||
assert.equal(e.buffs.filter((b) => String(b.key).startsWith('texas2:resDown')).length, 1, 'one RES cut');
|
||||
approx(e.s.res, 50 * (1 + bb.magic_resistance), 1e-6, 'once, not twice');
|
||||
});
|
||||
|
||||
test('缄默德克萨斯 S2 阵雨连绵: deploy burst atk_scale arts + RES down around her; ATK up; attacks become arts double hits', () => {
|
||||
for (const id of pair('chess_char_4_16_a')) {
|
||||
const sk = D(id, 1).skill, bb = sk.bb;
|
||||
|
||||
@@ -494,7 +494,7 @@ test('灵知 S2: 130 % ATK arts + 2.5 s cold to all in range; fully charged cast
|
||||
approx(burst[0].amount, u.s.atk * bb.atk_scale, 1e-6);
|
||||
assert.ok(a.s.flags.freeze && b.s.flags.freeze, 'charged cast: second cold ⇒ freeze');
|
||||
h.run(0.2);
|
||||
approx(a.findBuff(`gnosis:fragile:${u.id}`).mods.dmgTakenMul, t0.damage_scale_freeze, 1e-9, 'frozen ×2 fragile');
|
||||
approx(a.findBuff('gnosis:fragile').mods.dmgTakenMul, t0.damage_scale_freeze, 1e-9, 'frozen ×2 fragile');
|
||||
// next (single-charge) cast only chills once per enemy
|
||||
assert.ok(h.runUntil(() => u.skill.activations >= 2, 20));
|
||||
const starts = noisy(h, 'skillStart').filter((c) => c.unit === u).map((c) => c.t);
|
||||
@@ -552,6 +552,41 @@ test('莱恩哈特 S2: 170 % ATK arts to all enemies in the wider skill range +
|
||||
assert.ok(inR.alive);
|
||||
});
|
||||
|
||||
test('同名效果取最高: two 灵知 never compound 坚冰, two 莱恩哈特 never compound the RES cut (one instance per enemy, the strongest)', () => {
|
||||
// DESIGN §20.10 (the 奥术 rule): a pair / 联防 partner's copy of the same operator puts the same-named effect on the
|
||||
// same enemy — one instance, the strongest (PRTS 作战机制 "同名buff…只能表现出一个"), never m × m
|
||||
const gn = 'chess_char_4_13_a', t0 = D(gn).talents[0].bb;
|
||||
const g = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: gn, row: 10, col: 3 }, { chessId: gn, row: 11, col: 3 }], timeLimit: 60, hooks: [] });
|
||||
g.step();
|
||||
const both = g.b.allyUnits.filter((u) => u.def.id === gn);
|
||||
assert.equal(both.length, 2, 'two 灵知 on the board');
|
||||
const e = g.spawn('enemy_dummy', { pos: [10, 4] });
|
||||
let seen = 0;
|
||||
for (let i = 0; i < 120; i++) { // 4 s: chilled / frozen by both
|
||||
g.step();
|
||||
const fr = e.buffs.filter((b) => String(b.key).startsWith('gnosis:fragile'));
|
||||
assert.ok(fr.length <= 1, `one 坚冰 instance (${fr.map((b) => b.key)})`);
|
||||
if (!fr.length) continue;
|
||||
seen++;
|
||||
const want = e.s.flags.freeze ? t0.damage_scale_freeze : t0.damage_scale_cold;
|
||||
approx(e.s.dmgTakenMul, fr[0].mods.dmgTakenMul, 1e-9, 'the only damage-taken modifier');
|
||||
assert.ok(e.s.dmgTakenMul <= t0.damage_scale_freeze + 1e-9 && fr[0].mods.dmgTakenMul >= want - 1e-9, `never ${t0.damage_scale_cold}² / ${t0.damage_scale_freeze}² (${e.s.dmgTakenMul})`);
|
||||
}
|
||||
assert.ok(seen > 30, `坚冰 was up (${seen})`);
|
||||
const lh = 'chess_char_4_14_a', bb = D(lh).skill.bb;
|
||||
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy({ res: 50 }) } }, units: [{ chessId: lh, row: 10, col: 3 }, { chessId: lh, row: 11, col: 3 }], timeLimit: 60, hooks: [] });
|
||||
h.step();
|
||||
const two = h.b.allyUnits.filter((u) => u.def.id === lh);
|
||||
assert.equal(two.length, 2);
|
||||
const x = h.spawn('enemy_dummy', { pos: [10, 5] });
|
||||
h.step();
|
||||
for (const u of two) assert.ok(u.skill.activate('test', { free: true }), 'S2 cast');
|
||||
assert.equal(x.buffs.filter((b) => String(b.key).startsWith('lionhd:res')).length, 1, 'one RES cut');
|
||||
approx(x.s.res, 50 * (1 + bb.magic_resistance), 1e-9, 'RES −8 % once, not twice');
|
||||
h.run(bb.duration + 0.1);
|
||||
approx(x.s.res, 50, 1e-9, 'expired');
|
||||
});
|
||||
|
||||
test('录武官 S2: healed allies regain 80 HP whenever damaged for 10 s; 学成于聚 SP +1 & ASPD +16 when an operator in range casts', () => {
|
||||
const id = 'chess_char_4_15_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb;
|
||||
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 10, col: 4 }], timeLimit: 60, hooks: ['heal'], captureNoisy: true });
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
// boss-field deployment, the phone prep camera — and the normative §3 / §5.1 / §5.5 / §6.1 / §7 lines that changed; user
|
||||
// playtest #6 (DESIGN §20): summons placed by hand (start deploy), skill triggers and the operation cooldown, every
|
||||
// blocker hits what it blocks, push force vs weight, the ASPD floor, the enemies' collider reach, the boss pool floor,
|
||||
// multi-round bounties lasting two battles, the 联防 counter, the element gauge — and the user's settled decisions.
|
||||
// multi-round bounties lasting two battles, the 联防 counter, the element gauge — and the user's settled decisions; the
|
||||
// playtest6b follow-up (DESIGN §20.10–§20.13): leader HP and 直接乘算, the elite on the consumed copy's tile, the official
|
||||
// 999 layer cap (one implementation) and 限伤 300000, the 假想敌:胄 kit — and the normative lines they rewrote.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -27,9 +29,10 @@ import { moduleTypeIconUrl } from '../public/js/ui/assetUrls.js';
|
||||
import { validateC2S } from '../shared/protocol.js';
|
||||
import { ERR, PHASE } from '../shared/constants.js';
|
||||
import { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED, ELEMENT, ELEMENT_ORDER, DOWN_STATE, BLOCK_RADIUS, FORCED_EXIT, ASPD_MIN, BOSS_POOL_MIN_HP, AUTO_OP_COOLDOWN, ALLY_COLLIDER_RADIUS } from '../server/sim/constants.js';
|
||||
import { SKILL_SUMMON_START_DEPLOY } from '../shared/constants.js';
|
||||
import { SKILL_SUMMON_START_DEPLOY, BOND_LAYER_CAP, BOSS_HIT_LIMIT, layerGainRoom } from '../shared/constants.js';
|
||||
import * as SIM_CONST from '../server/sim/constants.js';
|
||||
import { MULTI_ROUND_BOUNTY_BATTLES } from '../server/match/choices.js';
|
||||
import { SELF_BOUND } from '../server/sim/content/bosses.js';
|
||||
import { SELF_BOUND, PART_TRANSFER, BLADE_TRANSFER, DRONE_LINK_BASE } from '../server/sim/content/bosses.js';
|
||||
import * as BOARD from '../server/match/board.js';
|
||||
import * as DAMAGE from '../server/sim/damage.js';
|
||||
import { SUB } from '../server/sim/professions.js';
|
||||
@@ -391,7 +394,7 @@ test('user playtest #6 (DESIGN §20): summons, skill triggers, blocking, push fo
|
||||
const sub = (a, b) => DESIGN.slice(DESIGN.indexOf(`### ${a}`), DESIGN.indexOf(`### ${b}`));
|
||||
const S20 = sec(20);
|
||||
assert.match(DESIGN, /## 20\. User playtest #6 \(v2\.5\)/);
|
||||
for (let i = 1; i <= 9; i++) assert.match(S20, new RegExp(`### 20\\.${i} `), `§20.${i}`);
|
||||
for (let i = 1; i <= 13; i++) assert.match(S20, new RegExp(`### 20\\.${i} `), `§20.${i}`);
|
||||
const intro = S20.slice(0, S20.indexOf('### 20.1'));
|
||||
for (let i = 1; i <= 19; i++) assert.match(intro, new RegExp(`#${i} `), `the intro maps report #${i}`);
|
||||
const s55 = DESIGN.slice(DESIGN.indexOf('### 5.5'), DESIGN.indexOf('### 5.6'));
|
||||
@@ -467,5 +470,145 @@ test('user playtest #6 (DESIGN §20): summons, skill triggers, blocking, push fo
|
||||
assert.match(sec(10), /a tap anywhere on the card, its confirm strip included, is the card's tap/);
|
||||
assert.match(sec(18), /Each card shows its full effect text \(§20\.7\)/);
|
||||
// README: the test count stays in the right order of magnitude
|
||||
assert.match(README, /约 27\d0 项/);
|
||||
assert.match(README, /约 28\d0 项/);
|
||||
});
|
||||
|
||||
test('user playtest #6 follow-up: a merge consuming a deployed copy puts the elite on that tile (code + research + META / PLAYING / SIM agree)', () => {
|
||||
const R01 = doc('docs/research/01-core-rules.md');
|
||||
const INDEX = doc('docs/research/00-INDEX.md');
|
||||
const PS = doc('server/match/PlayerState.js');
|
||||
// code: one deployed copy + one hand copy + the acquired one → the elite on the deployed copy's tile
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 71 }).start();
|
||||
h.toPrep(1);
|
||||
h.setStage('act2autochess_m04');
|
||||
const m = h.m;
|
||||
const ps = h.ps('p_0');
|
||||
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
ps.recompute();
|
||||
const id = Object.values(DATA.chess).find((c) => !c.isGolden && c.tier === 1 && c.position === 'MELEE' && m.pool.has(c.chessId) && m.gd.mergeCount(c.chessId) === 3 && !m.gd.placeableTokens(c.chessId).length).chessId;
|
||||
const [r, c] = BOARD.legalTiles(ps.deployMap(), 'melee')[0];
|
||||
const a = ps.newPiece('chess', id, { poolCopies: m.pool.take(id, 1) });
|
||||
a.dir = 'UP';
|
||||
ps.board.set(BOARD.tileKey(r, c), a);
|
||||
ps.hand[0] = ps.newPiece('chess', id, { poolCopies: m.pool.take(id, 1) });
|
||||
ps.recompute();
|
||||
const elite = ps.acquireChess(id, { source: 'test' });
|
||||
assert.equal(ps.find(elite.uid).key, BOARD.tileKey(r, c), 'the elite on the deployed copy\'s tile');
|
||||
assert.equal(elite.dir, 'UP');
|
||||
m.dispose();
|
||||
// the research no longer says "not to a board tile"; every doc names the official sentence
|
||||
const official = /若消耗已部署至作战区的干员,则发送至作战区对应位置/;
|
||||
assert.ok(!/1 elite goes to the hand, not to a board tile\.\*\*/.test(R01), 'research 01 A1 row 7 is corrected');
|
||||
assert.match(R01, /\| 7 \| promotion reward[^\n]*to that copy's board position/);
|
||||
// its machine-readable companion too (not read by build-data; the tile among several deployed copies is assumed)
|
||||
const promo = JSON.parse(doc('docs/research/01-core-data.json'))._criticAddendum.promotion;
|
||||
assert.match(promo.eliteGoesTo, official, '01-core-data.json promotion.eliteGoesTo quotes PRTS');
|
||||
assert.notEqual(promo.eliteGoesTo, 'hand');
|
||||
assert.equal(promo.eliteTileAmongSeveralDeployed?.assumed, true);
|
||||
for (const [name, text] of [['research 01', R01], ['00-INDEX', INDEX], ['META', META], ['SIM', SIM], ['PlayerState', PS]]) assert.match(text.replace(/\s+|\/\/\s/g, ''), official, `${name} quotes PRTS`);
|
||||
assert.match(INDEX, /to \*\*that copy's board position\*\*/);
|
||||
assert.match(META, /of several, the one that deploys first \(row desc, then col asc; `board\.js mergeTile`/);
|
||||
assert.ok(!/it takes a freed\s+board tile of a consumed copy only when the hand and temp are both full/.test(META), 'META: the old fallback-only wording is gone');
|
||||
assert.ok(!/only with the hand and temp both full does it take a/.test(PS), 'PlayerState header: the old fallback-only wording is gone');
|
||||
assert.match(PLAYING, /精锐会直接出现在\*\*那名干员的位置\*\*/);
|
||||
assert.ok(!/精锐进入整备区(满了进入临时整备区),原先配发的装备退回整备区。/.test(PLAYING), 'PLAYING: the hand-only wording is gone');
|
||||
});
|
||||
|
||||
test('playtest6b follow-up (DESIGN §20.10–§20.13): leader HP, 直接乘算, elite to the board, one official 999 cap, 限伤 300000, the 胄 kit — code and every doc agree', () => {
|
||||
const sec = (n) => DESIGN.slice(DESIGN.indexOf(`## ${n}.`), DESIGN.indexOf(`## ${n + 1}.`) > 0 ? DESIGN.indexOf(`## ${n + 1}.`) : undefined);
|
||||
const S20 = sec(20);
|
||||
const subsec = (n) => { const a = S20.indexOf(`### 20.${n} `); const b = S20.indexOf('\n### 20.', a + 5); return S20.slice(a, b > 0 ? b : undefined); };
|
||||
const intro = S20.slice(0, S20.indexOf('### 20.1 '));
|
||||
const s209 = subsec(9);
|
||||
const BALANCE = doc('docs/BALANCE.md');
|
||||
const R02 = doc('docs/research/02-bonds.md');
|
||||
const R11 = doc('docs/research/11-limits-official.md');
|
||||
// the intro names the follow-up and the four sections, in merge order
|
||||
assert.match(intro, /\*\*Follow-up \(`playtest6b`, 2026-10-02\)\.\*\*/);
|
||||
for (const n of [10, 11, 12, 13]) assert.match(intro, new RegExp(`§20\\.${n}`), `the intro names §20.${n}`);
|
||||
// one layer cap: shared/constants.js only — the boss-HP branch's sim/constants.js copy is gone
|
||||
assert.equal(BOND_LAYER_CAP, 999);
|
||||
assert.equal(layerGainRoom(995, 10), 4);
|
||||
assert.ok(!('BOND_LAYER_CAP' in SIM_CONST) && !('layerRoom' in SIM_CONST), 'no second cap in server/sim/constants.js');
|
||||
for (const f of ['server/match/PlayerState.js', 'server/match/Match.js', 'server/sim/Battle.js']) {
|
||||
const src = doc(f);
|
||||
assert.match(src, /layerGainRoom/, `${f} clamps with layerGainRoom`);
|
||||
assert.ok(!/layerRoom\b/.test(src), `${f}: no layerRoom`);
|
||||
}
|
||||
// 限伤: official constant, not an overflow — no doc keeps the boss-HP branch's "fixed-point overflow, not modelled"
|
||||
assert.equal(BOSS_HIT_LIMIT, 300000);
|
||||
for (const [name, text] of [['BALANCE', BALANCE], ['DATA', DATA_MD], ['PLAYING', PLAYING], ['SIM', SIM], ['META', META], ['research 02', R02], ['DESIGN', DESIGN]]) {
|
||||
assert.ok(!/reads as the engine's fixed-point overflow|is \[inference\] and is not modelled|Not modelled: the largest damage/.test(text), `${name}: 限伤 is not called an unmodelled overflow`);
|
||||
assert.ok(!/BOND_LAYER_CAP`?\)? ?\[ASSUMED\]|999 \[ASSUMED\]|awaiting the user's confirmation/.test(text), `${name}: the 999 cap is official, not [ASSUMED]`);
|
||||
}
|
||||
assert.match(BALANCE, /superseded by that binary evidence/);
|
||||
assert.match(R11, /MAX_BATTLE_DAMAGE = 300000/);
|
||||
assert.match(R02, /MAX_GARRISON_STACK = 999/);
|
||||
// §20.9: elite settled, 999 / 限伤 official with their flips, the fixed leader pool settled (aliveScaling off in the data)
|
||||
assert.match(s209, /\| A merge's elite \(§20\.11\) \| takes the consumed deployed copy's tile/);
|
||||
assert.match(s209, /`shared\/constants\.js BOND_LAYER_CAP = 0`/);
|
||||
assert.match(s209, /`shared\/constants\.js BOSS_HIT_LIMIT = 0`/);
|
||||
assert.match(s209, /Settled by the user \("保持固定血量"\)[^\n]*aliveScaling` is \*\*false\*\*/);
|
||||
assert.equal(DATA.config.bossHpScale.aliveScaling, false, 'the user chose the fixed leader pool');
|
||||
assert.equal(SIM_CONST.DIRECT_BONUS_STACKING, 'add');
|
||||
assert.match(s209, /DIRECT_BONUS_STACKING = 'multiply'/);
|
||||
// the normative lines
|
||||
const s52 = DESIGN.slice(DESIGN.indexOf('### 5.2'), DESIGN.indexOf('### 5.3'));
|
||||
const s53 = DESIGN.slice(DESIGN.indexOf('### 5.3'), DESIGN.indexOf('### 5.4'));
|
||||
const s55 = DESIGN.slice(DESIGN.indexOf('### 5.5'), DESIGN.indexOf('### 5.6'));
|
||||
const s62 = DESIGN.slice(DESIGN.indexOf('### 6.2'), DESIGN.indexOf('### 6.3'));
|
||||
const s63 = DESIGN.slice(DESIGN.indexOf('### 6.3'), DESIGN.indexOf('### 6.4'));
|
||||
assert.match(s52, /are 直接乘算 and go into `Σpct`/);
|
||||
assert.match(s53, /`Battle\.applyStrongest`/);
|
||||
assert.match(s55, /\*\*限伤\*\*[^\n]*`ceil\(final\) ≥ 300000` is cancelled[^\n]*before HP shields/);
|
||||
assert.match(s62, /elite onto the consumed deployed copy's tile, else to the hand \(§20\.11\)/);
|
||||
assert.ok(!/A merge's elite always goes to the hand/.test(DESIGN), '§6.2: the hand-first wording is gone');
|
||||
assert.match(s63, /`BOND_LAYER_CAP` = 999 layers \(the client's `MAX_GARRISON_STACK`/);
|
||||
assert.match(sec(8), /`'hitCap' \{id, n\}` = a cancelled 限伤 hit, drawn as nothing/);
|
||||
assert.match(sec(9), /fx\.promote instead of fx\.deploy/);
|
||||
assert.match(sec(14), /`layerGains ≤ min\(60 \+ 4·round, 999 − the bond's starting layers\)`/);
|
||||
assert.match(sec(2), /pick\.js, promote\.js/);
|
||||
// leader parts and drones: one share, 1:1 (PRTS 等量); drones read the pool
|
||||
assert.equal(PART_TRANSFER, 1);
|
||||
assert.equal(BLADE_TRANSFER, PART_TRANSFER);
|
||||
assert.equal(DRONE_LINK_BASE, 'pool');
|
||||
assert.match(sec(7), /pass every damage they take to their leader 1:1 as 无来源 HP loss \(`PART_TRANSFER` 1/);
|
||||
assert.match(SIM, /`PART_TRANSFER` 1 for 斩胄之剑 \/ 破胄之锤 \(`BLADE_TRANSFER`, the same\s+constant\) and 碎铳之簧/);
|
||||
assert.ok(!/`PART_TRANSFER` 0\.5 \[ASSUMED\]/.test(SIM), 'SIM: no ½ spring transfer');
|
||||
assert.match(PLAYING, /无人机死亡时(不论是谁击落;漏过去不算)/);
|
||||
// §20.13: the 胄 audit, short
|
||||
assert.match(subsec(13), /fires \*\*one\*\* 刺胄之弹 below 20 % too/);
|
||||
assert.match(subsec(13), /\*\*any\*\* drone death costs the leader 2 % max HP/);
|
||||
});
|
||||
|
||||
test('playtest6b QA residuals (DESIGN §20.14): the held boss result, the cue between two preps, the 可晋升 line — code and every doc agree', () => {
|
||||
const S20 = DESIGN.slice(DESIGN.indexOf('## 20.'));
|
||||
const s2014 = S20.slice(S20.indexOf('### 20.14 '));
|
||||
assert.ok(s2014.length > 100, '§20.14 exists');
|
||||
const intro = S20.slice(0, S20.indexOf('### 20.1 '));
|
||||
assert.match(intro, /residual issues are handled in §20\.14/);
|
||||
// the held 'cleared' result: Match.js = §14 = §20.14 = META
|
||||
const match = doc('server/match/Match.js');
|
||||
assert.match(match, /if \(result\.reason === 'cleared' && pool && reported - f\.bossAcked >= pool\.hp - 1\) \{\s*f\.heldResult = result;/);
|
||||
assert.match(match, /_bossHandover\(f, why, \{ demote = false \} = \{\}\) \{\s*if \(f\.done \|\| f\.mode !== 'client' \|\| f\.heldResult\) return;/);
|
||||
assert.match(match, /const BOSS_MIN_CLEAR_GS = 5;/, 'the budget itself is unchanged');
|
||||
const s14 = DESIGN.slice(DESIGN.indexOf('## 14.'), DESIGN.indexOf('## 15.'));
|
||||
assert.match(s14, /it is \*\*held\*\* \(`f\.heldResult`/);
|
||||
assert.match(META, /waits for the budget instead \(`f\.heldResult`/);
|
||||
assert.match(s2014, /\*\*Fast boss kills handed over \(fixed\)\.\*\*/);
|
||||
// the promotion cue between two preps (render/app.js promoBase) and the touch line (shopBar mergeHint)
|
||||
assert.match(doc('public/js/render/app.js'), /if \(prepPieces\.length\) promoBase = prepPieces;/);
|
||||
assert.match(doc('public/js/ui/shopBar.js'), /export function mergeHint\(priv, chessId\)/);
|
||||
assert.match(s2014, /`promoBase`/);
|
||||
assert.match(s2014, /可晋升:精锐干员将出现在作战区原位置/);
|
||||
assert.match(PLAYING, /详情卡里也会写明去向/);
|
||||
// the measurements: real seconds, the integrated build, the pre-merge numbers labelled
|
||||
const s2010 = S20.slice(S20.indexOf('### 20.10 '), S20.indexOf('### 20.11 '));
|
||||
assert.match(s2010, /\*\*Measured\*\* \(real seconds/);
|
||||
assert.match(s2010, /Integrated build \(QA 6b on `cca11e6`/);
|
||||
const BALANCE = doc('docs/BALANCE.md');
|
||||
assert.match(BALANCE, /\*\*2026-10-02 — the integrated build \(QA 6b/);
|
||||
assert.match(BALANCE, /measured on the boss-HP workstream's boards, \*\*before\*\* the elite-to-board merge/);
|
||||
});
|
||||
@@ -47,6 +47,7 @@ export async function startRealServer(opts = {}) {
|
||||
if (opts.fast.kit != null) env.SP_START_KIT = String(opts.fast.kit);
|
||||
if (opts.fast.chess?.length) env.SP_START_CHESS = opts.fast.chess.join(',');
|
||||
if (opts.fast.items?.length) env.SP_START_ITEMS = opts.fast.items.join(',');
|
||||
if (opts.fast.shop?.length) env.SP_START_SHOP = opts.fast.shop.join(',');
|
||||
if (opts.fast.idleBots) env.SP_IDLE_BOTS = '1';
|
||||
}
|
||||
const child = spawn(process.execPath, [entry], { cwd: ROOT, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
// SP_IDLE_BOTS=1: AI seats only ready up (no shop, no board) — test/ui/playtest2.real.e2e.test.js observes their battles.
|
||||
// SP_START_ITEMS=<itemId,…>: the starter kit also puts these items into the hand (test/ui/leftovers.e2e.test.js: three
|
||||
// distinct equipment items → the equip-replace dialog).
|
||||
// SP_START_SHOP=<chessId,…>: the starter kit also stocks the first shop slots with these chess at their normal price
|
||||
// (test/ui/playtest6-elite.e2e.test.js: the copy that completes a merge).
|
||||
// Not a test file (node --test runs it as a no-op module when NODE_TEST_CONTEXT is set).
|
||||
|
||||
import { startServer } from '../../server/index.js';
|
||||
@@ -25,6 +27,7 @@ if (!process.env.NODE_TEST_CONTEXT) {
|
||||
const idleBots = process.env.SP_IDLE_BOTS === '1';
|
||||
const kitIds = String(process.env.SP_START_CHESS || '').split(',').map((x) => x.trim()).filter(Boolean);
|
||||
const kitItems = String(process.env.SP_START_ITEMS || '').split(',').map((x) => x.trim()).filter(Boolean);
|
||||
const kitShop = String(process.env.SP_START_SHOP || '').split(',').map((x) => x.trim()).filter(Boolean);
|
||||
|
||||
class FastMatch extends Match {
|
||||
constructor(opts) {
|
||||
@@ -75,6 +78,9 @@ if (!process.env.NODE_TEST_CONTEXT) {
|
||||
if (slot < 0 || !this.gd.item(itemId)) continue;
|
||||
try { ps.hand[slot] = ps.newPiece('item', itemId); } catch { /* best effort */ }
|
||||
}
|
||||
kitShop.forEach((id, i) => {
|
||||
if (i < ps.shop.slots.length && this.gd.chess(id)) ps.shop.slots[i] = { kind: 'chess', id, basePrice: this.gd.chessPrice(id), frozen: false, sold: false };
|
||||
});
|
||||
ps.addFunds(20, { reason: 'income' });
|
||||
ps.recompute();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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}/);
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -389,6 +389,43 @@ describe('render engine in headless Chrome', { skip }, () => {
|
||||
assert.deepEqual(r.battle, [['UP', true, 1], ['LEFT', true, -1], ['RIGHT', false, 1]]);
|
||||
});
|
||||
|
||||
test('a merge completed between two preps (band grant at ROUND_START, after a battle) still plays the promotion cue (QA 6b)', async () => {
|
||||
const { page, problems } = await open('scene=prep&panel=0', 1600, 900);
|
||||
await wait(1500);
|
||||
const r = await page.evaluate(async () => {
|
||||
const v = window.__demo.view, st = window.__demo.scene.state;
|
||||
const rows = await (await fetch('/data/chess.json')).json();
|
||||
const chess = rows.chess || rows;
|
||||
const prev = JSON.parse(JSON.stringify(st));
|
||||
const b0 = prev.board.find((p) => p.kind === 'chess' && chess[p.id] && !chess[p.id].isGolden && chess[p.id].goldenId);
|
||||
if (!b0) return { skip: 'no normal chess on the demo board' };
|
||||
// the other copy waits in the hand
|
||||
const uids = [...prev.hand, ...prev.board, ...(prev.temp || [])].filter(Boolean).map((p) => p.uid);
|
||||
const hu = Math.max(...uids) + 1;
|
||||
prev.hand = [{ uid: hu, kind: 'chess', id: b0.id }, ...prev.hand.filter((p) => p && p.uid !== hu)].slice(0, 9);
|
||||
v.setPrep(prev, { editable: true });
|
||||
const n0 = v.debug.promotions.length;
|
||||
// a battle rebuilds the scene; the merge's elite takes the deployed copy's tile at the next round start
|
||||
v.enterBattle({ fieldId: 'x', kind: 'normal', units: [] });
|
||||
const next = JSON.parse(JSON.stringify(prev));
|
||||
next.hand = next.hand.filter((p) => p.uid !== hu);
|
||||
const eu = hu + 1;
|
||||
next.board = next.board.map((p) => (p.uid === b0.uid ? { uid: eu, kind: 'chess', id: chess[b0.id].goldenId, golden: true, row: b0.row, col: b0.col, dir: b0.dir } : p));
|
||||
v.setPrep(next, { editable: true });
|
||||
const p = v.debug.promotions.slice(n0);
|
||||
// a plain round rebuild (no merge) cues nothing
|
||||
v.enterBattle({ fieldId: 'y', kind: 'normal', units: [] });
|
||||
v.setPrep(JSON.parse(JSON.stringify(next)), { editable: true });
|
||||
return { p, after: v.debug.promotions.length - n0, row: b0.row, col: b0.col, eu };
|
||||
});
|
||||
await page.close();
|
||||
assert.deepEqual(problems, []);
|
||||
assert.equal(r.skip, undefined, 'the demo board has a normal operator');
|
||||
assert.equal(r.p.length, 1, JSON.stringify(r.p));
|
||||
assert.deepEqual([r.p[0].uid, r.p[0].area, r.p[0].row, r.p[0].col, r.p[0].copies], [r.eu, 'board', r.row, r.col, 2], 'the elite on the copy\'s tile, both copies');
|
||||
assert.equal(r.after, 1, 'no cue for a rebuild without a merge');
|
||||
});
|
||||
|
||||
test('drops outside the stage report client coords; a refused drop tweens back home', async () => {
|
||||
const { page, problems } = await open('scene=prep&panel=0', 1600, 900);
|
||||
await wait(2000);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// User playtest #6 follow-up — the promotion cue of a merge (render/promote.js, render/fx.js promote, render/app.js
|
||||
// setPrep). A merge's elite appears on the board tile of the deployed copy it replaced (PRTS 卫戍协议/帮助 "若消耗已部署至
|
||||
// 作战区的干员,则发送至作战区对应位置"), else on a bench slot; setPrep recognises it (a new elite uid while normal copies of
|
||||
// the same operator vanished) and plays fx.promote there instead of the plain deploy flash. Headless fake PIXI.
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
|
||||
import { presetCamera } from '../../public/js/render/projection.js';
|
||||
import { promotionsOf } from '../../public/js/render/promote.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const CHESS = JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
|
||||
const ROWS = CHESS.chess || CHESS;
|
||||
const chessOf = (id) => ROWS[id] || null;
|
||||
const A = 'chess_char_1_01_a', B = 'chess_char_1_02_a';
|
||||
const gold = (id) => ROWS[id].goldenId;
|
||||
const e = (uid, id, area, o = {}) => ({ uid, area, piece: { uid, kind: 'chess', id, golden: !!ROWS[id]?.isGolden, ...o }, key: `p:${uid}`, ...o });
|
||||
|
||||
describe('promotionsOf', () => {
|
||||
test('a new elite whose normal copies vanished is a promotion (board or bench)', () => {
|
||||
const prev = [e(1, A, 'board', { row: 10, col: 3 }), e(2, A, 'board', { row: 11, col: 5 }), e(3, B, 'hand', { idx: 9 })];
|
||||
const next = [e(9, gold(A), 'board', { row: 11, col: 5 }), e(3, B, 'hand', { idx: 9 })];
|
||||
const p = promotionsOf(prev, next, chessOf);
|
||||
assert.deepEqual([...p.keys()], [9]);
|
||||
assert.deepEqual(p.get(9).map((g) => g.uid).sort(), [1, 2]);
|
||||
const hand = promotionsOf([e(1, A, 'hand', { idx: 9 }), e(2, A, 'hand', { idx: 8 })], [e(7, gold(A), 'hand', { idx: 9 })], chessOf);
|
||||
assert.deepEqual([...hand.keys()], [7], 'an elite arriving in the hand is promoted too');
|
||||
});
|
||||
test('not a promotion: the first state, a sale, an elite gained without copies vanishing, another operator', () => {
|
||||
assert.equal(promotionsOf([], [e(9, gold(A), 'board', { row: 10, col: 3 })], chessOf).size, 0, 'first setPrep');
|
||||
assert.equal(promotionsOf([e(1, A, 'board')], [], chessOf).size, 0, 'a sale');
|
||||
assert.equal(promotionsOf([e(1, B, 'hand')], [e(9, gold(A), 'hand')], chessOf).size, 0, 'a granted elite while another operator left');
|
||||
assert.equal(promotionsOf([e(1, A, 'hand')], [e(1, A, 'board', { row: 10, col: 3 }), e(5, B, 'hand')], chessOf).size, 0, 'a move and a buy');
|
||||
assert.equal(promotionsOf([e(4, gold(A), 'hand')], [e(4, gold(A), 'board', { row: 9, col: 2 })], chessOf).size, 0, 'an elite placed by hand');
|
||||
});
|
||||
test('two merges in one update are told apart by operator', () => {
|
||||
const prev = [e(1, A, 'board'), e(2, A, 'hand'), e(3, B, 'hand'), e(4, B, 'temp')];
|
||||
const next = [e(10, gold(A), 'board'), e(11, gold(B), 'hand')];
|
||||
const p = promotionsOf(prev, next, chessOf);
|
||||
assert.deepEqual(p.get(10).map((g) => g.uid).sort(), [1, 2]);
|
||||
assert.deepEqual(p.get(11).map((g) => g.uid).sort(), [3, 4]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fx.promote', () => {
|
||||
let fake, FX;
|
||||
before(async () => { fake = installFakePixi(); FX = await import('../../public/js/render/fx.js'); });
|
||||
after(() => fake.restore());
|
||||
const cam = presetCamera('prep', { width: 1600, height: 900 });
|
||||
function makeFx() {
|
||||
const P = fake.P;
|
||||
const ctx = fakeViewCtx(P);
|
||||
return new FX.FxSystem({
|
||||
P, layers: ctx.layers, cam: () => cam, heightAt: () => 0, settings: { quality: 'high', damageNumbers: true },
|
||||
timeScale: () => 1, loadLevel: () => 0, subProfOf: () => null, view: () => null,
|
||||
screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120,
|
||||
});
|
||||
}
|
||||
const view = (x, y) => ({ id: 'p:9', x, y, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, isEnemy: false, info: { defId: gold(A) } });
|
||||
const texName = (fx, sp) => Object.keys(fx.tex).find((k) => fx.tex[k] === sp.texture);
|
||||
|
||||
test('a gold pillar, a shockwave and a hex ring at the elite, streaks from the other copies; counted', () => {
|
||||
const fx = makeFx();
|
||||
const before = fx.counts;
|
||||
assert.equal(before.promotions, 0);
|
||||
fx.promote(view(5, 10), [{ x: 3, y: 11, z: 0 }]);
|
||||
const names = fx.parts.map((p) => texName(fx, p.sp));
|
||||
assert.ok(names.filter((n) => n === 'pillar').length >= 2, `pillars (${names.join(',')})`);
|
||||
assert.ok(names.includes('streak'), 'a streak from the other consumed copy');
|
||||
assert.ok(fx.rings.length >= 2, 'shockwave + hex ring');
|
||||
assert.equal(fx.counts.promotions, 1);
|
||||
fx.promote(null);
|
||||
assert.equal(fx.counts.promotions, 1, 'no view: nothing');
|
||||
});
|
||||
});
|
||||
|
||||
test('render/app.js plays the promotion cue instead of the deploy flash for a merge\'s elite', () => {
|
||||
const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8');
|
||||
assert.match(src, /import \{ promotionsOf \} from '\.\/promote\.js';/);
|
||||
assert.match(src, /const before = prepPieces\.length \? prepPieces : promoBase;[\s\S]{0,80}promotionsOf\(before, list, \(id\) => data\.chess\(id\)\)/, 'compared with the previous state');
|
||||
assert.match(src, /if \(prepPieces\.length\) promoBase = prepPieces;\s*prepPieces = \[\];/, 'a battle keeps the last prep state: a merge between two preps is cued too (QA 6b)');
|
||||
assert.match(src, /if \(promoFrom\.has\(e\.uid\)\) \{[\s\S]{0,400}fx\.promote\(v,[\s\S]{0,400}\} else if \(e\.area === 'board' && prevBoard\.size && !prevBoard\.has\(e\.uid\)\) \{ v\.onDeploy\?\.\(\); fx\.deploy\(v\); \}/);
|
||||
assert.match(src, /promotions,/, 'the cue log is exposed on view.debug (E2E)');
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
// 限伤 on screen (docs/research/11-limits-official.md §2, DESIGN §20.12): a leader's hit of ≥ 300000 in a boss battle
|
||||
// is cancelled by the sim (server/sim/damage.js leaderHitCancelled), which emits ['fx', 'hitCap', x, y, { id, n }] and
|
||||
// no 'dmg' event. The official shows no number for it [ASSUMED]; the renderer draws nothing for the event.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FxSystem, FX_KINDS, fxSpec } from '../../public/js/render/fx.js';
|
||||
import { presetCamera } from '../../public/js/render/projection.js';
|
||||
|
||||
function recordingFx() {
|
||||
const cam = presetCamera('boss', { width: 1280, height: 720 });
|
||||
const calls = [];
|
||||
const fx = Object.create(FxSystem.prototype);
|
||||
const rec = (name) => (...a) => { calls.push(name); return name === '_where' || name === '_point' ? { x: a[0], y: a[1], z: 0, v: null } : undefined; };
|
||||
Object.assign(fx, {
|
||||
ctx: { cam: () => cam, heightAt: () => 0, fieldRect: () => ({ r0: 0, r1: 5, c0: 0, c1: 20 }), timeScale: () => 2 },
|
||||
_p: { x: 0, y: 0, s: 0, depth: 0 }, _q: { x: 0, y: 0, s: 0, depth: 0 }, locks: [],
|
||||
_where: rec('_where'), _point: rec('_point'), particle: rec('particle'), ring: rec('ring'), burst: rec('burst'),
|
||||
explosion: rec('explosion'), flashScreen: rec('flashScreen'), tileFlash: rec('tileFlash'),
|
||||
});
|
||||
fx._chest = (v, out) => Object.assign(out, cam.project(0, 0, 0.5, out));
|
||||
return { fx, calls };
|
||||
}
|
||||
|
||||
test('hitCap: listed in FX_KINDS as a kind that draws nothing', () => {
|
||||
assert.equal(FX_KINDS.hitCap.a, 'none');
|
||||
assert.equal(fxSpec('hitCap', { id: 3, n: 300000 }).a, 'none');
|
||||
});
|
||||
|
||||
test('hitCap: simFx draws nothing (no number, no spark); an unknown kind still gets its generic sparkle', () => {
|
||||
const { fx, calls } = recordingFx();
|
||||
fx.simFx('hitCap', 10, 3, { id: 7, n: 412345 });
|
||||
assert.deepEqual(calls, [], 'nothing drawn for a cancelled leader hit');
|
||||
fx.simFx('zzzUnknownKind', 10, 3, {});
|
||||
assert.ok(calls.includes('particle'), 'the generic sparkle still works');
|
||||
});
|
||||
+14
-1
@@ -4,7 +4,7 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Battle } from '../../server/sim/Battle.js';
|
||||
import { makeBattle, chessRec, enemyRec, flatStage, hashOf, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { UF, ANIM } from '../../shared/constants.js';
|
||||
import { UF, ANIM, BOND_LAYER_CAP } from '../../shared/constants.js';
|
||||
import { EV } from '../../shared/protocol.js';
|
||||
import { getDefaultSource, spawnsFromTemplate, hasGeneratedData } from '../../server/sim/simdata.js';
|
||||
import { LocalBossPool } from '../../server/sim/spec.js';
|
||||
@@ -478,6 +478,19 @@ test('layers & coins: addLayers records gains (normal field) and emits client ev
|
||||
assert.equal(h2.b.addLayers('p1', 'yanShip', 3), 0);
|
||||
});
|
||||
|
||||
test('layers: an IN_BATTLE gain stops at BOND_LAYER_CAP (999, MAX_GARRISON_STACK) — the battle\'s count and the reported gain', () => {
|
||||
assert.equal(BOND_LAYER_CAP, 999);
|
||||
const h = makeBattle({ content: 'none', bonds: { yanShip: { count: 3, active: true, tier: 1, layers: 995 } } });
|
||||
h.step();
|
||||
assert.equal(h.b.addLayers('p1', 'yanShip', 3, 'test'), 3);
|
||||
assert.equal(h.b.addLayers('p1', 'yanShip', 5, 'test'), 1, 'only the room left under the cap');
|
||||
assert.equal(h.b.addLayers('p1', 'yanShip', 5, 'test'), 0, 'at the cap: nothing');
|
||||
assert.equal(h.b.getPlayer('p1').bonds.yanShip.layers, 999);
|
||||
h.b.forceEnd('forced');
|
||||
assert.deepEqual(h.result().perPlayer.p1.layerGains, { yanShip: 4 });
|
||||
assert.deepEqual(h.eventsOf('layer').map((e) => e[3]), [3, 1], 'the client sees what was added');
|
||||
});
|
||||
|
||||
test('tokens: spawnToken deploys a token next to its owner; manual token pieces deploy from input', () => {
|
||||
const ds = getDefaultSource();
|
||||
const h = makeBattle({
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// test/sim/bossfield.browser.test.js — the Final Assault / Hidden Core fields in the BROWSER sim (DESIGN §14: the
|
||||
// clients simulate the boss fields; the server re-simulates them for SP_VERIFY and takeovers). A real bot co-op match
|
||||
// (终极, seed 12: both pair fields carry two 奥术 players) is played to the Hidden Core with every active bond at the
|
||||
// official 999 layers (DESIGN §20.12); its four captured boss specs (R14 假想敌:胄 boss_1, R15 隐秘核心 boss_8) run to
|
||||
// the end in Node and in headless Chrome through the client's own loader (public/js/battle/runner.js loadBrowserSim)
|
||||
// with the field's LocalBossPool, and must give the same result digest, the same pool and the same fx per kind.
|
||||
// The fields exercise the playtest-6b sim paths: 直接乘算 bonus sums (§20.10), one 奥术 instance per target
|
||||
// (applyStrongest), the drone 2 % links and the 剑 / 锤 / shell kit (§20.13), and 限伤 cancels (fx 'hitCap', §20.12).
|
||||
//
|
||||
// Opt-in (starts Chrome): SIM_E2E=1 node --test test/sim/bossfield.browser.test.js (or RENDER_E2E=1)
|
||||
// Run browser test files one at a time. Chrome path: $CHROME_PATH or the macOS default.
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
import { PHASE, BOND_LAYER_CAP } from '../../shared/constants.js';
|
||||
import { createBattleFromSpec, resultDigest } from '../../server/sim/spec.js';
|
||||
import { DataSource } from '../../server/sim/simdata.js';
|
||||
import { makeMatch, DATA } from '../match/harness.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const enabled = (process.env.RENDER_E2E === '1' || process.env.SIM_E2E === '1') && existsSync(CHROME);
|
||||
const skip = enabled ? false : 'set SIM_E2E=1 or RENDER_E2E=1 (needs Chrome)';
|
||||
const MAX_SECONDS = 600; // game seconds (Battle.runToEnd): every captured field clears long before
|
||||
|
||||
/** The boss / hidden specs of a 4-bot co-op match (终极, seed 12, boss_1 then boss_8), every active bond at 999. */
|
||||
function captureBossSpecs() {
|
||||
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: 'ABYSS', seats, seed: 12, captureFrames: false, instant: false, clientCombat: true });
|
||||
const m = h.m;
|
||||
m.bossId = 'boss_1';
|
||||
m.hiddenBossId = 'boss_8';
|
||||
const specs = [];
|
||||
const orig = m._ccField.bind(m);
|
||||
m._ccField = (o) => { const f = orig(o); if (f.kind === 'boss' || 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] = BOND_LAYER_CAP;
|
||||
ps.recompute();
|
||||
}
|
||||
}
|
||||
}
|
||||
return h.ended != null || m.phase === PHASE.HIDDEN_CORE;
|
||||
}, { maxSteps: 8e6 });
|
||||
assert.equal(m.phase, PHASE.HIDDEN_CORE, 'the bots reached the Hidden Core');
|
||||
m.dispose();
|
||||
return specs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a spec to the end (the same code in Node and in the page: `S` = spec module, `ds` = data source) and returns
|
||||
* its digest, the local pool, the fx count per kind and the leader-side checks (奥术 instances, 无来源 HP losses).
|
||||
* Serialised into the page with Function#toString, so it uses nothing from this module's scope.
|
||||
*/
|
||||
function runField(S, ds, spec, maxSeconds) {
|
||||
const b = S.createBattleFromSpec(spec, ds, { quiet: true, recordEvents: false });
|
||||
const fx = {};
|
||||
const fx0 = b.fx.bind(b);
|
||||
b.fx = (k, p) => { fx[k] = (fx[k] || 0) + 1; return fx0(k, p); };
|
||||
let hpLoss = 0;
|
||||
b.on('damaged', (c) => { if (c.target && c.target.isBoss && c.dmg && c.dmg.tags && c.dmg.tags.includes('hpLoss')) hpLoss++; }, { priority: -1e9 });
|
||||
let maxArcane = 0;
|
||||
b.on('tick', () => {
|
||||
for (const e of b.enemies) {
|
||||
if (!e.alive || !e.isBoss) continue;
|
||||
const n = e.buffs.filter((x) => String(x.key).startsWith('bond:arcaneShip')).length;
|
||||
if (n > maxArcane) maxArcane = n;
|
||||
}
|
||||
});
|
||||
const r = b.runToEnd(maxSeconds);
|
||||
const d = S.resultDigest(r);
|
||||
return { hash: d.hash, json: d.json, reason: r ? r.reason : null, poolHp: b.sharedBoss ? b.sharedBoss.hp : null, fx, hpLoss, maxArcane };
|
||||
}
|
||||
|
||||
describe('Final Assault / Hidden Core fields in the browser sim', { skip }, () => {
|
||||
let srv, browser;
|
||||
before(async () => {
|
||||
const require = createRequire(path.join(ROOT, 'package.json'));
|
||||
const puppeteer = require('puppeteer-core');
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-first-run'] });
|
||||
});
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
test('real boss specs at 999 layers: Chrome and Node give the same digest, pool and fx; 限伤, drones, 瘫痪 and 奥术 all occur', { timeout: 180000 }, async () => {
|
||||
const specs = captureBossSpecs();
|
||||
assert.deepEqual(specs.map((s) => `${s.kind}:${s.bossId}`).sort(), ['boss:boss_1', 'boss:boss_1', 'hidden:boss_8', 'hidden:boss_8']);
|
||||
const ds = new DataSource(DATA, null);
|
||||
const node = specs.map((spec) => runField({ createBattleFromSpec, resultDigest }, ds, spec, MAX_SECONDS));
|
||||
|
||||
const page = await browser.newPage();
|
||||
const problems = [];
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
// (a failed request is reported by the response handler with its URL; the favicon 404 is not a problem)
|
||||
page.on('console', (m) => { if (m.type() === 'error' && !/Failed to load resource/.test(m.text())) problems.push(`console: ${m.text()}`); });
|
||||
page.on('response', (r) => { if (r.status() >= 400 && !/favicon/.test(r.url())) problems.push(`HTTP ${r.status()} ${r.url()}`); });
|
||||
await page.goto(`http://127.0.0.1:${srv.port}/sim/spec.js`);
|
||||
const chrome = await page.evaluate(async (all, src, maxSeconds) => {
|
||||
const { loadBrowserSim } = await import('/js/battle/runner.js');
|
||||
const { spec: S, ds } = await loadBrowserSim();
|
||||
const run = new Function(`return (${src})`)();
|
||||
return all.map((spec) => run(S, ds, spec, maxSeconds));
|
||||
}, specs, runField.toString(), MAX_SECONDS);
|
||||
await page.close();
|
||||
assert.deepEqual(problems, []);
|
||||
|
||||
specs.forEach((spec, i) => {
|
||||
const at = `${spec.kind} ${spec.fieldId} ${spec.bossId}`;
|
||||
assert.equal(node[i].reason, 'cleared', `${at}: the 999-layer pair clears the field`);
|
||||
assert.equal(chrome[i].json, node[i].json, `${at}: browser and Node results are identical`);
|
||||
assert.equal(chrome[i].hash, node[i].hash, at);
|
||||
assert.equal(chrome[i].poolHp, node[i].poolHp, `${at}: same local pool`);
|
||||
assert.deepEqual(chrome[i].fx, node[i].fx, `${at}: same fx per kind`);
|
||||
assert.equal(chrome[i].hpLoss, node[i].hpLoss, `${at}: same 无来源 HP losses on the leader`);
|
||||
assert.ok(node[i].maxArcane <= 1, `${at}: one 奥术 instance on the leader at a time (${node[i].maxArcane})`);
|
||||
});
|
||||
const sum = (k) => node.reduce((a, r) => a + (r.fx[k] || 0), 0);
|
||||
assert.ok(sum('hitCap') >= 1, `a 999-layer hit reached 300000 and was cancelled (限伤: ${sum('hitCap')})`);
|
||||
assert.ok(sum('palsy') >= 1, `瘫痪 occurred (${sum('palsy')})`);
|
||||
assert.ok(node.every((r) => r.hpLoss >= 1), `every field cost the leader 无来源 HP (drone links / 剑 · 锤 transfers: ${node.map((r) => r.hpLoss)})`);
|
||||
assert.ok(node.some((r) => r.maxArcane === 1), 'the leader carried 奥术');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
// "终极 leaders die in seconds" (user report after playtest #6): the percentage attribute bonuses of the 卫戍 systems
|
||||
// compounded. PRTS 卫戍协议:盟约 下半/PRTS盟约记录: "盟约效果,策略效果,装备效果提供的属性加成均为直接乘算"; PRTS 游戏数据基础
|
||||
// 属性基本公式: 直接乘算 values are SUMMED — D_t = t₁ + … + tₙ, A = (A₀ + D_p)(1 + D_t) — and PRTS 作战机制 X₂ = X₁(1 + b₁% +
|
||||
// b₂%) with a skill's "攻击力+X%" in the same sum. v2.5 gave every bond / strategy / item its own ×(1 + x) (atkMul), so
|
||||
// with many layers a ranged operator under 精准 + 独行 + 卡西米尔 + an item + a 特质 dealt ×74 instead of ×12 of its ATK.
|
||||
// Now: content/support directMods → atkPct / defPct / hpPct (constants.js DIRECT_BONUS_STACKING 'add'); the 炎佑 ×1.5
|
||||
// ("提升至1.5倍") and the char_attribute_mul 特质 (a rune on the chess's base attributes) stay multipliers.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec, flatStage, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { directMods } from '../../server/sim/content/support/index.js';
|
||||
import { bondBb } from '../../server/sim/content/bonds/addon/battle.js';
|
||||
import { DIRECT_BONUS_STACKING } from '../../server/sim/constants.js';
|
||||
import { SharedBossPool } from '../../server/match/finalAssault.js';
|
||||
|
||||
const close = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg ?? ''} expected ${b}, got ${a}`);
|
||||
const bond = (tier, layers = 0, count = 2) => ({ count, active: tier > 0, tier, layers });
|
||||
const HAMMER = 'chess_item_1_01_e_a'; // 维式重锤 攻击力+15%
|
||||
const BLADE = 'chess_item_3_07_e_a'; // 阿戈尔重刃 攻击力+40%
|
||||
|
||||
test('directMods: 直接乘算 ratios go to the additive percentage bucket (zeros dropped, extras merged)', () => {
|
||||
assert.equal(DIRECT_BONUS_STACKING, 'add');
|
||||
assert.deepEqual(directMods({ atk: 0.3, def: 0.2, hp: -0.1 }), { atkPct: 0.3, defPct: 0.2, hpPct: -0.1 });
|
||||
assert.deepEqual(directMods({ atk: 0, hp: NaN }), {});
|
||||
assert.deepEqual(directMods({ def: 0.5 }, { redeployMul: 0.7 }), { defPct: 0.5, redeployMul: 0.7 });
|
||||
});
|
||||
|
||||
test('bonds + equipment + a skill ATK%: one sum (精准 L100 +130 %, 独行 +60 %, two items +55 %, skill +50 %)', () => {
|
||||
const pre = bondBb('preciShip'), solo = bondBb('soloShip');
|
||||
const preci = pre.base_atk + pre.atk_per_stack * 100;
|
||||
const defs = { chess: { s_op: chessRec({ id: 's_op', bonds: ['preciShip', 'soloShip'], profession: 'SNIPER', skill: null }) } };
|
||||
const h = makeBattle({
|
||||
defs, units: [{ chessId: 's_op', row: 10, col: 3, items: [HAMMER, BLADE] }],
|
||||
bonds: { preciShip: bond(1, 100), soloShip: bond(1, 0, 1) },
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit('s_op');
|
||||
const sum = 1 + preci + solo.atk + 0.15 + 0.4;
|
||||
close(u.s.atk, 500 * sum, 'bonds and items add up');
|
||||
h.b.addBuff(u, { key: 'test:skill', mods: { atkPct: 0.5 } }); // a skill's "攻击力+50%" (直接乘算 too)
|
||||
close(u.s.atk, 500 * (sum + 0.5), 'the skill joins the same sum');
|
||||
const v25 = 500 * (1 + preci) * (1 + solo.atk) * 1.15 * 1.4 * 1.5; // the v2.5 product (each source its own multiplier)
|
||||
assert.ok(u.s.atk < v25 / 1.8, `far below the v2.5 product ${v25}`);
|
||||
close(u.s.maxHp, 2000 * (1 + solo.max_hp), '独行 HP');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('strategy + bonds add up: 阿米娅 众志合一 (3 active bonds: +20 % ATK / HP) with 精准 and 绝技 (elite)', () => {
|
||||
const pre = bondBb('preciShip'), sunt = bondBb('suntShip');
|
||||
const defs = { chess: { e_op: chessRec({ id: 'e_op', bonds: ['preciShip'], profession: 'SNIPER', skill: null, golden: true }) } };
|
||||
const h = makeBattle({
|
||||
defs, bandId: 'band_amiya', units: [{ chessId: 'e_op', row: 10, col: 3 }],
|
||||
bonds: { preciShip: bond(1, 50), suntShip: bond(1, 0, 2), steadShip: bond(1, 0) },
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit('e_op');
|
||||
const want = 1 + pre.base_atk + pre.atk_per_stack * 50 + sunt.power_atk + 0.2;
|
||||
close(u.s.atk, 500 * want, 'one 直接乘算 sum');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('a leader sharing the pool takes the summed ATK: one normal hit of a stacked operator (精准 L600 + 独行 + 2 items)', () => {
|
||||
const pre = bondBb('preciShip'), solo = bondBb('soloShip');
|
||||
const defs = {
|
||||
chess: { s_op: chessRec({ id: 's_op', bonds: ['preciShip', 'soloShip'], profession: 'SNIPER', skill: null, rangeGrid: [[0, 0], [0, 1], [0, 2], [0, 3]] }) },
|
||||
enemies: { enemy_stack_leader: { ...enemyRec({ key: 'enemy_stack_leader', hp: 600000, speed: 0, dmgType: 'none' }), rank: 'BOSS', hitArea: { w: 4.95, h: 2.95, dx: 0, dy: 1 } } },
|
||||
};
|
||||
const pool = new SharedBossPool(3600000);
|
||||
const h = makeBattle({
|
||||
defs, kind: 'boss', stage: flatStage(), sharedBoss: pool, autoFinish: false,
|
||||
units: [{ chessId: 's_op', row: 10, col: 6, items: [HAMMER, BLADE] }],
|
||||
bonds: { preciShip: bond(1, 600), soloShip: bond(1, 0, 1) },
|
||||
enemies: [{ key: 'enemy_stack_leader', pos: [3, 10], route: { motion: 'WALK', start: [3, 10], end: [3, 10], checkpoints: [] }, tag: 'boss' }],
|
||||
});
|
||||
const hits = [];
|
||||
h.b.on('damaged', (c) => { if (c.target.bossPool && c.dmg.isAttack) hits.push(c.amount); }, { priority: -1e9 });
|
||||
h.runUntil(() => hits.length >= 1, 10);
|
||||
const atk = 500 * (1 + pre.base_atk + pre.atk_per_stack * 600 + solo.atk + 0.15 + 0.4);
|
||||
assert.ok(hits.length >= 1, 'the operator hits the leader');
|
||||
close(hits[0], atk, 'DEF 0: one hit = the summed ATK');
|
||||
close(pool.maxHp - pool.hp, hits.reduce((s, x) => s + x, 0), 'the pool lost exactly the hits');
|
||||
assert.ok(hits[0] < 500 * (1 + pre.base_atk + pre.atk_per_stack * 600) * (1 + solo.atk) * 1.15 * 1.4 / 2, 'v2.5 compounded this to more than twice as much');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
@@ -139,8 +139,11 @@ for (const bossId of BOSSES) {
|
||||
const pool = new SharedBossPool(max);
|
||||
const { b } = bossBattle(bossId, pool);
|
||||
const leader = b.enemies.find((e) => e.alive && e.isBoss);
|
||||
const dealt = b.dealDamage(null, leader, { amount: 1e9, type: 'true', mul: (max - 0.4) / 1e9 });
|
||||
assert.ok(dealt > max - 1, `${bossId}: the hit took the whole pool (${dealt} of ${max})`);
|
||||
// the team already dealt all but 250000 (one hit of the whole pool would be cancelled by 限伤, BOSS_HIT_LIMIT 300000)
|
||||
const rest = 250000;
|
||||
pool.damage('p_1', max - rest);
|
||||
const dealt = b.dealDamage(null, leader, { amount: 1e9, type: 'true', mul: (rest - 0.4) / 1e9 });
|
||||
assert.ok(dealt > rest - 1, `${bossId}: the hit took the rest of the pool (${dealt} of ${rest})`);
|
||||
assert.equal(pool.hp, 0, 'not 0.4 HP: nobody would ever see it and no hit may be needed to finish it');
|
||||
for (let i = 0; i < 3 && !b.finished; i++) b.step();
|
||||
assert.equal(b.finished, true);
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
// Official limits (docs/research/11-limits-official.md; the user asked for the co-op leaders to be as hard to kill as
|
||||
// officially): (1) bond layers stop at 999 per bond — the client's AutoChessBattleConst.MAX_GARRISON_STACK and
|
||||
// AddBondCount `min(L + n, 999)`; (2) the boss-hit limit "限伤" — in a boss battle outside training (our kinds 'boss' /
|
||||
// 'hidden'), a single hit on a leader (AutoChessBattleUtil.IsBossEnemy) whose ceil(damage) ≥ 300000
|
||||
// (AutoChessBattleConst.MAX_BATTLE_DAMAGE) is cancelled: 0 damage, nothing credited to the shared pool.
|
||||
//
|
||||
// This file: the switches (shared/constants.js), the in-battle layer copy (Battle.addLayers / support.gainLayers) and
|
||||
// the hit limit in the damage pipeline (server/sim/damage.js leaderHitCancelled: dealDamage, Battle.loseHp).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { BOND_LAYER_CAP, BOSS_HIT_LIMIT, layerGainRoom } from '../../shared/constants.js';
|
||||
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
|
||||
import { gainLayers } from '../../server/sim/content/support/index.js';
|
||||
import { SharedBossPool } from '../../server/match/finalAssault.js';
|
||||
import { LocalBossPool, buildBattleSpec, createBattleFromSpec, resultDigest } from '../../server/sim/spec.js';
|
||||
import { GameData } from '../../server/match/gamedata.js';
|
||||
import { buildBossWave, setupMatchWaves } from '../../server/match/waves.js';
|
||||
import { createRng } from '../../server/sim/rng.js';
|
||||
import { GEO } from '../../shared/constants.js';
|
||||
import { getData } from '../../server/data.js';
|
||||
import { leaderHitCancelled } from '../../server/sim/damage.js';
|
||||
import { abOf } from '../../server/sim/content/enemies.js';
|
||||
|
||||
const DATA = getData({ log: { warn() {}, error() {}, info() {} } });
|
||||
const LIMIT = BOSS_HIT_LIMIT;
|
||||
|
||||
test('switches: BOND_LAYER_CAP = 999 (MAX_GARRISON_STACK), BOSS_HIT_LIMIT = 300000 (MAX_BATTLE_DAMAGE)', () => {
|
||||
assert.equal(BOND_LAYER_CAP, 999);
|
||||
assert.equal(BOSS_HIT_LIMIT, 300000);
|
||||
});
|
||||
|
||||
test('layerGainRoom: min(n, 999 − before), never negative, never lowers a count; +Infinity = the room left', () => {
|
||||
assert.equal(layerGainRoom(0, 5), 5);
|
||||
assert.equal(layerGainRoom(990, 5), 5);
|
||||
assert.equal(layerGainRoom(995, 10), 4, 'clamped to 999');
|
||||
assert.equal(layerGainRoom(999, 1), 0, 'a gain at the cap adds 0');
|
||||
assert.equal(layerGainRoom(1200, 3), 0, 'a count over the cap (a test write) gains 0 and is not lowered');
|
||||
assert.equal(layerGainRoom(300, Infinity), 699);
|
||||
assert.equal(layerGainRoom(undefined, 7), 7);
|
||||
assert.equal(layerGainRoom(NaN, 7), 7);
|
||||
for (const bad of [0, -3, NaN, null, undefined]) assert.equal(layerGainRoom(10, bad), 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// in-battle layer gains (the live copy, as the client's AddBondCount)
|
||||
|
||||
const opRec = (o = {}) => chessRec({ id: 't_op', profession: 'WARRIOR', stats: { atk: 300, maxHp: 1e6 }, skill: null, bonds: ['yanShip'], ...o });
|
||||
function layerBattle(bonds, extra = {}) {
|
||||
return makeBattle({
|
||||
defs: { chess: { t_op: opRec() } },
|
||||
players: [{ playerId: 'P', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, chessId: 't_op', row: 10, col: 4 }], bonds, playerEffects: [] }],
|
||||
content: 'none', autoFinish: false, ...extra,
|
||||
});
|
||||
}
|
||||
|
||||
test('Battle.addLayers: clamps the live copy at 999; the event and layerGains carry what was added; at the cap: 0, no hook, no event', () => {
|
||||
const h = layerBattle({ yanShip: { count: 3, active: true, tier: 1, layers: 990 } });
|
||||
h.step();
|
||||
const b = h.b;
|
||||
assert.equal(b.addLayers('P', 'yanShip', 4, 'garrison'), 4);
|
||||
assert.equal(b.getPlayer('P').bonds.yanShip.layers, 994);
|
||||
assert.equal(b.addLayers('P', 'yanShip', 20, 'garrison'), 5, 'only the room left (999 − 994)');
|
||||
assert.equal(b.getPlayer('P').bonds.yanShip.layers, 999);
|
||||
const hooks0 = h.hooksOf('layerGain').length;
|
||||
assert.equal(b.addLayers('P', 'yanShip', 3, 'garrison'), 0, 'a gain at the cap adds 0');
|
||||
assert.equal(h.hooksOf('layerGain').length, hooks0, 'no layerGain hook at the cap');
|
||||
assert.equal(b.getPlayer('P').bonds.yanShip.layers, 999);
|
||||
assert.deepEqual(h.eventsOf('layer').map((e) => e[3]), [4, 5], "'layer' events carry the clamped gains, none at the cap");
|
||||
assert.equal(h.result().perPlayer.P.layerGains.yanShip, 9, 'the result reports what was added');
|
||||
});
|
||||
|
||||
test('Battle.addLayers: a layerGain hook that raises n (魔王 +1) is clamped after the hook', () => {
|
||||
const h = layerBattle({ yanShip: { count: 3, active: true, tier: 1, layers: 997 } }, {
|
||||
setup: (b) => b.on('layerGain', (c) => { c.n += 5; }),
|
||||
});
|
||||
h.step();
|
||||
assert.equal(h.b.addLayers('P', 'yanShip', 1, 'garrison'), 2, '1 + 5 asked, 2 room');
|
||||
assert.equal(h.b.getPlayer('P').bonds.yanShip.layers, 999);
|
||||
});
|
||||
|
||||
test('Battle.addLayers without a live copy of the bond (partial input): the battle\'s own gains stop at 999', () => {
|
||||
const h = layerBattle({});
|
||||
h.step();
|
||||
assert.equal(h.b.addLayers('P', 'kjeragShip', 600, 'bond'), 600);
|
||||
assert.equal(h.b.addLayers('P', 'kjeragShip', 600, 'bond'), 399);
|
||||
assert.equal(h.b.addLayers('P', 'kjeragShip', 1, 'bond'), 0);
|
||||
assert.equal(h.result().perPlayer.P.layerGains.kjeragShip, 999);
|
||||
});
|
||||
|
||||
test('support.gainLayers (特质 / bond gains): returns the clamped total; a capped bond counts nothing toward the per-battle cap', () => {
|
||||
const h = layerBattle({ yanShip: { count: 3, active: true, tier: 1, layers: 998 }, kjeragShip: { count: 2, active: true, tier: 1, layers: 10 } });
|
||||
h.step();
|
||||
assert.equal(gainLayers(h.b, { playerId: 'P', bonds: ['yanShip', 'kjeragShip'], n: 3, reason: 'garrison' }), 1 + 3);
|
||||
assert.equal(gainLayers(h.b, { playerId: 'P', bonds: 'yanShip', n: 3, reason: 'garrison', cap: 24, capKey: 'g' }), 0);
|
||||
const ps = h.b.getPlayer('P');
|
||||
assert.deepEqual([ps.bonds.yanShip.layers, ps.bonds.kjeragShip.layers], [999, 13]);
|
||||
});
|
||||
|
||||
test('layer gains stay disabled in boss / 联防 fields (unchanged)', () => {
|
||||
const h = layerBattle({ yanShip: { count: 3, active: true, tier: 1, layers: 10 } }, { kind: 'boss' });
|
||||
h.step();
|
||||
assert.equal(h.b.addLayers('P', 'yanShip', 5, 'garrison'), 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 限伤: the boss-hit limit
|
||||
|
||||
const guardRec = () => chessRec({ id: 't_guard', profession: 'WARRIOR', stats: { atk: 10, maxHp: 1e9 }, skill: null });
|
||||
/** A field of `kind` with a leader (tag 'boss'), a minion and a part, all standing still; the shared pool when given. */
|
||||
function hitField({ kind = 'boss', pool = null, leaderDef = 0, leaderRes = 0 } = {}) {
|
||||
const h = makeBattle({
|
||||
kind, sharedBoss: pool,
|
||||
defs: {
|
||||
chess: { t_guard: guardRec() },
|
||||
enemies: {
|
||||
enemy_lead: enemyRec({ key: 'enemy_lead', rank: 'BOSS', hp: 1e9, speed: 0, def: leaderDef, res: leaderRes }),
|
||||
enemy_mini: enemyRec({ key: 'enemy_mini', hp: 1e9, speed: 0 }),
|
||||
enemy_part: enemyRec({ key: 'enemy_part', rank: 'ELITE', hp: 1e9, speed: 0 }),
|
||||
},
|
||||
},
|
||||
players: [{ playerId: 'P', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, chessId: 't_guard', row: 10, col: 2 }], bonds: {}, playerEffects: [] }],
|
||||
enemies: kind === 'boss' || kind === 'hidden'
|
||||
? [{ key: 'enemy_lead', pos: [3, 9], tag: 'boss' }, { key: 'enemy_mini', pos: [3, 12] }, { key: 'enemy_part', pos: [4, 12], tag: 'part' }]
|
||||
: [{ key: 'enemy_lead', pos: [10, 9], tag: 'boss' }, { key: 'enemy_mini', pos: [11, 9] }],
|
||||
content: 'none', autoFinish: false,
|
||||
});
|
||||
h.step();
|
||||
const leader = h.enemy('enemy_lead');
|
||||
const op = h.unit(1);
|
||||
assert.ok(leader && leader.isBoss, 'the leader is a boss unit');
|
||||
return { h, b: h.b, leader, mini: h.enemy('enemy_mini'), part: h.enemy('enemy_part'), op };
|
||||
}
|
||||
const capEvents = (h) => h.eventsOf('fx').filter((e) => e[1] === 'hitCap');
|
||||
|
||||
test('限伤: a leader\'s hit of 299999 lands, 300000 deals 0 — nothing credited to the shared pool, no number, a hitCap event', () => {
|
||||
const pool = new SharedBossPool(5e6);
|
||||
const { h, b, leader, op } = hitField({ pool });
|
||||
assert.equal(b.dealDamage(op, leader, { amount: LIMIT - 1, type: 'true' }), LIMIT - 1);
|
||||
assert.equal(pool.hp, 5e6 - (LIMIT - 1));
|
||||
assert.equal(pool.byPlayer.get('P'), LIMIT - 1);
|
||||
const dmg0 = h.eventsOf('dmg').length;
|
||||
const damaged0 = h.hooksOf('damaged').length;
|
||||
assert.equal(b.dealDamage(op, leader, { amount: LIMIT, type: 'true' }), 0, 'cancelled, not clamped');
|
||||
assert.equal(pool.hp, 5e6 - (LIMIT - 1), 'the pool is untouched');
|
||||
assert.equal(pool.byPlayer.get('P'), LIMIT - 1, 'nothing credited');
|
||||
assert.equal(b._pp('P').bossDamage, LIMIT - 1, 'no boss damage stat');
|
||||
assert.equal(op.stats.dmg, LIMIT - 1, 'no damage stat');
|
||||
assert.equal(h.eventsOf('dmg').length, dmg0, 'no damage number');
|
||||
assert.equal(h.hooksOf('damaged').length, damaged0, "no 'damaged' hook (nothing was dealt)");
|
||||
const ev = capEvents(h);
|
||||
assert.equal(ev.length, 1);
|
||||
assert.deepEqual([ev[0][4].id, ev[0][4].n], [leader.id, LIMIT]);
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 5e6, type: 'true' }), 0, 'a whole pool in one hit is cancelled too');
|
||||
assert.ok(leader.alive);
|
||||
assert.equal(pool.hp, 5e6 - (LIMIT - 1));
|
||||
});
|
||||
|
||||
test('限伤: the check is ceil(final damage) — 299999.2 rounds up to the line and is cancelled, 299999 exactly lands', () => {
|
||||
const { b, leader, op } = hitField();
|
||||
const hp0 = leader.hp;
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 299999.2, type: 'true' }), 0);
|
||||
assert.equal(leader.hp, hp0);
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 299999, type: 'true' }), 299999);
|
||||
});
|
||||
|
||||
test('限伤: the value checked is the hit after DEF / RES and every multiplier (before shields)', () => {
|
||||
const { b, leader, op } = hitField({ leaderDef: 100000, leaderRes: 50 });
|
||||
// phys: 399999 − 100000 DEF = 299999 → lands; 400000 − 100000 = 300000 → cancelled
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 399999, type: 'phys' }), 299999);
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 400000, type: 'phys' }), 0);
|
||||
// arts: 590000 × (1 − 50 %) = 295000 lands; 600000 × 50 % = 300000 cancelled
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 590000, type: 'arts' }), 295000);
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 600000, type: 'arts' }), 0);
|
||||
// a multiplier pushes a small hit past the line: 150000 true × 2 (脆弱 on the leader) = 300000 → 0
|
||||
b.addBuff(leader, { key: 'test:fragile', mods: { dmgTakenMul: 2 } });
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 149999, type: 'true' }), 299998);
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 150000, type: 'true' }), 0, 'bigger multipliers lower the damage of hits past the line');
|
||||
// the attacker's damage-dealt multiplier and dmg.mul count too
|
||||
b.removeBuff(leader, 'test:fragile');
|
||||
b.addBuff(op, { key: 'test:dealt', mods: { dmgDealtMul: 3 } });
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 100000, type: 'true' }), 0);
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 99999, type: 'true' }), 299997);
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 50000, type: 'true', mul: 2 }), 0, '50000 × 3 × 2');
|
||||
});
|
||||
|
||||
test('限伤: checked before shields — a cancelled hit leaves the leader\'s shield untouched; a smaller hit is absorbed as usual', () => {
|
||||
const { b, leader, op } = hitField();
|
||||
b.addBuff(leader, { key: 'test:shield', shield: 100000 });
|
||||
const hp0 = leader.hp;
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 350000, type: 'true' }), 0);
|
||||
assert.equal(leader.findBuff('test:shield').shield, 100000, 'shield untouched');
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 250000, type: 'true' }), 150000, '100000 absorbed, 150000 lands');
|
||||
assert.equal(leader.hp, hp0 - 150000);
|
||||
});
|
||||
|
||||
test('限伤: only leaders — a minion and a part in the same boss field take any hit', () => {
|
||||
const { b, mini, part, op } = hitField();
|
||||
assert.equal(b.dealDamage(op, mini, { amount: 1e6, type: 'true' }), 1e6);
|
||||
assert.equal(b.dealDamage(op, part, { amount: 2e6, type: 'phys' }), 2e6);
|
||||
assert.equal(part.isBoss, false);
|
||||
});
|
||||
|
||||
test('限伤: only boss / hidden battles — the same leader-tagged unit takes any hit in a normal or 联防 field', () => {
|
||||
for (const kind of ['normal', 'unite']) {
|
||||
const { b, leader, op } = hitField({ kind });
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 5e5, type: 'true' }), 5e5, kind);
|
||||
}
|
||||
const { b, leader, op } = hitField({ kind: 'hidden' });
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 5e5, type: 'true' }), 0, 'the Hidden Core is a boss battle');
|
||||
});
|
||||
|
||||
test('限伤: every HP-damage kind — 元素伤害 and element bursts, DoT ticks, losses passed on (loseHp); element 损伤 (gauge) is not HP damage', () => {
|
||||
const pool = new SharedBossPool(5e7);
|
||||
const { h, b, leader, op } = hitField({ pool });
|
||||
const hp = () => pool.hp;
|
||||
// 元素伤害
|
||||
let p0 = hp();
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 4e5, type: 'elemental', element: 'burn' }), 0);
|
||||
assert.equal(hp(), p0);
|
||||
// a burn burst whose 7000 元素伤害 is pushed past the line by 元素脆弱 ×100: the gauge bursts (its lock / RES cut stay),
|
||||
// the burst's hit is cancelled
|
||||
b.addBuff(leader, { key: 'test:elemFragile', mods: { elementalTakenMul: 100 } });
|
||||
p0 = hp();
|
||||
const caps0 = capEvents(h).length;
|
||||
b.dealDamage(op, leader, { amount: 1e7, type: 'element', element: 'burn' });
|
||||
assert.ok(leader.findBuff('burnBurst'), 'the gauge burst (爆发冷却 running)');
|
||||
assert.equal(hp(), p0, 'the burst\'s 700000 元素伤害 was cancelled');
|
||||
assert.equal(capEvents(h).length, caps0 + 1);
|
||||
b.removeBuff(leader, 'test:elemFragile');
|
||||
// DoT ticks: each tick is a hit of its own
|
||||
p0 = hp();
|
||||
b.addBuff(leader, { key: 'test:dotBig', duration: 3.01, interval: 1, onTick: () => b.dealDamage(op, leader, { amount: 4e5, type: 'true', canDodge: false }) });
|
||||
b.addBuff(leader, { key: 'test:dotSmall', duration: 3.01, interval: 1, onTick: () => b.dealDamage(op, leader, { amount: 1000, type: 'true', canDodge: false }) });
|
||||
h.run(3.2);
|
||||
assert.equal(p0 - hp(), 3000, 'only the small ticks landed');
|
||||
// a loss passed on to the leader (parts' 传递, a drone's death): one hit too
|
||||
p0 = hp();
|
||||
assert.equal(b.loseHp(leader, 3e5, { source: op }), 0);
|
||||
assert.equal(hp(), p0);
|
||||
assert.equal(b.loseHp(leader, 2e5, { source: op }), 2e5);
|
||||
assert.equal(hp(), p0 - 2e5);
|
||||
});
|
||||
|
||||
test('限伤: what ran before the hit stays — the attack\'s hit hook fires; nothing after (damaged, fatal, kill)', () => {
|
||||
const { h, b, leader, op } = hitField();
|
||||
const hit0 = h.hooksOf('hit').length;
|
||||
const dmg0 = h.hooksOf('damaged').length;
|
||||
b.dealDamage(op, leader, { amount: 1e6, type: 'phys', isAttack: true });
|
||||
assert.equal(h.hooksOf('hit').length, hit0 + 1, "the 'hit' hook (before mitigation) ran");
|
||||
assert.equal(h.hooksOf('damaged').length, dmg0);
|
||||
assert.equal(h.hooksOf('fatal').length, 0);
|
||||
assert.equal(h.hooksOf('kill').length, 0);
|
||||
assert.ok(leader.alive);
|
||||
});
|
||||
|
||||
test('限伤: a browser pool (client-side combat) behaves the same; the field stays alive and deterministic', () => {
|
||||
const pool = new LocalBossPool(1e6);
|
||||
const { b, leader, op } = hitField({ pool });
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 3e5, type: 'true' }), 0);
|
||||
assert.equal(pool.hp, 1e6);
|
||||
assert.equal(pool.cum, 0, 'nothing to report to the server');
|
||||
assert.equal(b.dealDamage(op, leader, { amount: 2e5, type: 'true' }), 2e5);
|
||||
assert.equal(pool.cum, 2e5);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// the real leaders (data/bosses.json enemyKey = activity_table autoChessData.bossInfoDict enemyId, the official
|
||||
// IsBossEnemy list) on real boss fields
|
||||
|
||||
test('IsBossEnemy ⇔ tag \'boss\': every tag-\'boss\' spawn of a boss / hidden template is a bosses.json enemyKey; parts and escorts never are', () => {
|
||||
const leaders = new Set(Object.values(DATA.bosses).map((x) => x.enemyKey));
|
||||
assert.equal(leaders.size, 10);
|
||||
const parts = new Set(Object.values(DATA.bosses).flatMap((x) => x.parts || []));
|
||||
for (const [id, t] of Object.entries(DATA.waves)) {
|
||||
if (!t || !Array.isArray(t.spawns) || (t.kind !== 'boss' && t.kind !== 'hidden')) continue;
|
||||
for (const s of t.spawns) {
|
||||
const key = s && (s.key ?? s.enemyKey);
|
||||
if (!key) continue;
|
||||
if (s.tag === 'boss') assert.ok(leaders.has(key), `${id}: ${key} tagged boss is a leader`);
|
||||
else assert.ok(!leaders.has(key), `${id}: ${key} is a leader but not tagged boss`);
|
||||
if (s.tag === 'part') assert.ok(parts.has(key) && !leaders.has(key), `${id}: part ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const gd = new GameData(DATA, 'mode_multi_hard');
|
||||
const setup = setupMatchWaves(gd, createRng(3));
|
||||
const PAIR = [
|
||||
{ playerId: 'p_0', seat: 0, side: 'L', colOffset: 0, units: [], bonds: {}, playerEffects: [] },
|
||||
{ playerId: 'p_1', seat: 1, side: 'R', colOffset: 8, units: [], bonds: {}, playerEffects: [] },
|
||||
];
|
||||
function realBossField(bossId, hidden, pool, players = PAIR) {
|
||||
const wave = buildBossWave(gd, createRng(1), setup.factions, hidden ? 15 : 14, { bossId, solo: false });
|
||||
const spec = buildBattleSpec({
|
||||
battleId: `lim.${bossId}`, fieldId: 'b1', kind: hidden ? 'hidden' : 'boss', seed: 5, modeId: 'mode_multi_hard', round: hidden ? 15 : 14,
|
||||
stageId: 'act2autochess_m01', rect: { ...GEO.BOSS_RECT }, timeLimit: null, players,
|
||||
spawns: wave.spawns.filter((s) => s && gd.enemy(s.enemyKey)), routes: wave.routes, flags: { layerGainsEnabled: false, ...gd.dp },
|
||||
enemyOverrides: wave.overrides, waveId: wave.templateId, bossId, boss: { poolHp: pool.maxHp, poolMax: pool.maxHp },
|
||||
});
|
||||
const b = createBattleFromSpec(spec, undefined, { sharedBoss: pool, recordEvents: true, quiet: true });
|
||||
const want = gd.boss(bossId).parts?.length ? 1 + gd.boss(bossId).parts.length : 1;
|
||||
for (let i = 0; i < 30 * 120 && b.enemies.filter((e) => e.alive && (e.isBoss || e.tag === 'part')).length < want; i++) b.step();
|
||||
return { b, spec };
|
||||
}
|
||||
|
||||
for (const [bossId, hidden] of [['boss_1', false], ['boss_5', false], ['boss_8', true], ['boss_9', true], ['boss_10', true]]) {
|
||||
test(`限伤 on a real field: ${bossId} — every copy of the leader cancels a 300000 hit and takes a 299999 one; its parts are no leaders`, () => {
|
||||
const pool = new SharedBossPool(gd.boss(bossId).bloodPoint.HARD);
|
||||
const { b } = realBossField(bossId, hidden, pool);
|
||||
const caps = [];
|
||||
const fx0 = b.fx.bind(b);
|
||||
b.fx = (k, p) => { if (k === 'hitCap') caps.push(p.id); return fx0(k, p); };
|
||||
const leaders = b.enemies.filter((e) => e.alive && e.isBoss);
|
||||
assert.ok(leaders.length >= 1, 'the leader spawned');
|
||||
for (const L of leaders) {
|
||||
assert.equal(L.defId, gd.boss(bossId).enemyKey);
|
||||
const before = pool.hp;
|
||||
assert.equal(b.dealDamage(null, L, { amount: LIMIT, type: 'true', ignoreSleep: true }), 0, `${L.defId} #${L.id}`);
|
||||
assert.equal(pool.hp, before);
|
||||
assert.ok(caps.includes(L.id), `${L.defId} #${L.id}: cancelled by the limit (not by an invulnerable phase)`);
|
||||
assert.equal(b.dealDamage(null, L, { amount: LIMIT - 1, type: 'true', ignoreSleep: true }), LIMIT - 1);
|
||||
assert.equal(pool.hp, before - (LIMIT - 1));
|
||||
}
|
||||
const parts = b.enemies.filter((e) => e.alive && e.tag === 'part');
|
||||
if (hidden && bossId !== 'boss_10') assert.ok(parts.length > 0, 'the parts spawned');
|
||||
for (const p of parts) {
|
||||
assert.equal(p.isBoss, false);
|
||||
assert.equal(leaderHitCancelled(b, p, 1e7), false, `part ${p.defId} is no leader`);
|
||||
}
|
||||
for (const m of b.enemies.filter((e) => e.alive && !e.isBoss && e.tag !== 'part')) assert.equal(leaderHitCancelled(b, m, 1e7), false, m.defId);
|
||||
});
|
||||
}
|
||||
|
||||
test('限伤 vs the real leader defences [ASSUMED order]: 阿利斯泰尔\'s 5000 vest barrier stays under a cancelled hit; a 再生 block acts first', () => {
|
||||
// boss_6: the kit's own 莫非王土 animates a shield equipment lying on the field → the `boss:vest` HP shield (5000)
|
||||
const pool6 = new SharedBossPool(gd.boss('boss_6').bloodPoint.HARD);
|
||||
const { b: b6 } = realBossField('boss_6', false, pool6);
|
||||
const lion = b6.enemies.find((e) => e.alive && e.isBoss);
|
||||
const spot = [Math.round(lion.y), Math.round(lion.x) + 1];
|
||||
assert.ok(b6.spawnEnemy('enemy_10029_vtshld', { pos: spot, route: { motion: 'FLY', start: spot, end: spot, steps: [{ t: 'wait', s: 99999 }] }, tag: 'part', countInTotal: false }));
|
||||
for (let i = 0; i < 30 * 10 && !lion.findBuff('boss:vest'); i++) b6.step();
|
||||
const vest = lion.findBuff('boss:vest');
|
||||
assert.ok(vest, 'the kit put on the vest');
|
||||
assert.equal(vest.shield, 5000, 'store.dynamic');
|
||||
const caps6 = [];
|
||||
const fx6 = b6.fx.bind(b6);
|
||||
b6.fx = (k, p) => { if (k === 'hitCap') caps6.push(p.id); return fx6(k, p); };
|
||||
const before = pool6.hp;
|
||||
assert.equal(b6.dealDamage(null, lion, { amount: 302000, type: 'true', ignoreSleep: true }), 0, 'within 5000 of the line: still cancelled');
|
||||
assert.equal(lion.findBuff('boss:vest')?.shield, 5000, 'the barrier is untouched (限伤 before shields)');
|
||||
assert.equal(pool6.hp, before);
|
||||
assert.deepEqual(caps6, [lion.id]);
|
||||
assert.equal(b6.dealDamage(null, lion, { amount: 299999, type: 'true', ignoreSleep: true }), 294999, 'under the line: 5000 absorbed, the rest lands');
|
||||
assert.equal(pool6.hp, before - 294999);
|
||||
assert.equal(lion.findBuff('boss:vest'), null, 'the barrier is spent');
|
||||
|
||||
// boss_10 (hidden): a 假想敌:再生 aura block (`hit` step) negates the hit before the limit sees it
|
||||
const pool10 = new SharedBossPool(gd.boss('boss_10').bloodPoint.HARD);
|
||||
const { b: b10 } = realBossField('boss_10', true, pool10);
|
||||
const pipe = b10.enemies.find((e) => e.alive && e.isBoss);
|
||||
abOf(b10, pipe).hitShield = 1;
|
||||
const caps10 = [];
|
||||
const fx10 = b10.fx.bind(b10);
|
||||
b10.fx = (k, p) => { if (k === 'hitCap') caps10.push(p.id); return fx10(k, p); };
|
||||
const before10 = pool10.hp;
|
||||
assert.equal(b10.dealDamage(null, pipe, { amount: 2e6, type: 'phys', ignoreSleep: true }), 0);
|
||||
assert.equal(abOf(b10, pipe).hitShield, 0, 'the block is spent');
|
||||
assert.deepEqual(caps10, [], 'the limit never saw the hit');
|
||||
assert.equal(b10.dealDamage(null, pipe, { amount: 2e6, type: 'phys', ignoreSleep: true }), 0);
|
||||
assert.deepEqual(caps10, [pipe.id], 'the next one is cancelled by the limit');
|
||||
assert.equal(pool10.hp, before10);
|
||||
});
|
||||
|
||||
test('限伤 vs 【死亡集群】 (real kit): a drone killed by an operator costs the leader 2 % of the pool max — a loss that lands up to a 14999950 pool and is cancelled above it (ceil)', () => {
|
||||
// boss_1 / boss_8 skill 2: bb hp_ratio 0.02; the leader's max HP is the shared pool's max (Battle syncs it), so the
|
||||
// loss is 0.02 × pool max. Today's largest drone pool is boss_8 ABYSS 7.2M → 144000; a pool above 14999950 (≈ 2.08 ×
|
||||
// that), where ceil(0.02 × max) reaches 300000, would make every drone kill a cancelled hit — kept in view for any
|
||||
// change to the pool size (research 11 §6).
|
||||
assert.equal(gd.enemy('enemy_9013_acstmk').skills.find((s) => s.prefabKey === '2').bb.hp_ratio, 0.02);
|
||||
assert.equal(gd.enemy('enemy_9013_acstmk_2').skills.find((s) => s.prefabKey === '2').bb.hp_ratio, 0.02);
|
||||
const withOp = [{ ...PAIR[0], units: [{ uid: 1, chessId: 'chess_char_1_01_a', row: 10, col: 2 }] }, PAIR[1]];
|
||||
const run = (poolMax) => {
|
||||
const pool = new SharedBossPool(poolMax);
|
||||
const { b } = realBossField('boss_1', false, pool, withOp);
|
||||
const L = b.enemies.find((e) => e.alive && e.isBoss);
|
||||
const key = gd.enemy(L.defId).skills.find((s) => s.prefabKey === '2').bbStr.enemy_key; // enemy_1005_yokai
|
||||
const caps = [];
|
||||
const fx0 = b.fx.bind(b);
|
||||
b.fx = (k, p) => { if (k === 'hitCap') caps.push([p.id, p.n]); return fx0(k, p); };
|
||||
for (let i = 0; i < 30 * 5 && !b.enemies.some((e) => e.alive && e.defId === key); i++) b.step();
|
||||
const drone = b.enemies.find((e) => e.alive && e.defId === key);
|
||||
assert.ok(drone, 'the kit summoned its drones (skill 2, initCooldown 0)');
|
||||
const op = b.units.find((u) => u.alive && u.side === 'ally');
|
||||
assert.ok(op, 'an operator to kill the drone');
|
||||
assert.equal(L.s.maxHp, poolMax, 'the leader\'s max HP is the pool max');
|
||||
const before = pool.hp;
|
||||
b.dealDamage(op, drone, { amount: 1e9, type: 'true', ignoreSleep: true });
|
||||
assert.equal(drone.alive, false, 'the drone died');
|
||||
return { lost: before - pool.hp, caps, id: L.id };
|
||||
};
|
||||
const today = run(7200000);
|
||||
assert.equal(today.lost, 144000, 'boss_8 ABYSS-sized pool: the loss lands');
|
||||
assert.deepEqual(today.caps, []);
|
||||
const under = run(14999950);
|
||||
assert.equal(under.lost, 299999, 'just under the line: lands');
|
||||
assert.deepEqual(under.caps, []);
|
||||
for (const m of [14999951, 15000000]) {
|
||||
const over = run(m);
|
||||
assert.equal(over.lost, 0, `${m}: ceil(0.02 × max) = 300000, one hit at the line: cancelled`);
|
||||
assert.deepEqual(over.caps.map((c) => c[0]), [over.id]);
|
||||
}
|
||||
});
|
||||
|
||||
test('限伤 keeps the sim deterministic: a boss field whose hits cross the line gives the same result digest twice', () => {
|
||||
const run = () => {
|
||||
const pool = new LocalBossPool(gd.boss('boss_1').bloodPoint.HARD);
|
||||
const { b } = realBossField('boss_1', false, pool);
|
||||
// a stand-in for a 999-layer board: every hit on the leader is ×40 (some land, the big ones are cancelled)
|
||||
b.on('hit', (c) => { if (c.target && c.target.isBoss) c.dmg.mul *= 40; });
|
||||
b.on('tick', () => {
|
||||
const L = b.enemies.find((e) => e.alive && e.isBoss);
|
||||
if (L && b.tickCount % 15 === 0) b.dealDamage(null, L, { amount: 2000 + (b.tickCount % 7) * 2500, type: 'true' });
|
||||
});
|
||||
let caps = 0;
|
||||
const fx0 = b.fx.bind(b);
|
||||
b.fx = (k, p) => { if (k === 'hitCap') caps++; return fx0(k, p); };
|
||||
for (let i = 0; i < 30 * 20; i++) b.step();
|
||||
return { d: resultDigest(b.result()), hp: pool.hp, caps };
|
||||
};
|
||||
const a = run(), c = run();
|
||||
assert.deepEqual(a.d, c.d);
|
||||
assert.equal(a.hp, c.hp);
|
||||
assert.ok(a.caps > 0, 'some hits were cancelled');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// the docs state the rules (kept here, next to the code, so they move together)
|
||||
|
||||
test('docs: research 11 is indexed; SIM / META / DATA / PLAYING / BALANCE state the 999 cap and the 300000 cancel', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const doc = (p) => readFileSync(new URL(`../../${p}`, import.meta.url), 'utf8');
|
||||
const R11 = doc('docs/research/11-limits-official.md');
|
||||
assert.match(R11, /^# 11 · Official limits/);
|
||||
assert.match(R11, /MAX_GARRISON_STACK/);
|
||||
assert.match(R11, /MAX_BATTLE_DAMAGE/);
|
||||
assert.match(R11, /IsBossEnemy/);
|
||||
assert.match(doc('docs/research/00-INDEX.md'), /`11-limits-official\.md`/);
|
||||
const SIM = doc('docs/SIM.md');
|
||||
assert.match(SIM, /限伤/);
|
||||
assert.match(SIM, /BOSS_HIT_LIMIT/);
|
||||
assert.match(SIM, /BOND_LAYER_CAP/);
|
||||
assert.match(SIM, /hitCap/);
|
||||
const META = doc('docs/META.md');
|
||||
assert.match(META, /BOND_LAYER_CAP/);
|
||||
assert.match(META, /BOSS_HIT_LIMIT/);
|
||||
assert.match(doc('docs/DATA.md'), /IsBossEnemy/);
|
||||
const PLAYING = doc('docs/PLAYING.md');
|
||||
assert.match(PLAYING, /999 层/);
|
||||
assert.match(PLAYING, /300000/);
|
||||
assert.match(doc('docs/BALANCE.md'), /限伤 300000/);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
// Real-server browser E2E of the user's playtest #6 follow-up: "官方就是合成精锐时,如果消耗了场上的干员,精锐会出现在场上那个
|
||||
// 位置" (PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置"). A solo prep (test/e2e/fastServer.mjs hooks
|
||||
// SP_START_ROUND 2 + SP_START_CHESS: two 古米 in the hand + SP_START_SHOP: a third 古米 in the shop) is driven with real
|
||||
// input: one copy goes onto the board through the direction wheel facing UP, the shop card is tapped once (armed: the
|
||||
// gold merge highlight lights that copy's tile) and again (bought) — the elite appears on that tile facing UP with the
|
||||
// promotion cue (render/app.js setPrep → fx.promote), the hand copy is gone, the deploy count is unchanged and the
|
||||
// promotion reward shows. Zero console errors.
|
||||
//
|
||||
// SP_E2E=1 node --test test/ui/playtest6-elite.e2e.test.js
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
|
||||
|
||||
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
|
||||
const GUMMY = 'chess_char_1_10_a';
|
||||
|
||||
async function soloToPrep(c) {
|
||||
await c.open();
|
||||
await c.enter('煌');
|
||||
await c.click('.mode-card', '独立模拟');
|
||||
await c.click('.diff-card', '险境');
|
||||
await c.click('.create-box button', '开始独立模拟');
|
||||
await c.waitFor((s) => !!s.room, 'solo room');
|
||||
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
|
||||
await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
|
||||
await c.click('.brief__foot .btn--primary', '准备就绪');
|
||||
await c.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
|
||||
await c.click('.dband', null, { nth: 1 });
|
||||
await c.click('.draft-detail__btns .btn--primary', '确认选择');
|
||||
await c.waitFor((x) => x.phase === 'PREP' && !x.ready, 'prep', 60000);
|
||||
await sleep(1800);
|
||||
}
|
||||
|
||||
const privOf = (c) => c.page.evaluate(() => globalThis.__SP__.store.get().match.private);
|
||||
|
||||
describe('user playtest #6 follow-up: a merge\'s elite takes the deployed copy\'s tile (real server, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome + assets) to run' }, () => {
|
||||
test('buying the third 古米 with one deployed: the elite stands on that tile, facing kept, with the promotion cue', { timeout: 240000 }, async () => {
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 4, startRound: 2, chess: [GUMMY, GUMMY], shop: [GUMMY] } });
|
||||
const c = new Client(puppeteer, srv.base, 'elite', { prefix: 'pt6' });
|
||||
try {
|
||||
await soloToPrep(c);
|
||||
await c.hookRequests();
|
||||
const hand0 = (await c.handPieces('chess')).filter((p) => p.id === GUMMY);
|
||||
assert.equal(hand0.length, 2, 'two 古米 in the starter kit');
|
||||
// one copy onto the board, facing UP (drag + direction wheel)
|
||||
const tile = await c.freeTileFor(hand0[0].uid);
|
||||
const from = await c.piecePoint(hand0[0].uid);
|
||||
const to = await c.tilePoint(tile.row, tile.col);
|
||||
assert.ok(from && to, 'piece and tile on screen');
|
||||
await c.drag(from, to);
|
||||
await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
|
||||
await c.swipe('UP');
|
||||
await sleep(500);
|
||||
const placed = (await privOf(c)).board.find((p) => p.uid === hand0[0].uid);
|
||||
assert.ok(placed && placed.row === tile.row && placed.col === tile.col && placed.dir === 'UP', `placed facing UP (${JSON.stringify(placed)})`);
|
||||
const count0 = (await c.st()).deployCount;
|
||||
const promos0 = await c.page.evaluate(() => globalThis.__SP_VIEW__?.raw?.debug?.promotions?.length ?? 0);
|
||||
// first tap: armed — the card says 可晋升 and the gold highlight lights the copy's tile
|
||||
const name = await c.page.evaluate((id) => globalThis.__SP__.data.lookup('chess', id)?.name, GUMMY);
|
||||
await c.click('.shopbar__cards .scard', name);
|
||||
await sleep(400);
|
||||
const armed = await c.page.evaluate(() => {
|
||||
const card = document.querySelector('.shopbar__cards .scard.is-armed');
|
||||
const hl = globalThis.__SP_VIEW__?.raw?.debug?.tiles?.highlights?.get('mergeTile');
|
||||
const hint = document.querySelector('.dpanel .dhint--merge');
|
||||
return { merge: !!card?.querySelector('.scard__mergetag'), title: card?.querySelector('.scard__mergetag')?.title || null, tiles: hl ? hl.tiles : null,
|
||||
hint: hint ? hint.textContent.trim() : null, hintVisible: !!(hint && hint.getBoundingClientRect().height > 0) };
|
||||
});
|
||||
assert.ok(armed.merge, 'the armed card is a merge (可晋升)');
|
||||
assert.equal(armed.title, '精锐干员将出现在作战区原位置');
|
||||
// touch never shows a title: the detail card the first tap opened carries the same line (QA 6b)
|
||||
assert.equal(armed.hint, '可晋升:精锐干员将出现在作战区原位置');
|
||||
assert.ok(armed.hintVisible, 'the line is laid out in the detail card');
|
||||
assert.deepEqual(armed.tiles, [[tile.row, tile.col]], 'the merge highlight lights the deployed copy\'s tile');
|
||||
await c.shot('elite-armed');
|
||||
// second tap: bought
|
||||
await c.click('.shopbar__cards .scard.is-armed');
|
||||
await c.page.waitForFunction((id) => (globalThis.__SP__.store.get().match.private?.board || []).some((p) => p.golden && p.id === id), { timeout: 8000 }, `${GUMMY.slice(0, -2)}_b`);
|
||||
const priv = await privOf(c);
|
||||
const elite = priv.board.find((p) => p.golden);
|
||||
assert.deepEqual([elite.row, elite.col, elite.dir], [tile.row, tile.col, 'UP'], 'the elite on the copy\'s tile, facing kept');
|
||||
assert.ok(!priv.hand.some((p) => p && p.kind === 'chess'), 'the hand copy was consumed');
|
||||
assert.equal((await c.st()).deployCount, count0, 'the deploy count is unchanged');
|
||||
assert.ok(priv.shop.rewardOffer, 'the promotion reward is offered');
|
||||
assert.ok((await c.requests('g.buy')).length === 1, 'one g.buy');
|
||||
await sleep(300);
|
||||
const promos = await c.page.evaluate(() => (globalThis.__SP_VIEW__?.raw?.debug?.promotions || []).slice());
|
||||
assert.equal(promos.length, promos0 + 1, 'one promotion cue');
|
||||
assert.deepEqual([promos.at(-1).area, promos.at(-1).row, promos.at(-1).col, promos.at(-1).uid], ['board', tile.row, tile.col, elite.uid], 'played on the elite\'s tile');
|
||||
const hlAfter = await c.page.evaluate(() => globalThis.__SP_VIEW__?.raw?.debug?.tiles?.highlights?.get('mergeTile') || null);
|
||||
assert.equal(hlAfter, null, 'the merge highlight is gone with the purchase');
|
||||
// the elite's model stands on the tile (a prep view keyed by its uid)
|
||||
const view = await c.page.evaluate((uid) => { const v = globalThis.__SP_VIEW__?.raw?.debug?.views?.get('p:' + uid); return v ? { golden: !!v.info?.golden, x: v._home?.x, y: v._home?.y } : null; }, elite.uid);
|
||||
assert.ok(view && view.golden, `the elite's view exists (${JSON.stringify(view)})`);
|
||||
await c.shot('elite-on-board');
|
||||
assert.deepEqual(problemsOf([c]), []);
|
||||
} finally {
|
||||
await c.close();
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
// User playtest #6 follow-up, client side of "合成精锐时,如果消耗了场上的干员,精锐会出现在场上那个位置" (PRTS 卫戍协议/帮助
|
||||
// "若消耗已部署至作战区的干员,则发送至作战区对应位置"; server PlayerState._mergeChess / board.js mergeTile):
|
||||
// * gameLogic.mergeTarget mirrors the server: the tile of the deployed copy that deploys first (row desc, col asc)
|
||||
// when gaining one more copy completes a merge, null when it completes none or no copy is deployed (the hand)
|
||||
// * the shop card's 可晋升 tag says where the elite goes; ShopBar reports the armed card (armedSlotOf → onArm) so the
|
||||
// game screen lights that tile (screens/game.js MERGE_HL)
|
||||
// * the server and client agree on the target for real merges (the engine's elite lands on mergeTarget's tile)
|
||||
// * an elite card never merges (server completesChessMerge): mergeProgress reports 0 copies, so no pips / tag / tile
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
const name = String(url).split('/').pop();
|
||||
try {
|
||||
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
|
||||
return { ok: true, status: 200, json: async () => JSON.parse(body) };
|
||||
} catch {
|
||||
return { ok: false, status: 404, json: async () => ({}) };
|
||||
}
|
||||
};
|
||||
|
||||
const { mergeTarget, mergeProgress } = await import('../../public/js/ui/gameLogic.js');
|
||||
const { ChessCard, armedSlotOf, armKey, mergeHint } = await import('../../public/js/ui/shopBar.js');
|
||||
const { ChessDetail, resolveDetail } = await import('../../public/js/ui/detailPanel.js');
|
||||
const { data } = await import('../../public/js/data.js');
|
||||
const { DATA, makeMatch, give } = await import('../match/harness.js');
|
||||
|
||||
await data.loadAll('chess', 'bonds', 'assets');
|
||||
|
||||
const getChess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
|
||||
const A = 'chess_char_1_01_a';
|
||||
const KAZE = 'chess_char_2_11_a';
|
||||
const golden = (id) => DATA.chess[id].goldenId;
|
||||
const onBoard = (id, row, col, dir = 'RIGHT', uid = row * 100 + col) => ({ uid, kind: 'chess', id, golden: false, row, col, dir });
|
||||
const inHand = (id, uid) => ({ uid, kind: 'chess', id, golden: false });
|
||||
|
||||
function* walk(v) {
|
||||
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
|
||||
if (!v || typeof v !== 'object') return;
|
||||
yield v;
|
||||
yield* walk(v.props?.children);
|
||||
}
|
||||
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
|
||||
|
||||
describe('gameLogic.mergeTarget (mirror of server board.js mergeTile)', () => {
|
||||
test('no merge yet / hand copies only → null; a deployed copy → its tile and facing', () => {
|
||||
assert.equal(mergeTarget({ board: [], hand: [inHand(A, 1)] }, A, getChess), null, 'one copy: no merge');
|
||||
assert.equal(mergeTarget({ board: [], hand: [inHand(A, 1), inHand(A, 2)] }, A, getChess), null, 'hand copies: the elite goes to the hand');
|
||||
assert.deepEqual(mergeTarget({ board: [onBoard(A, 10, 4, 'UP')], hand: [inHand(A, 2)] }, A, getChess), { row: 10, col: 4, dir: 'UP' });
|
||||
assert.deepEqual(mergeTarget({ board: [onBoard(A, 10, 4)], temp: [inHand(A, 2)] }, A, getChess), { row: 10, col: 4, dir: 'RIGHT' }, 'temp copies count');
|
||||
});
|
||||
test('two deployed copies → the one that deploys first: row desc, then col asc', () => {
|
||||
const priv = { board: [onBoard(A, 9, 2, 'DOWN'), onBoard(A, 11, 7, 'LEFT')], hand: [] };
|
||||
assert.deepEqual(mergeTarget(priv, A, getChess), { row: 11, col: 7, dir: 'LEFT' });
|
||||
const same = { board: [onBoard(A, 10, 8, 'UP'), onBoard(A, 10, 3, 'DOWN')], hand: [] };
|
||||
assert.deepEqual(mergeTarget(same, A, getChess), { row: 10, col: 3, dir: 'DOWN' });
|
||||
});
|
||||
test('elites and other operators are ignored; 风丸 merges with one deployed copy', () => {
|
||||
const priv = { board: [{ ...onBoard(golden(A), 12, 2), golden: true }, onBoard('chess_char_1_02_a', 12, 3), onBoard(A, 9, 9)], hand: [inHand(A, 5)] };
|
||||
assert.deepEqual(mergeTarget(priv, A, getChess), { row: 9, col: 9, dir: 'RIGHT' });
|
||||
assert.equal(mergeProgress({ board: [onBoard(KAZE, 10, 5)] }, KAZE, getChess).need, 2);
|
||||
assert.deepEqual(mergeTarget({ board: [onBoard(KAZE, 10, 5, 'UP')] }, KAZE, getChess), { row: 10, col: 5, dir: 'UP' });
|
||||
assert.equal(mergeTarget({ board: [onBoard(A, 10, 5)] }, A, getChess), null, 'two of three: no merge yet');
|
||||
});
|
||||
test('the engine agrees: a real merge puts the elite on mergeTarget\'s tile', () => {
|
||||
for (const seed of [61, 62, 63]) {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed }).start();
|
||||
h.toPrep(1);
|
||||
h.setStage('act2autochess_m04');
|
||||
const m = h.m;
|
||||
const ps = h.ps('p_0');
|
||||
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
ps.recompute();
|
||||
const id = Object.values(DATA.chess).find((c) => !c.isGolden && c.tier === 1 && c.position === 'MELEE' && m.pool.has(c.chessId) && m.gd.mergeCount(c.chessId) === 3 && !m.gd.placeableTokens(c.chessId).length).chessId;
|
||||
const tiles = [];
|
||||
const map = ps.deployMap();
|
||||
for (const [k, cls] of map) if (cls === 'melee') tiles.push(k.split(',').map(Number));
|
||||
// two copies on two seed-dependent melee tiles
|
||||
const pick = [tiles[seed % tiles.length], tiles[(seed * 7 + 3) % tiles.length]];
|
||||
if (pick[0].join() === pick[1].join()) pick[1] = tiles[(seed + 1) % tiles.length];
|
||||
give(m, ps, id, 'board', pick[0]).dir = 'UP';
|
||||
give(m, ps, id, 'board', pick[1]).dir = 'DOWN';
|
||||
const want = mergeTarget(ps.privateView(), id, getChess);
|
||||
ps.funds = 10;
|
||||
ps.shop.slots[0] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), frozen: false, sold: false };
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
|
||||
const view = ps.privateView().board.find((v) => v.golden);
|
||||
assert.deepEqual([view.row, view.col, view.dir], [want.row, want.col, want.dir], `seed ${seed}: server and client agree`);
|
||||
m.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('shop bar: the merge tag and the armed card', () => {
|
||||
test('the 可晋升 tag says where the elite goes (the board copy\'s place / the hand)', () => {
|
||||
const slot = { kind: 'chess', id: A, price: 1, basePrice: 1 };
|
||||
const tagOf = (priv) => [...walk(ChessCard({ slot, idx: 0, priv, onBuy() {}, onDetail() {} }))].find((v) => hasClass(v, 'scard__mergetag'));
|
||||
assert.equal(tagOf({ board: [onBoard(A, 10, 4)], hand: [inHand(A, 2)] })?.props.title, '精锐干员将出现在作战区原位置');
|
||||
assert.equal(tagOf({ board: [], hand: [inHand(A, 1), inHand(A, 2)] })?.props.title, '精锐干员将进入整备区');
|
||||
assert.equal(tagOf({ board: [], hand: [inHand(A, 1)] }), undefined, 'no tag before the merge');
|
||||
});
|
||||
test('touch shows no title: the detail card the first tap opens says where the elite goes (QA 6b)', async () => {
|
||||
await data.loadAll('chess', 'garrisons', 'assets', 'bonds', 'items');
|
||||
const board = { board: [onBoard(A, 10, 4)], hand: [inHand(A, 2)] };
|
||||
const hand = { board: [], hand: [inHand(A, 1), inHand(A, 2)] };
|
||||
assert.equal(mergeHint(board, A), '精锐干员将出现在作战区原位置');
|
||||
assert.equal(mergeHint(hand, A), '精锐干员将进入整备区');
|
||||
assert.equal(mergeHint({ board: [], hand: [inHand(A, 1)] }, A), null, 'no merge yet');
|
||||
assert.equal(mergeHint(board, golden(A)), null, 'an elite card never merges');
|
||||
// the first tap: ShopBar tapCard → onDetail(id, 'chess', mergeHint) → game.js setDetail({ …, hint }) → resolveDetail
|
||||
assert.match(read('public/js/ui/shopBar.js'), /setArmed\(key\); onDetail\(slot\.id, detailKind, detailKind === 'chess' \? mergeHint\(priv, slot\.id\) : null\)/);
|
||||
assert.match(read('public/js/screens/game.js'), /onDetail=\$\{\(id, kind, hint\) => setDetail\(\{ kind: kind === 'item' \? 'item' : 'chess', id, hint: hint \|\| null \}\)\}/);
|
||||
const r = resolveDetail({ kind: 'chess', id: A, hint: mergeHint(board, A) }, new Map());
|
||||
assert.equal(r.hint, '精锐干员将出现在作战区原位置');
|
||||
const blocks = ChessDetail({ chess: r.chess, piece: null, editable: false, bonds: [], loadout: null, hint: r.hint });
|
||||
assert.equal(blocks[0].key, 'head');
|
||||
assert.equal(blocks[1].key, 'merge', 'right under the header');
|
||||
assert.ok(hasClass(blocks[1], 'dhint'));
|
||||
const text = JSON.stringify(blocks[1].props.children);
|
||||
assert.ok(text.includes('可晋升:') && text.includes('精锐干员将出现在作战区原位置'), text);
|
||||
assert.ok(!ChessDetail({ chess: r.chess, piece: null, editable: false, bonds: [], loadout: null }).some((b) => b.key === 'merge'), 'no hint, no line');
|
||||
});
|
||||
test('an elite card never merges (server completesChessMerge refuses isGolden): no pips, no tag, no target tile', () => {
|
||||
const E = golden(A);
|
||||
const priv = { board: [onBoard(A, 10, 4, 'UP')], hand: [inHand(A, 2)] };
|
||||
assert.equal(mergeProgress(priv, A, getChess).copies, 2);
|
||||
assert.equal(mergeProgress(priv, E, getChess).copies, 0);
|
||||
assert.equal(mergeTarget(priv, E, getChess), null);
|
||||
const card = ChessCard({ slot: { kind: 'chess', id: E, price: 3, basePrice: 3 }, idx: 0, priv, onBuy() {}, onDetail() {} });
|
||||
const nodes = [...walk(card)];
|
||||
assert.equal(nodes.find((v) => hasClass(v, 'scard__mergetag')), undefined, 'no 可晋升 tag');
|
||||
assert.equal(nodes.find((v) => hasClass(v, 'scard__pips')), undefined, 'no 已拥有 pips');
|
||||
assert.ok(!hasClass(card, 'is-merge'));
|
||||
// the normal card in the same state still merges onto the deployed copy's tile
|
||||
assert.deepEqual(mergeTarget(priv, A, getChess), { row: 10, col: 4, dir: 'UP' });
|
||||
// and the server agrees: gaining the elite completes no merge
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 64 }).start();
|
||||
h.toPrep(1);
|
||||
const ps = h.ps('p_0');
|
||||
const id = Object.values(DATA.chess).find((c) => !c.isGolden && c.tier === 1 && h.m.pool.has(c.chessId) && h.m.gd.mergeCount(c.chessId) === 3).chessId;
|
||||
give(h.m, ps, id);
|
||||
give(h.m, ps, id);
|
||||
assert.equal(ps.completesChessMerge(id), true);
|
||||
assert.equal(ps.completesChessMerge(golden(id)), false);
|
||||
h.m.dispose();
|
||||
});
|
||||
test('armedSlotOf: shop / reward slots by key; level card, stale and sold keys → null', () => {
|
||||
const slots = [{ kind: 'chess', id: A }, { kind: 'chess', id: KAZE, sold: true }, { kind: 'item', id: 'chess_item_1_01_e_a' }];
|
||||
const reward = [{ kind: 'chess', id: 'chess_char_1_02_a' }];
|
||||
assert.equal(armedSlotOf(armKey('c', 0, slots[0]), slots), slots[0]);
|
||||
assert.equal(armedSlotOf(armKey('i', 2, slots[2]), slots), slots[2]);
|
||||
assert.equal(armedSlotOf(armKey('r', 0, reward[0]), slots, reward), reward[0]);
|
||||
assert.equal(armedSlotOf(armKey('c', 1, slots[1]), slots), null, 'sold');
|
||||
assert.equal(armedSlotOf('c:0:other', slots), null, 'rerolled');
|
||||
assert.equal(armedSlotOf('lv', slots), null);
|
||||
assert.equal(armedSlotOf(null, slots), null);
|
||||
});
|
||||
test('the game screen lights the target tile while a merge-completing card is armed', () => {
|
||||
const src = read('public/js/screens/game.js');
|
||||
assert.match(src, /onArm=\$\{setArmedCard\}/, 'ShopBar reports the armed card');
|
||||
assert.match(src, /mergeTarget\(priv, armedCard\.id, gd\.chess\)/, 'the target comes from gameLogic.mergeTarget');
|
||||
assert.match(src, /view\.highlightTiles\(mergeAt \? \[\[mergeAt\.row, mergeAt\.col\]\] : \[\], MERGE_HL\)/, 'its own highlight group (cleared with [])');
|
||||
assert.match(src, /const MERGE_HL = Object\.freeze\(\{ group: 'mergeTile'/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user