Files
ark-stronghold-protocol/test/sim/playtest6_bosspool.test.js
T
sganggs 6464e4b46a Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar
- #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
2026-10-01 19:08:23 +08:00

151 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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), []);
});
}