- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player; placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call) - #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿 - #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text - #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool, and the team LP reaching 0 first is a defeat (PRTS) - #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones - #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10) - #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range - #9 official per-enemy model scales (威龙) - #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding - #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider - #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes - #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20 - #18 炎佑 is 孤立: no heals or ally buffs - #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators - DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites green; dev recordings regenerated
151 lines
8.5 KiB
JavaScript
151 lines
8.5 KiB
JavaScript
// User playtest #6 item 5 — "图中的boss没有血量了但是不死,还在战斗,直到超时我们的生命被扣完才结束,但是最后的判定仍是胜利"
|
||
// (绝境 co-op Final Assault on act2autochess_m01, client-side combat: the HUD read 0.0 %, the leader kept fighting until
|
||
// the overtime drain took the team LP, and the result still said 胜利).
|
||
//
|
||
// Cause (reproduced in real matches, test/match/playtest6-bosspool.test.js): the shared pool's float arithmetic. The
|
||
// server subtracts each field's reported damage per player (Match._creditBoss); the pieces add up to the report only
|
||
// in exact arithmetic, so the server pool can keep float dust (e.g. 40000 − 12345.6 − 7654.3 − 20000.1 = 3.6e-12). The
|
||
// browser's LocalBossPool shows `server hp − (cum − acked)`: with 3.6e-12 left, every hit adds 3.6e-12 to a cumulative
|
||
// counter of 40000 — less than half its ulp, so `cum` never changes, the pool never reaches 0, the leader never dies
|
||
// and nothing more is reported. The same absorption stalls a killing hit whose `cum + rest` rounds down.
|
||
//
|
||
// Rule now (server/sim/constants.js BOSS_POOL_MIN_HP): a shared pool holding less than 1 HP is empty — the hit that
|
||
// would leave less takes the rest (SharedBossPool, LocalBossPool), and a pool reading below 1 HP is 0 (LocalBossPool),
|
||
// so a leader whose pool shows 0 is dead on every field at once and the fight ends.
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { LocalBossPool, buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
|
||
import { SharedBossPool, CreditPool } from '../../server/match/finalAssault.js';
|
||
import * as SIM_CONST from '../../server/sim/constants.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';
|
||
|
||
const { BOSS_POOL_MIN_HP } = SIM_CONST;
|
||
const DATA = getData({ log: { warn() {}, error() {}, info() {} } });
|
||
/** The stalled state a real 2-human 绝境 match reached (boss_2 at 5 % pool, seed 7): server hp, acked = local cum. */
|
||
const STALL = Object.freeze({ max: 40000, serverHp: 3.637978807091713e-12, cum: 40000 });
|
||
|
||
test('BOSS_POOL_MIN_HP is 1 (HP counts in whole points on every display)', () => {
|
||
assert.equal(BOSS_POOL_MIN_HP, 1);
|
||
});
|
||
|
||
test('SharedBossPool: per-player credits that add up to the pool leave no float dust; a hit leaving < 1 HP takes the rest', () => {
|
||
const p = new SharedBossPool(40000);
|
||
assert.equal(p.damage('p_0', 12345.6), 12345.6);
|
||
assert.equal(p.damage('p_1', 7654.3), 7654.3);
|
||
p.damage('p_0', 20000.1); // 40000 − 12345.6 − 7654.3 − 20000.1 = 3.637978807091713e-12 in doubles
|
||
assert.equal(p.hp, 0, 'empty, not 3.6e-12');
|
||
assert.equal(p.damage('p_1', 5), 0, 'nothing more to deal');
|
||
const q = new SharedBossPool(1000);
|
||
q.damage('p_0', 998.7);
|
||
assert.ok(Math.abs(q.hp - 1.3) < 1e-9, '1.3 HP is a living leader');
|
||
assert.ok(Math.abs(q.damage('p_1', 0.5) - 1.3) < 1e-9, 'the hit that would leave 0.8 HP takes the rest (credited to it)');
|
||
assert.equal(q.hp, 0);
|
||
assert.ok(Math.abs(q.byPlayer.get('p_1') - 1.3) < 1e-9);
|
||
// a takeover's crediting wrapper goes through the same rule
|
||
const r = new SharedBossPool(500);
|
||
r.damage('p_0', 100); // what the client reported before the server took its field over
|
||
const cp = new CreditPool(r, { acked: 100 });
|
||
cp.damage('p_0', 100); // re-simulated damage the client already reported: nothing new
|
||
assert.equal(r.hp, 400);
|
||
cp.damage('p_0', 399.5);
|
||
assert.equal(r.hp, 0, '0.5 HP left is empty');
|
||
});
|
||
|
||
test('LocalBossPool: the stalled state of the real match reads 0 HP (dead), not float dust', () => {
|
||
const pool = new LocalBossPool(STALL.max);
|
||
pool.cum = STALL.cum;
|
||
pool.byPlayer = { p_0: 25000, p_1: 15000 };
|
||
pool.sync(STALL.serverHp, STALL.cum);
|
||
assert.equal(pool.hp, 0, 'less than 1 HP left = empty');
|
||
assert.equal(pool.damage('p_0', 5000), 0);
|
||
assert.equal(pool.cum, STALL.cum);
|
||
});
|
||
|
||
test('LocalBossPool: a killing hit empties the pool whatever the float alignment of server hp and local damage', () => {
|
||
let stuck = 0;
|
||
for (let k = 0; k < 400; k++) {
|
||
const p = new LocalBossPool(1800000);
|
||
p.damage('p_0', 1799000.123 + k * 0.0137);
|
||
const A = p.cum;
|
||
// a server remnant whose granularity differs from the local counter's (the other field / per-player crediting)
|
||
p.sync(1800000 - A + (k % 7) * 1.1e-13, A);
|
||
for (let hits = 0; p.hp > 0 && hits < 50; hits++) p.damage('p_1', 5000);
|
||
if (p.hp !== 0) stuck++;
|
||
}
|
||
assert.equal(stuck, 0, 'no pool stays alive on float dust');
|
||
// below 1 HP reads 0; the rest of a normal pool is untouched
|
||
const p = new LocalBossPool(100);
|
||
p.damage('p_0', 99.2);
|
||
assert.equal(p.hp, 0, '0.8 HP left reads 0');
|
||
const q = new LocalBossPool(100);
|
||
q.damage('p_0', 98.5);
|
||
assert.ok(Math.abs(q.hp - 1.5) < 1e-9);
|
||
assert.equal(q.damage('p_0', 0.7), 1.5, 'the hit that would leave 0.8 HP takes the rest');
|
||
assert.equal(q.hp, 0);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// every leader, on a real boss field (co-op pair template, 绝境)
|
||
|
||
const gd = new GameData(DATA, 'mode_multi_hard');
|
||
const setup = setupMatchWaves(gd, createRng(3));
|
||
const PLAYERS = [
|
||
{ playerId: 'p_0', seat: 0, side: 'L', colOffset: 0, units: [], bonds: {}, playerEffects: [] },
|
||
{ playerId: 'p_1', seat: 1, side: 'R', colOffset: 8, units: [], bonds: {}, playerEffects: [] },
|
||
];
|
||
const BOSSES = ['boss_1', 'boss_2', 'boss_3', 'boss_4', 'boss_5', 'boss_6', 'boss_7', 'boss_8', 'boss_9', 'boss_10'];
|
||
|
||
function bossBattle(bossId, sharedBoss) {
|
||
const wave = buildBossWave(gd, createRng(1), setup.factions, 14, { bossId, solo: false });
|
||
const max = sharedBoss ? sharedBoss.maxHp : gd.boss(bossId).bloodPoint.HARD;
|
||
const spec = buildBattleSpec({
|
||
battleId: `t.${bossId}`, fieldId: 'b1', kind: 'boss', seed: 11, modeId: 'mode_multi_hard', round: 14, stageId: 'act2autochess_m01',
|
||
rect: { ...GEO.BOSS_RECT }, timeLimit: null, players: 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: max, poolMax: max },
|
||
});
|
||
const b = createBattleFromSpec(spec, undefined, { sharedBoss, recordEvents: false, quiet: true });
|
||
for (let i = 0; i < 30 * 90 && !b.enemies.some((e) => e.alive && e.isBoss); i++) b.step();
|
||
const leaders = b.enemies.filter((e) => e.alive && e.isBoss);
|
||
assert.ok(leaders.length > 0, `${bossId}: the leader spawned`);
|
||
return { b, leaders, spec };
|
||
}
|
||
|
||
for (const bossId of BOSSES) {
|
||
test(`${bossId}: client field — a pool left with float dust means a dead leader at once and a 'cleared' field`, () => {
|
||
const { b, spec } = bossBattle(bossId, null);
|
||
const pool = b.sharedBoss;
|
||
assert.ok(pool instanceof LocalBossPool, 'the browser pool');
|
||
// the real stalled state: everything this field dealt is acknowledged, the server holds 3.6e-12
|
||
pool.cum = spec.boss.poolMax;
|
||
pool.byPlayer = { p_0: spec.boss.poolMax };
|
||
pool.sync(STALL.serverHp, pool.cum);
|
||
const leader = b.enemies.find((e) => e.alive && e.isBoss);
|
||
b.dealDamage(null, leader, { amount: 5000, type: 'true' }); // an operator's hit
|
||
for (let i = 0; i < 3 && !b.finished; i++) b.step();
|
||
assert.equal(b.finished, true, `${bossId}: the field ended`);
|
||
assert.equal(b.reason, 'cleared');
|
||
assert.deepEqual(b.enemies.filter((e) => e.alive && e.isBoss).map((e) => e.defId), [], 'every copy of the leader is down');
|
||
assert.equal(b.snapshot().boss.hp, 0);
|
||
});
|
||
|
||
test(`${bossId}: server field — the hit that would leave less than 1 HP kills the leader at once`, () => {
|
||
const max = gd.boss(bossId).bloodPoint.HARD;
|
||
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})`);
|
||
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);
|
||
assert.equal(b.reason, 'cleared');
|
||
assert.deepEqual(b.enemies.filter((e) => e.alive && e.isBoss).map((e) => e.defId), []);
|
||
});
|
||
}
|