Files
ark-stronghold-protocol/test/match/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

156 lines
8.9 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没有血量了但是不死,还在战斗,直到超时我们的生命被扣完才结束,但是最后的判定仍是胜利".
// The user's match: 2 humans (the AI teammates eliminated), 绝境, act2autochess_m01, client-side combat, one pair
// field; the HUD read 0.0 %, the leader kept fighting, the overtime drain took the team LP and the result said 胜利.
//
// Reproduced with real matches (real sim, SimClients as the browsers): on v2.4.1 about one boss fight in three stalled.
// The server pool kept float dust from crediting a report per player (40000 − 12345.6 − 7654.3 − 20000.1 = 3.6e-12);
// the authority's LocalBossPool showed exactly that and every hit added 3.6e-12 to a cumulative counter of 40000 —
// absorbed, so its pool never reached 0 and it never reported anything new. The fight ran on until the team LP was 0;
// the verdict then came from the pool after the final b.result, whose per-player damage is rounded: rounding up
// emptied the dust → 胜利 (what the user saw), rounding down → 失败.
//
// Now (sim/constants.js BOSS_POOL_MIN_HP, finalAssault.js SharedBossPool, sim/spec.js LocalBossPool, Match.js): a pool
// never holds less than 1 HP (the hit that would leave less takes the rest; a browser pool reading below 1 is 0), so
// the leader dies on every field as soon as the pool shows 0 and the fight ends; the first end condition the server
// registers decides the verdict (pool 0 → victory, team LP 0 → defeat: PRTS 卫戍协议:盟约 下半 "…使目标生命值扣除至0,
// 则无视倒计时直接失败"), and reports arriving after the team LP ran out credit nothing.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { makeMatch } from './harness.js';
/** A real client-combat match (real sim) of 2 humans to the Final Assault on the user's stage and difficulty. */
function realFinalAssault({ bossId, seed, clientCombat = true }) {
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, bots: 0, seed, captureFrames: false, clientCombat, pace: 'paced' });
h.setStage('act2autochess_m01');
const m = h.m;
m.bossId = bossId;
// the autoplay lineups cannot clear a full 绝境 pool: 5 % of it (the tuning knob GameData.bossHpMul) — the pool's
// arithmetic, not its size, stalled the fight
m.gd.bossHpMul = () => 0.05;
h.autoHumans();
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 = 300; }
return h.ended != null || m.phase === PHASE.FINAL_ASSAULT;
}, { maxSteps: 8e6 });
assert.equal(m.phase, PHASE.FINAL_ASSAULT, `${bossId} seed ${seed}: reached the Final Assault`);
return h;
}
// (boss, seed) pairs whose fight stalled on v2.4.1: pool < 1 HP within 30 s, then ~11 minutes of overtime drain;
// boss_3 / 11 and boss_10 / 12 ended as a 胜利 after the team LP ran out (the user's report), the others as a 失败
const STALLED = [['boss_1', 12], ['boss_2', 12], ['boss_3', 11], ['boss_10', 12]];
for (const [bossId, seed] of STALLED) {
test(`#5 real match (client-side combat): ${bossId} seed ${seed} — the leader dies as soon as the shared pool is empty, victory, no overtime`, () => {
const h = realFinalAssault({ bossId, seed });
const m = h.m;
const f = m.fields[0];
assert.equal(m.fields.length, 1, 'one pair field');
assert.deepEqual([f.mode, f.authority], ['client', 'p_0']);
const t0 = h.sched.now();
const lp0 = m.teamLp;
const pools = [];
let emptyAt = null, endAt = null;
h.onBroadcast.push((msg) => {
if (msg.t !== 'b.pool') return;
pools.push(msg.hp);
if (msg.hp < 1 && emptyAt == null) emptyAt = h.sched.now() - t0;
});
h.onSend.push((pid, msg) => { if (msg.t === 'b.end' && endAt == null) endAt = h.sched.now() - t0; });
h.run(() => m.phase !== PHASE.FINAL_ASSAULT, { maxSteps: 8e6 });
assert.ok(emptyAt != null, 'the team emptied the pool');
assert.ok(pools.every((hp) => hp === 0 || hp >= 1), `the pool never holds float dust (${pools.filter((hp) => hp > 0 && hp < 1)})`);
assert.ok(endAt != null && endAt <= emptyAt, `b.end went out when the pool emptied (${endAt} ms vs ${emptyAt} ms)`);
assert.equal(m._finalEnding, 'cleared');
assert.equal(m.overtimeApplied, 0, 'no overtime drain');
assert.ok(m.teamLp > 0 && m.teamLp >= lp0 - 30, `team LP kept (${lp0} → ${m.teamLp})`);
const c = h.clients.get('p_0');
const local = c.battles.get(f.battleId).battle;
assert.equal(local.reason, 'cleared', 'the authority\'s own battle saw the pool reach 0');
assert.deepEqual(local.enemies.filter((e) => e.alive && e.isBoss).map((e) => e.defId), [], 'no leader left standing');
assert.equal(local.sharedBoss.hp, 0);
m.flush(true);
const pub = h.lastBc('m.public');
assert.deepEqual(pub.bossHp, { hp: 0, max: m.bossPool.maxHp });
const end = h.runToEnd();
assert.equal(end.victory, true);
assert.equal(m.errorCount, 0);
assert.equal(m.verifyStats.rejected, 0);
m.dispose();
});
}
test('#5 real match (server-run combat, SP_COMBAT=server): the Final Assault ends when the pool empties, never on float dust', () => {
const h = realFinalAssault({ bossId: 'boss_3', seed: 11, clientCombat: false });
const m = h.m;
const pool = m.bossPool;
const seen = [];
const orig = pool.damage.bind(pool);
pool.damage = (pid, amount) => { const d = orig(pid, amount); seen.push(pool.hp); return d; };
const end = h.runToEnd();
assert.ok(seen.length > 0);
assert.ok(seen.every((hp) => hp === 0 || hp >= 1), 'the pool is 0 or at least 1 HP after every hit');
assert.equal(pool.hp, 0);
assert.equal(end.victory, true);
assert.equal(m.overtimeApplied, 0);
m.dispose();
});
// ---- the shared pool through the real crediting path (FakeBattle hits a real LocalBossPool; deterministic) ---------
test('#5 client-side combat: a boss dps whose per-player credits used to leave 1.5e-11 in the pool ends the fight when the pool empties', () => {
for (const dps of [3456.789, 24680.13]) {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9110, fake: true, clientCombat: true, instant: false,
script: (b) => (b.kind === 'boss' ? { bossDps: dps } : { duration: 2 }) }).start();
const m = h.m;
h.autoHumans();
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
const t0 = h.sched.now();
const lp0 = m.teamLp;
let emptyAt = null, endAt = null;
const pools = [];
h.onBroadcast.push((msg) => { if (msg.t === 'b.pool') { pools.push(msg.hp); if (msg.hp < 1 && emptyAt == null) emptyAt = h.sched.now() - t0; } });
h.onSend.push((pid, msg) => { if (msg.t === 'b.end' && endAt == null) endAt = h.sched.now() - t0; });
h.run(() => m.phase !== PHASE.FINAL_ASSAULT, { maxSteps: 5e6 });
assert.ok(emptyAt != null, `dps ${dps}: the pool emptied`);
assert.ok(pools.every((hp) => hp === 0 || hp >= 1), `dps ${dps}: never float dust (${pools.filter((hp) => hp > 0 && hp < 1)})`);
assert.ok(endAt != null && endAt <= emptyAt, `dps ${dps}: b.end cleared went out with the empty pool (${emptyAt} / ${endAt} ms; v2.4.1 stalled until the drain at ~205 s)`);
assert.equal(m._finalEnding, 'cleared');
assert.equal(m.teamLp, lp0, 'no overtime drain');
const end = h.runToEnd();
assert.equal(end.victory, true);
m.dispose();
}
});
test('#5 verdict: once the team LP runs out the run is lost — a boss field\'s final result can never credit damage or turn it into a victory', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9109, fake: true, clientCombat: true, instant: false,
script: (b) => (b.kind === 'boss' ? { bossDps: 0, leakEvents: [{ at: 4, lpr: 1 }] } : { duration: 2 }),
// the authority's b.result after the forced end reports more boss damage than was ever credited (in flight / rounded)
perPlayer: { p_0: { tamper: (result, spec) => {
if (spec.kind !== 'boss') return result;
for (const pp of Object.values(result.perPlayer)) pp.bossDamage = 1e6;
return result;
} } } }).start();
const m = h.m;
h.autoHumans();
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
h.sched.advance(1000);
m.teamLp = 1; // the last target LP point: the leak at 4 game s takes it
m.bossPool.hp = 50; // a leader 50 HP from death
h.run(() => m.phase !== PHASE.FINAL_ASSAULT, { maxSteps: 5e6 });
assert.equal(m._finalEnding, 'forced', 'the team LP ran out first');
assert.ok(h.clients.get('p_0').log.some((x) => x.t === 'b.result'), 'the authority reported after b.end forced');
assert.equal(m.bossPool.hp, 50, 'nothing reported after the forced end is credited');
const end = h.runToEnd();
assert.equal(end.victory, false, 'defeat: the team LP reached 0 before the pool');
assert.equal(end.reason, 'defeat');
assert.equal(m.teamLp, 0);
m.dispose();
});