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
This commit is contained in:
sganggs committed 2026-10-01 19:08:23 +08:00
1 parent e6628f0cfe
commit 6464e4b46a
138 files changed
+7946 -1379

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
// 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();
});