Files
ark-stronghold-protocol/test/sim/playtest6_elemgauge.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

109 lines
6.2 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.
// test/sim/playtest6_elemgauge.test.js — user playtest #6 report 11, sim side: "干员血条右边的元素损伤条积累速度不对,损伤条
// 远远没扣完但是实际已经爆条受到伤害了". The gauge a unit shows (damage.js elementView → b.snap `elem`) must be exactly
// the gauge that bursts (PRTS 元素: "以白条显示剩余的元素值", the shown element = 当前损伤元素, lowest 元素值 > element id):
// · every tick, the shown element is the one with the least 元素值 left, and the shown remainder (1 − fill) is the
// sim's remainder rounded UP to 1 % — never less than what is left, so the bar is never empty before the burst;
// · the element that bursts is the one shown the tick before (one source), and the burst shows as its 爆发冷却;
// · real 海嗣 against a real operator (古米): 底海滑动者 (15 % ATK 神经 per hit) and 掠海漂移体 (50 % ATK 侵蚀 per hit,
// × the round's ATK multiplier: a chunk of the gauge per hit) with 骨海漂流体 (20 % 侵蚀).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle } from '../helpers/battleHarness.js';
import { elementView } from '../../server/sim/damage.js';
import { ELEMENT_ORDER } from '../../server/sim/constants.js';
const OP = 'chess_char_1_10_a'; // 古米 (T1 defender, real kit)
/** A real operator with real 海嗣 pinned in contact; returns the harness and the operator. */
function arena(keys, mods = {}) {
const h = makeBattle({ units: [{ chessId: OP, row: 10, col: 5 }], seed: 3, autoFinish: false, timeLimit: 600 });
h.step();
const pos = [[10, 5.6], [10, 4.4], [10.6, 5]];
keys.forEach((k, i) => h.spawn(k, { pos: pos[i], routeIndex: 0, mods: { speedMul: 0, hpMul: 1000, ...mods } }));
return { h, u: h.allies()[0] };
}
/** The sim's own view of the gauge: the element with the least 元素值 left (ties: official id) and that remainder. */
function simGauge(u) {
let best = null, bv = 0;
for (const k of ELEMENT_ORDER) if (u.elem[k] > bv) { bv = u.elem[k]; best = k; }
return best ? { el: best, left: 1 - bv / u.gaugeMax } : null;
}
/** Run until `bursts` bursts of the operator happened, checking the shown gauge against the sim's every tick. */
function followGauge(h, u, bursts, maxSeconds = 200) {
const seen = [];
let prevShown = null;
h.b.on('elementBurst', (c) => { if (c.target === u) seen.push({ el: c.element, shownBefore: prevShown }); });
for (let i = 0; i < maxSeconds * 30 && seen.length < bursts; i++) {
const snap = h.b.snapshot();
const entry = (snap.elem || []).find((x) => x[0] === u.id) || null;
const g = simGauge(u);
const locked = !!u.s.flags.burstLock;
if (!locked) {
if (!g) assert.equal(entry, null, `t=${h.b.time}: no gauge, nothing shown`);
else {
assert.ok(entry, `t=${h.b.time}: a gauge (${g.el} ${g.left}) is shown`);
assert.equal(entry[1], g.el, `t=${h.b.time}: the shown element is the one with the least 元素值 left`);
const shownLeft = 1 - entry[2];
assert.ok(shownLeft >= g.left - 1e-9, `t=${h.b.time}: shown remainder ${shownLeft} is never below the real ${g.left}`);
assert.ok(shownLeft - g.left < 0.01 + 1e-9, `t=${h.b.time}: shown remainder ${shownLeft} within 1 % of the real ${g.left}`);
assert.ok(shownLeft > 0, `t=${h.b.time}: the bar is not empty before the burst`);
assert.equal(entry[3], 0, 'no cooldown numbers outside a burst');
}
} else {
assert.equal(entry[2], 1, 'the burst shows full with its cooldown');
assert.ok(entry[3] > h.b.time && entry[4] > 0);
}
prevShown = entry && !locked ? { el: entry[1], left: +(1 - entry[2]).toFixed(2) } : null;
h.step();
if (u.alive) u.hp = u.s.maxHp; // keep the operator standing (only its gauge matters)
assert.ok(u.alive, 'the operator stays on the field');
}
return seen;
}
test('底海滑动者 on 古米: the shown gauge is the sim\'s gauge every tick; the burst comes as the bar runs out', () => {
const { h, u } = arena(['enemy_1148_dssbr']);
const seen = followGauge(h, u, 2);
assert.equal(seen.length, 2, 'two 神经 bursts');
for (const s of seen) {
assert.equal(s.el, 'neural');
assert.equal(s.shownBefore.el, 'neural', 'the burst is the element shown the tick before');
assert.ok(s.shownBefore.left <= 0.05, `a steady 42-per-hit source bursts off a sliver (${s.shownBefore.left} left)`);
}
});
test('掠海漂移体 (a chunk per hit, × the round\'s ATK) with 骨海漂流体 on 古米: shown = sim every tick, bursts off the shown element', () => {
const { h, u } = arena(['enemy_2025_syufo', 'enemy_2021_syfish'], { atkMul: 1.771561 }); // 绝境 multi R11
const seen = followGauge(h, u, 2);
assert.equal(seen.length, 2);
for (const s of seen) {
assert.equal(s.el, 'erosion');
assert.equal(s.shownBefore.el, 'erosion');
// one 掠海漂移体 hit = 500 × 1.77 × 0.5 ≈ 443 of 1000: officially the bar drops by ~44 % per hit, so a burst can
// come off a bar that still shows up to that much — the sim and the bar agree on it
assert.ok(s.shownBefore.left <= 0.45, `${s.shownBefore.left} left before the burst ≤ one hit`);
}
});
test('a gauge a few 元素值 short of bursting still shows a sliver (the remainder rounds up, never to an empty bar)', () => {
const { h, u } = arena([]);
h.b.dealDamage(null, u, { type: 'element', element: 'neural', amount: 996 });
assert.equal(u.s.flags.burstLock, undefined, 'no burst yet');
const v = elementView(u, h.b.time);
assert.equal(v[0], 'neural');
assert.ok(v[1] < 1, `fill ${v[1]} < 1: 4 元素值 are left`);
assert.equal(v[1], 0.99);
h.b.dealDamage(null, u, { type: 'element', element: 'neural', amount: 3 });
assert.equal(elementView(u, h.b.time)[1], 0.99, '1 元素值 left still shows');
h.b.dealDamage(null, u, { type: 'element', element: 'neural', amount: 1 });
assert.ok(u.s.flags.burstLock, 'the last point bursts');
assert.deepEqual(elementView(u, h.b.time).slice(0, 2), ['neural', 1], 'now it shows the 爆发冷却');
// a tiny first hit shows at least 1 %
const { h: h2, u: u2 } = arena([]);
h2.b.dealDamage(null, u2, { type: 'element', element: 'burn', amount: 3 });
assert.equal(elementView(u2, h2.b.time)[1], 0.01);
});