// 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)); });