Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash

- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays
  under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed
- #2 two-tap confirm on 机变 cards (like the shop)
- #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles)
- #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band
- #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from
- #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others)
- #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats)
- #8 official element gauges on units; burst cooldown (阻回) per PRTS
- #9 knocked-down operators stay on their tile with a redeploy countdown ring
- #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼)
- #11 赫默's drone only from her skill; placeable summons limited to talent tokens
- #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel
- #13 the occasional giant bond icon (loading texture scaled ×46) fixed
- docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
This commit is contained in:
sganggs committed 2026-09-30 05:35:45 +08:00
1 parent 0aba602ecf
commit d4f868cd19
107 files changed
+4510 -1653

No files matched your search

+54 -8
View File
@@ -156,7 +156,7 @@ test('element bursts on enemies: apoptosis = 800 元素伤害/s + 50 % weaken re
assert.equal(e.s.def, 500 - 2 * ELEMENT.erosion.enemy.defDown, 'stacks, permanent');
});
test('element bursts on operators (enemy damage): burn 1200 arts, neural 1000 true + stun 10 s, apoptosis no skill / −1 SP/s / 100 arts/s, erosion 800 phys + DEF −100', () => {
test('element bursts on operators (enemy damage): burn 1200 arts, neural stun 10 s then 1000 true, apoptosis 阻回 + no skill / −1 SP/s / 100 arts/s, erosion DEF −100 then 800 phys (10 s cooldown)', () => {
const op = chessRec({ id: 't_op', profession: 'WARRIOR', stats: { atk: 0, maxHp: 1e6, def: 300, res: 0, blockCnt: 0 }, skill: { spCost: 30, initSp: 20, duration: 5, bb: { atk: 0.5 } } });
const h = makeBattle({ defs: { chess: { t_op: op } }, units: [{ chessId: 't_op', row: 9, col: 5 }], content: 'generic', autoFinish: false, timeLimit: 120 });
h.step();
@@ -164,31 +164,74 @@ test('element bursts on operators (enemy damage): burn 1200 arts, neural 1000 tr
let hp = u.hp;
h.b.dealDamage(null, u, { type: 'element', element: 'burn', amount: 1000 });
approx(hp - u.hp, ELEMENT.burn.ally.damage);
h.run(ELEMENT.burn.ally.duration + 0.1); // the burn cooldown holds every gauge
hp = u.hp;
let stunned = null;
h.b.on('damaged', (c) => { if (c.target === u && c.type === 'true' && stunned == null) stunned = !!u.s.flags.stun; });
h.b.dealDamage(null, u, { type: 'element', element: 'neural', amount: 1000 });
approx(hp - u.hp, ELEMENT.neural.ally.damage);
assert.ok(u.findBuff('stun'));
assert.equal(stunned, true, 'PRTS: "立刻获得等时长的眩晕;随后受到1000点…真实伤害" — stunned before the hit');
approx(u.findBuff('stun').timeLeft, ELEMENT.neural.ally.stun);
h.run(10.1);
const sp0 = u.skill.sp;
hp = u.hp;
h.b.dealDamage(null, u, { type: 'element', element: 'apoptosis', amount: 1000 });
assert.ok(u.s.flags.silence, 'no skill activation');
assert.ok(u.s.flags.noSp, '阻回: no SP recovery');
h.run(3.02);
approx(hp - u.hp, 3 * ELEMENT.apoptosis.ally.dps);
approx(u.skill.sp, sp0 + 3 * u.s.spRecovery - 3 * ELEMENT.apoptosis.ally.spLossPerSec, 0.05);
approx(u.skill.sp, sp0 - 3 * ELEMENT.apoptosis.ally.spLossPerSec, 0.05);
const spHeld = u.skill.sp;
assert.ok(spHeld < u.skill.spCost && !u.skill.active, 'room for SP (a 0 below is 阻回, not a full bar)');
assert.equal(u.skill.gainSp(5, 'talent'), 0, '阻回 ("停止并阻止任意形式的技力回复"): no granted SP');
assert.equal(u.skill.gainSp(1, 'hurt'), 0, '… nor SP from hits taken');
assert.equal(u.skill.gainSp(1, 'attack'), 0, '… nor from attacks');
assert.equal(u.skill.sp, spHeld);
h.run(12.1);
assert.ok(!u.s.flags.silence);
assert.ok(!u.s.flags.silence && !u.s.flags.noSp);
hp = u.hp;
h.b.dealDamage(null, u, { type: 'element', element: 'erosion', amount: 1000 });
approx(hp - u.hp, ELEMENT.erosion.ally.damage - (300 - ELEMENT.erosion.ally.defDown), 1e-6, 'DEF cut first, then 800 phys');
assert.equal(u.s.def, 300 - ELEMENT.erosion.ally.defDown);
assert.equal(u.elem.erosion, 0, 'no lock: accumulates again at once');
assert.equal(ELEMENT.erosion.ally.duration, 10, 'PRTS 元素: 侵蚀 爆发 10 s on operators');
assert.equal(u.elem.erosion, 1000, 'held full during its cooldown');
h.run(10.1);
assert.equal(u.elem.erosion, 0, 'reset when the cooldown ends');
h.b.dealDamage(null, u, { type: 'element', element: 'erosion', amount: 1000 });
assert.equal(u.findBuff('erosionDown').stacks, 2, 'the DEF cut stacks (permanent)');
});
test('爆发冷却 (PRTS 元素): during a burst no element fills or recovers; its end resets every gauge; 损伤抵抗 = epResistance %', () => {
const resRec = enemyRec({ key: 'enemy_res', hp: 1e6, speed: 0 });
resRec.stats.elementRes = 10;
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy(), enemy_res: resRec } },
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }, { key: 'enemy_res', pos: [10, 8] }], content: 'none', autoFinish: false,
});
h.step();
const e = h.enemy('enemy_dummy');
h.b.dealDamage(null, e, { type: 'element', element: 'neural', amount: 400 });
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 1000 });
assert.ok(e.findBuff('burnBurst') && e.s.flags.burstLock);
assert.equal(h.b.dealDamage(null, e, { type: 'element', element: 'neural', amount: 1000 }), 0, 'another element cannot fill during the cooldown');
approx(e.elem.neural, 400);
assert.equal(h.b.reduceElement(e, 300), 0, 'nor be recovered');
h.run(ELEMENT.burn.enemy.duration - 0.2);
assert.equal(e.elem.burn, 1000);
h.run(0.3);
assert.deepEqual([e.elem.burn, e.elem.neural, e.elem.apoptosis, e.elem.erosion], [0, 0, 0, 0], 'every gauge restored');
h.b.dealDamage(null, e, { type: 'element', element: 'neural', amount: 300 });
approx(e.elem.neural, 300, 1e-6, 'fills again after the cooldown');
// 损伤抵抗 (data elementRes → epResistance, a percentage): 受到的元素损伤 = 损伤值 × (1 − 损伤抵抗 × 0.01)
const r = h.enemy('enemy_res');
assert.equal(r.def.epResistance, 10);
h.b.dealDamage(null, r, { type: 'element', element: 'burn', amount: 500 });
approx(r.elem.burn, 450);
});
test('a resolving burst already locks its gauge: an elementBurst handler spreading the element back cannot re-burst (no ping-pong recursion)', () => {
// two operators that spread every burst to each other (the 淤困 parasite pattern) — burn has a lock, erosion on
// operators has none (duration 0), apoptosis locks with a ticker: each must burst exactly once per fill
// two operators that spread every burst to each other (the 淤困 parasite pattern) — every burst has its cooldown
// (erosion on operators too: 10 s), apoptosis locks with a ticker: each must burst exactly once per fill
const op = (id) => chessRec({ id, profession: 'WARRIOR', stats: { atk: 0, maxHp: 1e6, def: 300, res: 0, blockCnt: 0 }, skill: null });
for (const el of ['burn', 'erosion', 'apoptosis', 'neural']) {
const h = makeBattle({
@@ -210,7 +253,10 @@ test('a resolving burst already locks its gauge: an elementBurst handler spreadi
if (el === 'burn') { approx(hpA - A.hp, ELEMENT.burn.ally.damage); approx(hpB - B.hp, ELEMENT.burn.ally.damage); }
if (el === 'erosion') {
for (const u of [A, B]) assert.equal(u.findBuff('erosionDown').stacks, 1, 'one permanent DEF cut');
assert.equal(A.elem.erosion, 0, 'no lock once resolved: accumulates again');
h.b.dealDamage(null, A, { type: 'element', element: 'erosion', amount: 1000 });
assert.equal(A.findBuff('erosionDown').stacks, 1, 'locked during its 10 s cooldown');
h.run(ELEMENT.erosion.ally.duration + 0.1);
assert.equal(A.elem.erosion, 0, 'reset once the cooldown ended');
h.b.dealDamage(null, A, { type: 'element', element: 'erosion', amount: 1000 });
assert.equal(A.findBuff('erosionDown').stacks, 2, 'a later fill bursts again');
}
+103
View File
@@ -0,0 +1,103 @@
// test/sim/downelem.test.js — user playtest #4 items 8 and 9, sim side (server/sim/Battle.js snapshot / fieldMeta,
// damage.js elementView): b.snap `down` lists the knocked-out operators waiting to redeploy on their own tile (the
// client keeps them on the field knocked down with a redeploy countdown) and `elem` the element gauge each unit shows
// (PRTS 元素: the fullest gauge, its 爆发冷却) — entries, states, and what is left out.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
import { DOWN_STATE, ELEMENT, ELEMENT_ORDER } from '../../server/sim/constants.js';
import { elementView } from '../../server/sim/damage.js';
const approx = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≈ ${b}`);
const op = (id, stats = {}) => chessRec({ id, profession: 'WARRIOR', stats: { atk: 0, maxHp: 1000, def: 0, blockCnt: 0, cost: 12, respawnTime: 6, ...stats }, skill: null });
const TOKEN = { name: '测试召唤物', stats: { maxHp: 100, atk: 0, def: 0, res: 0, blockCnt: 0, cost: 0, respawnTime: 0 } };
const downOf = (h, id) => (h.snapshot().down || []).find((d) => d[0] === id) || null;
test('a knocked-out operator is `down` until it redeploys: its timer, then waiting for its tile / the DP; then back', () => {
const h = makeBattle({
defs: { chess: { t_a: op('t_a'), t_b: op('t_b') } }, units: [{ chessId: 't_a', row: 9, col: 5 }, { chessId: 't_b', row: 10, col: 5 }],
content: 'none', autoFinish: false, timeLimit: 120, flags: { dpInit: 0, dpPerSec: 1, dpMax: 99 },
});
h.step();
const a = h.unit('t_a');
assert.equal(h.snapshot().down, undefined, 'no entry while everyone stands');
const t0 = h.b.time;
h.b.kill(a);
let d = downOf(h, a.id);
assert.ok(d, 'down right after the knock-out');
approx(d[1], Math.round((t0 + 6) * 100) / 100, 0.011);
approx(d[2], 6, 0.011);
assert.equal(d[3], DOWN_STATE.COUNTING);
assert.ok(h.b.fieldMeta().units.some((u) => u.id === a.id), 'a client joining now gets its UnitInfo');
h.run(6.5);
assert.equal(downOf(h, a.id)[3], DOWN_STATE.WAIT_DP, 'timer done, 6.5 DP < cost 12');
const tok = h.b.spawnToken('p1', 'token_test', 9, 5, { def: TOKEN });
assert.ok(tok && tok.alive, 'a summon stands on its tile');
assert.equal(downOf(h, a.id)[3], DOWN_STATE.WAIT_TILE);
h.run(7);
assert.ok(!a.alive, 'no redeploy while its tile is taken');
assert.equal(downOf(h, a.id)[3], DOWN_STATE.WAIT_TILE);
h.b.retreat(tok, { reason: 'expired', permanent: true });
h.step();
assert.ok(a.alive && a.deployed, 'redeployed once the tile is free (DP 13.5 ≥ 12)');
assert.equal(downOf(h, a.id), null);
assert.equal(h.snapshot().down, undefined);
assert.ok(h.eventsOf('deploy').some((e) => e[1] === a.id));
});
test('summons, devices, enemies and withdrawn / removed operators are never `down`', () => {
const h = makeBattle({
defs: { chess: { t_a: op('t_a'), t_b: op('t_b'), t_c: op('t_c') }, enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 100, speed: 0 }) } },
units: [{ chessId: 't_a', row: 9, col: 5 }, { chessId: 't_b', row: 10, col: 5 }, { chessId: 't_c', row: 11, col: 5 }],
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false, timeLimit: 120,
});
h.step();
const tok = h.b.spawnToken('p1', 'token_test', 12, 5, { def: TOKEN });
h.b.kill(tok);
h.b.retreat(h.unit('t_a'), { reason: 'retreat' });
h.b.retreat(h.unit('t_b'), { reason: 'expired', permanent: true });
h.b.kill(h.enemy('enemy_dummy'));
assert.equal(h.snapshot().down, undefined);
h.b.kill(h.unit('t_c'));
assert.deepEqual(h.snapshot().down.map((d) => d[0]), [h.unit('t_c').id], 'only the knocked-out operator');
const ids = new Set(h.b.fieldMeta().units.map((u) => u.id));
assert.ok(ids.has(h.unit('t_c').id) && !ids.has(h.unit('t_a').id) && !ids.has(tok.id));
});
test('elementView: the fullest gauge (ties: the official element order), its cooldown while bursting, nothing when empty', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e6, speed: 0 }), enemy_lead: enemyRec({ key: 'enemy_lead', hp: 1e6, speed: 0, rank: 'BOSS' }) } },
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }, { key: 'enemy_lead', pos: [10, 8] }], content: 'none', autoFinish: false,
});
h.step();
const e = h.enemy('enemy_dummy'), L = h.enemy('enemy_lead');
assert.equal(elementView(e, h.b.time), null);
assert.equal(h.snapshot().elem, undefined, 'no `elem` key when nothing shows');
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 300 });
h.b.dealDamage(null, e, { type: 'element', element: 'apoptosis', amount: 450 });
assert.deepEqual(elementView(e, h.b.time), ['apoptosis', 0.45, 0, 0]);
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 150 });
assert.deepEqual(elementView(e, h.b.time), ['burn', 0.45, 0, 0], 'a tie goes to the lower official id (灼燃 3 < 凋亡 4)');
assert.deepEqual(ELEMENT_ORDER.slice(0, 4), ['neural', 'erosion', 'burn', 'apoptosis']);
h.b.dealDamage(null, L, { type: 'element', element: 'neural', amount: 500 });
assert.deepEqual(elementView(L, h.b.time), ['neural', 0.25, 0, 0], 'leaders hold 2000');
const snap = h.snapshot();
assert.deepEqual(snap.elem.map((x) => x[0]).sort(), [e.id, L.id].sort());
// burst: the bursting element, full, with the cooldown's end and length
h.b.dealDamage(null, e, { type: 'element', element: 'apoptosis', amount: 600 });
const t = h.b.time;
const v = elementView(e, t);
assert.equal(v[0], 'apoptosis');
assert.equal(v[1], 1);
approx(v[2], Math.round((t + ELEMENT.apoptosis.enemy.duration) * 100) / 100, 0.011);
assert.equal(v[3], ELEMENT.apoptosis.enemy.duration);
h.run(5);
approx(elementView(e, h.b.time)[2], v[2], 0.05);
h.run(ELEMENT.apoptosis.enemy.duration - 4.9);
assert.equal(elementView(e, h.b.time), null, 'every gauge reset after the cooldown');
// a dead or hidden unit has no entry
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 100 });
h.b.kill(e);
assert.ok(!(h.snapshot().elem || []).some((x) => x[0] === e.id));
});