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

+150
View File
@@ -0,0 +1,150 @@
// 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), []);
});
}