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

208 lines
9.7 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.
// Sim core: rng, grid/pathing, damage formulas, stat aggregation, buffs.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRng } from '../../server/sim/rng.js';
import { Grid } from '../../server/sim/grid.js';
import { mitigate } from '../../server/sim/damage.js';
import { Unit } from '../../server/sim/units.js';
import { getDefaultSource, hasGeneratedData } from '../../server/sim/simdata.js';
import { makeBattle, flatStage, chessRec, enemyRec } from '../helpers/battleHarness.js';
const approx = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≈ ${b}`);
test('rng is deterministic and in [0,1)', () => {
const a = createRng(123), b = createRng(123), c = createRng(124);
const xs = Array.from({ length: 50 }, () => a());
const ys = Array.from({ length: 50 }, () => b());
assert.deepEqual(xs, ys);
assert.notDeepEqual(xs, Array.from({ length: 50 }, () => c()));
for (const x of xs) assert.ok(x >= 0 && x < 1);
const r = createRng(5);
for (let i = 0; i < 200; i++) { const v = r.int(7); assert.ok(Number.isInteger(v) && v >= 0 && v < 7); }
});
test('grid: 8-dir A* without corner cutting matches helper path lengths on real stages', () => {
const ds = getDefaultSource();
for (const id of ['act2autochess_m01', 'act2autochess_m02', 'act1autochess_m03']) {
const st = ds.getStage(id);
const g = new Grid(st, { r0: 9, r1: 12, c0: 0, c1: 10 });
const helper = st.raw.groundPaths?.['9,10->9,2'] ?? st.raw.groundPathsHelper?.paths?.['9,10->9,2'];
const p = g.findPath(9, 10, 9, 2);
assert.ok(p, `path on ${id}`);
assert.deepEqual(p[0], [9, 10]);
assert.deepEqual(p[p.length - 1], [9, 2]);
if (helper) approx(Grid.pathLength(p), Grid.pathLength(helper), 1e-6);
// no corner cutting: every diagonal step has both orthogonals passable
for (let i = 1; i < p.length; i++) {
const [r0, c0] = p[i - 1], [r1, c1] = p[i];
assert.ok(Math.abs(r1 - r0) <= 1 && Math.abs(c1 - c0) <= 1);
if (r1 !== r0 && c1 !== c0) {
assert.ok(g.groundPassable(r0, c1) || (r0 === 9 && c1 === 2));
assert.ok(g.groundPassable(r1, c0) || (r1 === 9 && c0 === 2));
}
}
}
});
test('grid: obstacles re-route and bump version; blocked entirely ⇒ null unless ignoreObstacles', () => {
const g = new Grid(flatStage(), { r0: 9, r1: 12, c0: 0, c1: 10 });
const direct = g.findPath(9, 10, 9, 2);
assert.equal(direct.length, 9);
const v0 = g.version;
g.setObstacle(9, 6, true);
assert.ok(g.version > v0);
const around = g.findPath(9, 10, 9, 2);
assert.ok(!around.some(([r, c]) => r === 9 && c === 6));
for (let r = 9; r <= 12; r++) g.setObstacle(r, 6, true);
assert.equal(g.findPath(9, 10, 9, 2), null);
assert.ok(g.findPath(9, 10, 9, 2, { ignoreObstacles: true }));
});
test('damage formulas: phys/arts/true, ignore, 5% floor', () => {
approx(mitigate(1000, 'phys', { def: 300 }), 700);
approx(mitigate(1000, 'phys', { def: 2000 }), 50);
approx(mitigate(1000, 'phys', { def: 300 }, { defIgnorePct: 0.5, defIgnoreFlat: 50 }), 900);
approx(mitigate(1000, 'arts', { res: 30 }), 700);
approx(mitigate(1000, 'arts', { res: 100 }), 50);
approx(mitigate(1000, 'arts', { res: 40 }, { resIgnorePct: 0.5, resIgnoreFlat: 10 }), 900);
approx(mitigate(1000, 'true', { def: 9999, res: 100 }), 1000);
});
test('dealDamage applies multipliers, shields and records stats', () => {
const h = makeBattle({
defs: { chess: { t_a: chessRec({ id: 't_a', stats: { atk: 1000 } }) }, enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 100000, def: 200, res: 50, speed: 0 }) } },
units: [{ chessId: 't_a', row: 9, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }],
content: 'none',
});
h.step();
const op = h.unit('t_a');
const e = h.enemy('enemy_dummy');
op.markDirty();
h.b.addBuff(op, { key: 'dd', mods: { dmgDealtMul: 1.5 } });
h.b.addBuff(e, { key: 'fr', mods: { dmgTakenMul: 1.2, artsTakenMul: 2 } });
const hp0 = e.hp;
const d1 = h.b.dealDamage(op, e, { amount: 1000, type: 'phys' });
approx(d1, (1000 - 200) * 1.5 * 1.2);
const d2 = h.b.dealDamage(op, e, { amount: 1000, type: 'arts' });
approx(d2, 1000 * 0.5 * 1.5 * 1.2 * 2);
approx(e.hp, hp0 - d1 - d2);
// shield absorbs
h.b.addBuff(e, { key: 'sh', shield: 500 });
const d3 = h.b.dealDamage(null, e, { amount: 300, type: 'true' });
assert.equal(d3, 0);
const d4 = h.b.dealDamage(null, e, { amount: 300, type: 'true' });
approx(d4, 360 - (500 - 360)); // 300×1.2 per hit; the shield absorbed 360 of 500 on the first hit
assert.equal(e.findBuff('sh'), null, 'depleted shield removed');
// hit-negating barrier
h.b.addBuff(e, { key: 'barrier', shieldHits: 1 });
assert.equal(h.b.dealDamage(null, e, { amount: 5000, type: 'true' }), 0);
assert.ok(h.b.dealDamage(null, e, { amount: 10, type: 'true' }) > 0);
assert.ok(op.stats.dmg > 0);
assert.ok(h.result().perPlayer.p1.damageDealt >= d1 + d2);
});
test('dodge uses the seeded rng and is deterministic', () => {
const run = (seed) => {
const h = makeBattle({ seed, defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0 }) } }, enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none' });
h.step();
const e = h.enemy('enemy_dummy');
h.b.addBuff(e, { key: 'dodge', mods: { dodgePhys: 0.5 } });
let hits = 0;
for (let i = 0; i < 400; i++) if (h.b.dealDamage(null, e, { amount: 100, type: 'phys' }) > 0) hits++;
assert.equal(h.b.dealDamage(null, e, { amount: 100, type: 'phys', canDodge: false }) > 0, true);
return hits;
};
const a = run(9);
assert.equal(a, run(9));
assert.ok(a > 150 && a < 250, `~50% hits (${a})`);
});
test('stat aggregation: (base+flat)(1+pct)Πmul, res clamp, aspd clamp, interval, HP ratio kept', () => {
const u = new Unit({ id: 1, side: 'ally', kind: 'op', base: { maxHp: 1000, atk: 100, def: 50, res: 20, aspd: 100, bat: 1.2 } });
u.alive = true;
u.hp = 500;
u.buffs.push({ key: 'a', stacks: 1, mods: { atkFlat: 50, atkPct: 0.5, atkMul: 1.2, hpPct: 1, resFlat: 200, aspd: 30, batPct: -0.25 } });
u.buffs.push({ key: 'b', stacks: 2, mods: { atkPct: 0.1, atkMul: 1.1 } });
u.markDirty();
const s = u.s;
approx(s.atk, (100 + 50) * (1 + 0.5 + 0.2) * 1.2 * 1.1 * 1.1);
assert.equal(s.res, 100);
assert.equal(s.aspd, 130);
approx(s.interval, 1.2 * 0.75 * 100 / 130);
approx(s.maxHp, 2000);
approx(u.hp, 1000, 1e-6);
u.buffs.push({ key: 'c', stacks: 1, mods: { aspd: -500 } });
u.markDirty();
assert.equal(u.s.aspd, 20, 'ASPD floor 20 (PRTS 数值范围)');
});
test('buffs: replace / extend / stack / independent / keep, expiry and onExpire', () => {
const h = makeBattle({ defs: { chess: { t_a: chessRec({ id: 't_a' }) } }, units: [{ chessId: 't_a', row: 9, col: 3 }], content: 'none' });
h.step();
const u = h.unit('t_a');
const b = h.b;
b.addBuff(u, { key: 'r', duration: 2, mods: { atkPct: 0.1 } });
b.addBuff(u, { key: 'r', duration: 1, mods: { atkPct: 0.3 } });
assert.equal(u.buffs.filter((x) => x.key === 'r').length, 1);
approx(u.findBuff('r').timeLeft, 1);
b.addBuff(u, { key: 'e', duration: 5, refresh: 'extend', mods: { defPct: 0.1 } });
b.addBuff(u, { key: 'e', duration: 2, refresh: 'extend', mods: { defPct: 0.2 } });
approx(u.findBuff('e').timeLeft, 5);
assert.equal(u.findBuff('e').mods.defPct, 0.2);
for (let i = 0; i < 5; i++) b.addBuff(u, { key: 's', duration: 3, refresh: 'stack', maxStacks: 3, mods: { aspd: 10 } });
assert.equal(u.findBuff('s').stacks, 3);
assert.equal(u.s.aspd, 130);
for (let i = 0; i < 4; i++) b.addBuff(u, { key: 'i', duration: 1 + i, refresh: 'independent', maxStacks: 3, mods: { atkFlat: 1 } });
assert.equal(u.buffs.filter((x) => x.key === 'i').length, 3);
b.addBuff(u, { key: 'k', duration: 1, refresh: 'keep', mods: { atkFlat: 5 } });
b.addBuff(u, { key: 'k', duration: 9, refresh: 'keep', mods: { atkFlat: 50 } });
assert.equal(u.findBuff('k').mods.atkFlat, 5);
let expired = 0;
b.addBuff(u, { key: 'x', duration: 0.5, onExpire: () => expired++ });
h.run(0.6);
assert.equal(expired, 1);
assert.equal(u.findBuff('x'), null);
assert.ok(u.findBuff('r'), '1 s buff still alive at 0.6 s');
h.run(3);
assert.equal(u.findBuff('r'), null, '1 s buff expired');
assert.equal(u.findBuff('s'), null);
assert.ok(u.findBuff('e'));
// interval ticks
let ticks = 0;
b.addBuff(u, { key: 'dot', duration: 3, interval: 1, onTick: () => ticks++ });
h.run(3.05);
assert.equal(ticks, 3);
});
test('persistent buffs survive death; others are cleared', () => {
const h = makeBattle({ defs: { chess: { t_a: chessRec({ id: 't_a', stats: { respawnTime: 1, cost: 0 } }) } }, units: [{ chessId: 't_a', row: 9, col: 3 }], content: 'none' });
h.step();
const u = h.unit('t_a');
h.b.addBuff(u, { key: 'keepme', persist: true, mods: { atkPct: 1 } });
h.b.addBuff(u, { key: 'loseme', mods: { atkPct: 1 } });
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.equal(u.alive, false);
assert.ok(u.findBuff('keepme'));
assert.equal(u.findBuff('loseme'), null);
h.run(1.5);
assert.equal(u.alive, true, 'redeployed');
approx(u.s.atk, 500 * 2);
});
test('simdata normalises real chess, enemy, token, stage and routes', () => {
assert.ok(hasGeneratedData(), 'tests run against the generated data/*.json');
const ds = getDefaultSource();
const c = ds.getChess('chess_char_1_01_a');
assert.equal(c.subProf, 'fastshot');
assert.ok(c.skill && c.skill.spCost > 0);
assert.ok(c.rangeGrid.length > 3);
const e = ds.getEnemy('enemy_1427_lrnazg');
assert.ok(e.maxHp > 0 && e.rangeRadius > 0);
const e2 = ds.getEnemy('1427_lrnazg');
assert.equal(e2.key, 'enemy_1427_lrnazg');
const st = ds.getStage('act2autochess_m01');
assert.equal(st.rows.length, 19);
assert.equal(st.rows[9][2], 'E');
});