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

+5 -4
View File
@@ -289,7 +289,7 @@ test('淤困 (enemy_9007_acelem) parasite hosts side by side: one burst spreads
assert.ok(hpA - A.hp < 2 * ELEMENT.burn.ally.damage && hpB - B.hp < 2 * ELEMENT.burn.ally.damage);
});
test('statuses (official terms): tremble only stops attacks while blocked; fear = unblockable + no attack + no advance; stunned blocker lets go', () => {
test('statuses (official terms): tremble only stops attacks while blocked; fear = unblockable + no attack + no advance (no source: it stays on its tile, fear.js); stunned blocker lets go', () => {
const g = chessRec({ id: 't_guard', profession: 'WARRIOR', stats: { atk: 0, blockCnt: 2, maxHp: 1e6 }, skill: null });
const h = makeBattle({
defs: { chess: { t_guard: g }, enemies: { enemy_walker: walker({ atk: 100, bat: 1 }) } },
@@ -309,13 +309,14 @@ test('statuses (official terms): tremble only stops attacks while blocked; fear
assert.equal(e.blockedBy, null, 'a stunned operator blocks nothing');
assert.equal(u.blocking.length, 0);
h.run(1.2);
// fear: released, no attacks, does not advance
// fear: released, no attacks, does not advance — without a source there is no 恐惧可达地块: it flutters on its own tile
if (!e.blockedBy) assert.ok(h.runUntil(() => e.blockedBy === u, 20));
h.b.applyStatus(e, 'fear', { duration: 2 });
assert.equal(e.blockedBy, null);
const x0 = e.x, a1 = e.stats.attacks;
const x0 = e.x, a1 = e.stats.attacks, tc = Math.round(e.x);
h.run(1.9);
approx(e.x, x0);
assert.ok(Math.abs(e.x - tc) <= 0.5 && Math.round(e.x) === Math.round(x0), `stays on its tile (${x0} → ${e.x})`);
assert.equal(e.blockedBy, null);
assert.equal(e.stats.attacks, a1);
h.run(0.5);
assert.ok(e.blockedBy === u || e.x < x0, 'walks / is blocked again after the fear');
+1 -1
View File
@@ -134,7 +134,7 @@ test('stat aggregation: (base+flat)(1+pct)Πmul, res clamp, aspd clamp, interval
approx(u.hp, 1000, 1e-6);
u.buffs.push({ key: 'c', stacks: 1, mods: { aspd: -500 } });
u.markDirty();
assert.equal(u.s.aspd, 10);
assert.equal(u.s.aspd, 20, 'ASPD floor 20 (PRTS 数值范围)');
});
test('buffs: replace / extend / stack / independent / keep, expiry and onExpire', () => {
+33 -26
View File
@@ -2,7 +2,9 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { releaseSkillSummon } from '../../server/sim/content/tokens.js';
const approx = (a, b, msg = '', eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0, ...o });
test('SIM.md §10: 隐现 S2 fires 14 shots then ends', () => {
@@ -64,50 +66,55 @@ test('SIM.md example 2: 幽灵鲨 undying during the skill, self-stun afterwards
assert.equal(u.alive, false, 'undying ended with the skill');
});
test('SIM.md example 5: 赫默 summons a medical drone that expires after 10 s', () => {
const kit = () => ({
test('SIM.md example 5: 赫默 brings her placed medical drone onto its tile; it expires after 10 s', () => {
const kit = (bb) => ({
skill: {
kind: 'instant', trigger: 'SP_FULL',
onStart({ battle, unit }) {
for (const k of unit.rangeKeys) {
const r = Math.floor(k / 21), c = k % 21;
if (!battle.unitAt(r, c) && battle.grid.canStand(r, c, { ranged: true })) { battle.spawnToken(unit, 'token_10000_silent_healrb', r, c, { duration: 10 }); break; }
}
},
kind: 'instant', heal: true,
onStart({ battle, unit }) { releaseSkillSummon(battle, unit, 'token_10000_silent_healrb', { cap: bb.cnt }); },
},
});
const h = makeBattle({ kits: { chess_char_2_02_a: kit }, units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 4 }] });
const h = makeBattle({
defs: { chess: { t_guard: chessRec({ id: 't_guard', profession: 'WARRIOR', skill: null, stats: { atk: 0, maxHp: 1e5 } }) } },
kits: { chess_char_2_02_a: kit },
units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 4, uid: 1 }, { chessId: 't_guard', row: 10, col: 5, uid: 2 }, { kind: 'token', tokenId: 'token_10000_silent_healrb', ownerUid: 1, row: 11, col: 4, uid: 3 }],
});
const u = h.unit('chess_char_2_02_a');
const drone = h.unit(3);
h.step();
assert.equal(drone.alive, true, 'the placed piece deploys once with the board (SKILL_SUMMON_START_DEPLOY)');
h.run(10.1);
assert.equal(drone.alive, false, 'its 10 s');
h.unit('t_guard').hp = 1000;
h.runUntil(() => u.skill.activations === 1, 60);
const drone = h.b.allyUnits.find((x) => x.kind === 'token');
assert.ok(drone && drone.alive);
h.step();
assert.ok(drone.alive && drone.tileR === 11 && drone.tileC === 4);
assert.equal(drone.profile.dmgType, 'heal');
h.run(10.1);
assert.equal(drone.alive, false);
});
test('SIM.md example 6: 古米 TAKE_DAMAGE heal on the next attack', () => {
const kit = (bb) => ({
test('SIM.md example 6: 古米 备用军粮 — an AUTO heal skill cast by an injured ally of its range (no enemy), the next attack is the heal', () => {
const kit = (bb, chess, def) => ({
skill: {
kind: 'instant',
attack: { onHit({ battle, unit }) {
const ally = battle.alliesInRadius(unit.x, unit.y, 1.5, unit.ownerId).sort((a, b) => a.hpRatio - b.hpRatio)[0];
if (ally) battle.heal(unit, ally, unit.s.atk * bb.heal_scale);
} },
kind: 'instant', heal: true,
trigger: { rule: 'SKILL_RANGE', grid: def.skill.rangeGrid, allies: true },
targeting: { rangeGrid: def.skill.rangeGrid },
attack: { dmgType: 'heal', heal: { mode: 'single' }, healScale: bb.heal_scale },
},
});
const h = makeBattle({
defs: { chess: { t_ally: chessRec({ id: 't_ally', skill: null, stats: { maxHp: 10000, atk: 0 } }) }, enemies: { enemy_dummy: dummy({ atk: 50, bat: 1 }) } },
defs: { chess: { t_ally: chessRec({ id: 't_ally', skill: null, stats: { maxHp: 10000, atk: 0 } }) } },
kits: { chess_char_1_10_a: kit },
units: [{ chessId: 'chess_char_1_10_a', row: 9, col: 5 }, { chessId: 't_ally', row: 10, col: 5 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 5], time: 8 }], autoFinish: false, // spawns on 古米's tile ⇒ blocked
autoFinish: false, // no enemy at all
});
const g = h.unit('chess_char_1_10_a');
h.run(7);
assert.equal(g.skill.rule, 'SKILL_RANGE');
assert.equal(g.skill.activations, 0, 'ready, nobody injured');
h.unit('t_ally').hp = 2000;
assert.equal(g.skill.rule, 'TAKE_DAMAGE');
assert.equal(g.skill.activations, 0, 'ready but not hit yet');
h.run(4);
assert.ok(g.skill.activations >= 1);
assert.ok(h.unit('t_ally').hp > 2000, 'healed by the empowered attack');
h.run(2);
assert.equal(g.skill.activations, 1);
approx(h.unit('t_ally').hp, 2000 + g.s.atk * g.def.skill.bb.heal_scale, 'healed by her heal-mode attack');
checkInvariants(h.b);
});
+5 -4
View File
@@ -107,15 +107,16 @@ test('massFlat: current massLevel (weight) for displacement and 浮空 halving;
h.step();
const e = h.spawn('enemy_heavy', { pos: [10, 5] });
assert.equal(e.s.massLevel, 4);
const d1 = h.b.displace(e, { x: 1, y: 0 }, 1, { force: 1 });
approx(d1, 0.3, 0.051, 'mass 4 vs force 1: 25 %');
// official 力度 − 重量 (PRTS 游戏数据基础 §重量公式): a 中力 push (1) on weight 4 = 受力等级 −3 ⇒ nothing
const d1 = h.b.push(e, 1, { from: { x: 4, y: 10 } });
assert.equal(d1, 0, 'weight 4 vs 中力: not moved');
h.b.addBuff(e, { key: 'weightless', mods: { massFlat: -1 }, duration: 10 });
assert.equal(e.s.massLevel, 3);
assert.equal(e.weight, 3);
assert.equal(e.base.massLevel, 4, 'base untouched');
e.x = 5; e.y = 10;
const d2 = h.b.displace(e, { x: 1, y: 0 }, 1, { force: 1 });
approx(d2, 0.5, 0.051, 'mass 3: 50 %');
const d2 = h.b.push(e, 1, { from: { x: 4, y: 10 } });
approx(d2, 0.12, 1e-6, '失重 weight 3 vs 中力: 受力等级 −2 ⇒ 0.12 tiles');
h.b.applyStatus(e, 'levitate', { duration: 4 });
approx(e.findBuff('levitate').timeLeft, 4, 1e-9, 'weight 3: full 浮空');
h.b.removeStatus(e, 'levitate');
+20 -11
View File
@@ -48,15 +48,19 @@ function runUntil(b, pred, sec = 10) {
test('two players in one field, same chess, different skills / modules: summons spawned mid-battle get each owner\'s variant', { skip }, () => {
const spec = uniteSpec(
[{ uid: 1, chessId: MLYSS, row: 10, col: 4, dir: 'RIGHT', skillIndex: 0, moduleId: 'uniequip_003_mlyss' }, { uid: 2, chessId: CATHY, row: 11, col: 4, dir: 'RIGHT', skillIndex: 0 }],
[{ uid: 1, chessId: MLYSS, row: 10, col: 4, dir: 'RIGHT', skillIndex: 0, moduleId: 'uniequip_003_mlyss' }, { uid: 2, chessId: CATHY, row: 11, col: 4, dir: 'RIGHT', skillIndex: 0 },
{ uid: 3, kind: 'token', tokenId: CATSLD, ownerUid: 2, row: 11, col: 5, dir: 'LEFT' }],
// p2: MLYSS with another explicit choice, CATHY without loadout fields (= the default, never p1's choice)
[{ uid: 1, chessId: MLYSS, row: 10, col: 4, dir: 'RIGHT', skillIndex: 1, moduleId: 'none' }, { uid: 2, chessId: CATHY, row: 11, col: 4, dir: 'RIGHT' }],
[{ uid: 1, chessId: MLYSS, row: 10, col: 4, dir: 'RIGHT', skillIndex: 1, moduleId: 'none' }, { uid: 2, chessId: CATHY, row: 11, col: 4, dir: 'RIGHT' },
{ uid: 3, kind: 'token', tokenId: CATSLD, ownerUid: 2, row: 11, col: 5, dir: 'LEFT' }],
);
const b = createBattleFromSpec(spec, freshDs(), { recordEvents: false, quiet: true });
assert.deepEqual([...b.data.loadoutConflicts].sort(), [CATHY, MLYSS].sort(), 'id-only lookups would give p2 p1\'s choices');
const m1 = opOf(b, 'p1', 1), m2 = opOf(b, 'p2', 1), c1 = opOf(b, 'p1', 2), c2 = opOf(b, 'p2', 2);
// no board pieces: the 流形 (援军) and 凯瑟琳's devices are summoned during the battle
assert.ok(runUntil(b, () => [m1, m2, c1, c2].every((o) => summonsOf(b, o, o === m1 || o === m2 ? WTRMAN : CATSLD).length > 0), 5), 'every owner summoned');
// no 流形 piece: the 援军 is summoned during the battle; 凯瑟琳's devices are board pieces (user playtest #6) whose
// def follows the owner's loadout too
const piecesOf = (owner) => b.allyUnits.filter((u) => u.kind === 'token' && u.uid != null && u.ownerUnit === owner && u.defId === CATSLD);
assert.ok(runUntil(b, () => [m1, m2].every((o) => summonsOf(b, o, WTRMAN).length > 0) && [c1, c2].every((o) => piecesOf(o).some((t) => t.alive)), 5), 'every owner summoned');
const d = freshDs();
const w1 = summonsOf(b, m1, WTRMAN)[0], w2 = summonsOf(b, m2, WTRMAN)[0];
assert.equal(w1.def.skill.id, 'sktok_mlyss_wtrman_1', 'p1 流形: its owner\'s S1');
@@ -65,8 +69,9 @@ test('two players in one field, same chess, different skills / modules: summons
assert.ok(!w2.def.talents.some((t) => t.bb.taunt_level === 1), 'p2 流形 (no module): no module talent');
assert.deepEqual(w2.def.talents, d.getToken(WTRMAN, MLYSS, { skillIndex: 1, moduleId: 'none' }).talents);
assert.equal(w2.base.maxHp, d.getToken(WTRMAN, MLYSS, { skillIndex: 1, moduleId: 'none' }).stats.maxHp);
for (const t of summonsOf(b, c1, CATSLD)) assert.equal(t.def.skill.id, 'sktok_cathy_catsld_1', 'p1 devices: S1 token skill');
for (const t of summonsOf(b, c2, CATSLD)) assert.equal(t.def.skill.id, 'sktok_cathy_catsld_2', 'p2 devices (default loadout): S2 token skill');
assert.ok(piecesOf(c1).length === 1 && piecesOf(c2).length === 1);
for (const t of piecesOf(c1)) assert.equal(t.def.skill.id, 'sktok_cathy_catsld_1', 'p1 devices: S1 token skill');
for (const t of piecesOf(c2)) assert.equal(t.def.skill.id, 'sktok_cathy_catsld_2', 'p2 devices (default loadout): S2 token skill');
// Battle.tokenDef / spawnToken (content summons) resolve the owner's loadout as well
assert.equal(b.tokenDef(WTRMAN, m1).skill.id, 'sktok_mlyss_wtrman_1');
assert.equal(b.tokenDef(WTRMAN, m2).skill.id, 'sktok_mlyss_wtrman_2');
@@ -231,17 +236,21 @@ test('tokens.js variantOf: the deploy limit of a summon follows the owner\'s mod
});
test('tokens.js tokenSources: the generic-kit skill-summon fallback reads the owner loadout (赫默 S1 makes no drone)', { skip }, () => {
const run = (lo) => {
const h = makeBattle({ players: [mk('p1', 0, 0, [{ uid: 1, chessId: HEMO, row: 10, col: 5, ...lo }])], data: freshDs(), kits: { [HEMO]: () => null } });
// the drone is a placed piece (user playtest #6): S2 deploys it once with the board (SKILL_SUMMON_START_DEPLOY) and
// brings it back onto its tile with each cast; S1 never makes one (its piece never deploys)
const run = (lo, atStart) => {
const units = [{ uid: 1, chessId: HEMO, row: 10, col: 5, ...lo }, { uid: 2, kind: 'token', tokenId: DRONE, ownerUid: 1, row: 10, col: 6 }];
const h = makeBattle({ players: [mk('p1', 0, 0, units)], data: freshDs(), kits: { [HEMO]: () => null } });
h.step();
const u = h.b.allyUnits.find((x) => x.uid === 1);
assert.equal(u.kit.generic, true);
assert.equal(h.b.allyUnits.find((x) => x.uid === 2).alive, atStart, 'with the board: S2 yes, S1 no');
h.b.producesToken = () => true; // only the fallback's own check decides here
assert.ok(u.skill.activate('test', { free: true }));
return summonsOf(h.b, u, DRONE).length;
return h.b.allyUnits.filter((t) => t.kind === 'token' && t.defId === DRONE && t.ownerUnit === u && t.alive).length;
};
assert.equal(run({}), 1, 'S2 (default): the drone');
assert.equal(run({ skillIndex: 0 }), 0, 'S1: none');
assert.equal(run({}, true), 1, 'S2 (default): the drone');
assert.equal(run({ skillIndex: 0 }, false), 0, 'S1: none');
});
test('dollkeeper: the substitute token is looked up with the unit\'s own loadout', { skip }, () => {
+16 -11
View File
@@ -84,9 +84,12 @@ test('getChess(id, loadout): selected skill (bb, SP, trigger) and module (stats,
assert.deepEqual(s1.stats, def0.stats, 'a skill choice never changes stats');
assert.ok(Object.isFrozen(s1) && Object.isFrozen(s1.skill.bb), 'variant defs are frozen like every def');
assert.equal(C[INSIDE].skill.skillId, 'skchr_inside_2', 'the raw record is never mutated');
// per-index trigger: 角峰 S1 casts when hit (TANK row, skill 1 only); S2 is DEFAULT
// per-skill trigger (PRTS 技能策略): 角峰's MANUAL skills both cast when hit (the 重装 row covers every skill); 古米's
// AUTO S1 keeps its own rule (DEFAULT in data, its kit: an injured ally of the skill range), his MANUAL S2 TAKE_DAMAGE
assert.equal(d.getChess('chess_char_1_02_a', { skillIndex: 0 }).skill.trigger.rule, 'TAKE_DAMAGE');
assert.equal(d.getChess('chess_char_1_02_a', { skillIndex: 1 }).skill.trigger.rule, 'DEFAULT');
assert.equal(d.getChess('chess_char_1_02_a', { skillIndex: 1 }).skill.trigger.rule, 'TAKE_DAMAGE');
assert.equal(d.getChess('chess_char_1_10_a', { skillIndex: 0 }).skill.trigger.rule, 'DEFAULT');
assert.equal(d.getChess('chess_char_1_10_a', { skillIndex: 1 }).skill.trigger.rule, 'TAKE_DAMAGE');
// modules on the elite: statsBase + module attr; trait / talents of that module; 'none' = the base
const g = C[MLYSS];
@@ -358,22 +361,24 @@ test('getToken: def.sources / def.count follow the owner loadout (which summons
});
test('a Battle built WITHOUT the spec path (server-run fields) resolves mid-battle summons with the owner loadout', { skip }, () => {
const CATHY = 'chess_char_4_11_a', SHIELD = 'token_10041_cathy_catsld';
// 缪尔赛思 without a 流形 piece: her 援军 is summoned during the battle (凯瑟琳's devices, the old example, are placed
// pieces since user playtest #6)
const MLYSS = 'chess_char_6_11_a', WTRMAN = 'token_10030_mlyss_wtrman';
const run = (skillIndex) => {
const players = [{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, playerEffects: [],
units: [{ uid: 1, kind: 'chess', chessId: CATHY, row: 10, col: 5, dir: 'RIGHT', ...(skillIndex != null ? { skillIndex } : {}) }] }];
units: [{ uid: 1, kind: 'chess', chessId: MLYSS, row: 10, col: 5, dir: 'RIGHT', ...(skillIndex != null ? { skillIndex } : {}) }] }];
const b = new Battle({ seed: 3, kind: 'normal', stageId: 'act2autochess_m01', timeLimit: 30, players, spawns: [], routes: [], data: freshDs(), recordEvents: false, quiet: true });
for (let i = 0; i < 60 && !b.allyUnits.some((u) => u.defId === SHIELD); i++) b.step();
const t = b.allyUnits.find((u) => u.defId === SHIELD);
assert.ok(t && t.uid == null, 'a device spawned during the battle');
for (let i = 0; i < 60 && !b.allyUnits.some((u) => u.defId === WTRMAN); i++) b.step();
const t = b.allyUnits.find((u) => u.defId === WTRMAN);
assert.ok(t && t.uid == null, 'a 流形 spawned during the battle');
return [b, t];
};
const [b1, t1] = run(0);
assert.ok(isLoadoutView(b1.data), 'installContent gave the battle the loadout view');
assert.equal(b1.allyUnits.find((u) => u.uid === 1).skill.id, C[CATHY].skills[0].skillId);
assert.equal(t1.def.skill.id, 'sktok_cathy_catsld_1', 'the summon follows the owner\'s S1');
const [, t2] = run(1);
assert.equal(t2.def.skill.id, 'sktok_cathy_catsld_2', 'default owner ⇒ default token skill');
assert.equal(b1.allyUnits.find((u) => u.uid === 1).skill.id, C[MLYSS].skills[0].skillId);
assert.equal(t1.def.skill.id, 'sktok_mlyss_wtrman_1', 'the summon follows the owner\'s S1');
const [, t2] = run(null);
assert.equal(t2.def.skill.id, 'sktok_mlyss_wtrman_3', 'default owner (S3) ⇒ default token skill');
// the spec path is not wrapped twice
const spec = normalSpec([{ uid: 1, chessId: INSIDE, row: 10, col: 5, dir: 'RIGHT', skillIndex: 0 }]);
const b3 = createBattleFromSpec(spec, freshDs(), { recordEvents: false, quiet: true });
+4 -3
View File
@@ -201,7 +201,6 @@ function floaterDuel(id, skillIndex, { col = 5, secs = 30 } = {}) {
test('#1 ground-only skills and talents spare a hovering enemy (the worst 731a01c offenders + the PRTS "不可对空" notes)', () => {
const cases = [
['chess_char_6_19_a', 1, 5, '锏 S2 无声的嘲笑'],
['chess_char_6_19_a', 2, 5, '锏 S3 归于宁静'],
['chess_char_6_07_a', 0, 5, '维娜·维多利亚 S1 重铸晖光'],
['chess_char_5_06_a', 2, 5, '隐德来希 S3 灵与欲的惜别'],
['chess_char_6_12_a', 2, 4, '迷迭香 S3 “如你所愿”'],
@@ -217,8 +216,10 @@ test('#1 ground-only skills and talents spare a hovering enemy (the worst 731a01
}
});
test('#1 skills PRTS marks "可对空" still hit a hovering enemy (德克萨斯 S2 剑雨, 焰尾 S2 “红松林”)', () => {
for (const [id, idx, tag, name] of [['chess_char_1_08_a', 1, 'skill', '德克萨斯 S2'], ['chess_char_4_19_a', 1, 'redPine', '焰尾 S2']]) {
test('#1 skills PRTS marks "可对空" still hit a hovering enemy (德克萨斯 S2 剑雨, 焰尾 S2 “红松林”, 锏 S3 归于宁静)', () => {
// 锏 S3: PRTS 备注 "※可对空。不会拖拽自身中心半径0.6708范围内的敌人" (user playtest #6, WF audit — it used to sit in the
// ground-only list above)
for (const [id, idx, tag, name] of [['chess_char_1_08_a', 1, 'skill', '德克萨斯 S2'], ['chess_char_4_19_a', 1, 'redPine', '焰尾 S2'], ['chess_char_6_19_a', 2, 'slash', '锏 S3']]) {
const r = floaterDuel(id, idx);
assert.ok((r.floaterTags.get(tag) || 0) > 0, `${name}: ${JSON.stringify([...r.floaterTags])}`);
}
+23 -19
View File
@@ -168,11 +168,12 @@ test('#4: a blocker attacks the enemy it blocks even when it stands outside its
assert.ok(h.hooksOf('damaged').some((c) => c.source === u && c.target === x()), 'it hits its blocked enemy');
});
test('#4: a ranged operator on a melee tile blocks, but never attacks or casts for the enemy held behind it (阻挡(近战限定))', () => {
// PRTS 索敌的概念: "我方索敌优先级:阻挡(近战限定)"; "远程位干员通常而言不会优先攻击自己阻挡的目标;甚至如果这个目标被阻挡在
// 该干员的身后,该干员可能因为敌人未处于自身攻击范围而无法攻击到这个敌人". Facing LEFT, its range (9,6),(9,5) never holds
// the enemy blocked at x ≈ 6.71; a melee operator in the same spot hits it and casts its DEFAULT skill (帮助: "敌人被近战
// 干员自身阻挡" satisfies the target condition).
test('#4 (revised by the user after playtest #6): a ranged operator on a melee tile also hits and casts for the enemy it holds behind it', () => {
// The playtest #5 QA had made blocked-first melee-only (PRTS 索敌的概念 "阻挡(近战限定)"); the user's rule after
// playtest #6 — "阻挡了就一定要能打到": officially the collision pushes a blocked enemy to its blocker's front, so
// whatever blocks an enemy can hit it. Facing LEFT, the range (9,6),(9,5) never holds the enemy blocked at x ≈ 6.71;
// the ranged blocker hits it and casts its DEFAULT skill like a melee one (帮助: the blocked enemy satisfies the
// target condition).
const run = (id, profession) => {
const h = makeBattle({
defs: { chess: { [id]: chessRec({ id, profession, stats: { atk: 100, blockCnt: 1, maxHp: 1e6 }, skill: { spCost: 1, initSp: 1, duration: 5 } }) }, enemies: { enemy_x: foe('enemy_x') } },
@@ -187,13 +188,12 @@ test('#4: a ranged operator on a melee tile blocks, but never attacks or casts f
checkInvariants(h.b);
return { hits: h.hooksOf('damaged').filter((c) => c.source === u && c.target === x()).length, casts: u.skill.activations, still: x().blockedBy === u };
};
const ranged = run('t_rg', 'SNIPER');
assert.equal(ranged.hits, 0, 'the ranged blocker never hits the enemy behind it');
assert.equal(ranged.casts, 0, 'nor casts its skill for it');
assert.ok(ranged.still, 'it still holds the enemy');
const melee = run('t_ml', 'WARRIOR');
assert.ok(melee.hits > 0, 'a melee blocker hits it');
assert.equal(melee.casts, 1, 'and casts its skill');
for (const [id, profession] of [['t_rg', 'SNIPER'], ['t_ml', 'WARRIOR']]) {
const r = run(id, profession);
assert.ok(r.hits > 0, `${profession}: hits the enemy it blocks behind it`);
assert.equal(r.casts, 1, `${profession}: and casts its skill`);
assert.ok(r.still, `${profession}: it still holds the enemy`);
}
});
test('#4: "melee" is the deploy position — a 领主 with a ranged attack (银灰) still hits the enemy it holds behind it', () => {
@@ -213,10 +213,10 @@ test('#4: "melee" is the deploy position — a 领主 with a ranged attack (银
assert.ok(h.hooksOf('damaged').some((c) => c.source === u && c.target === x()), 'it hits the enemy it blocks');
});
test('#4: blocked-first is a melee priority — a ranged blocker shoots the enemy nearest the goal', () => {
test('#4 (revised after playtest #6): blocked-first holds for every blocker — a ranged blocker keeps shooting the enemy it blocks', () => {
// range (9,5)–(9,7) facing RIGHT: the walker it blocks stands on (9,7); a flyer passes over the operator towards the
// goal. The ranged operator picks by its own order (least remaining distance first), a melee one (able to hit air)
// keeps hitting the enemy it blocks.
// goal. Melee or ranged, the blocker keeps hitting the enemy it blocks (user's rule: the blocked enemy stands in front
// of its blocker officially) — the ranged one no longer switches to the flyer nearer the goal.
const run = (id, o) => {
const h = makeBattle({
defs: {
@@ -232,10 +232,13 @@ test('#4: blocked-first is a melee priority — a ranged blocker shoots the enem
assert.ok(h.runUntil(() => walker()?.blockedBy === u, 30), 'the walker is blocked');
assert.ok(h.runUntil(() => flyer() && flyer().x < 4.4, 30), 'the flyer passed');
assert.equal(walker().blockedBy, u);
return h.hooksOf('damaged').filter((c) => c.source === u && c.target === flyer()).length;
return { fl: h.hooksOf('damaged').filter((c) => c.source === u && c.target === flyer()).length, wk: h.hooksOf('damaged').filter((c) => c.source === u && c.target === walker()).length };
};
assert.ok(run('t_rg2', { profession: 'SNIPER' }) > 0, 'the ranged blocker shot the flyer while it was nearer the goal');
assert.equal(run('t_ml2', { profession: 'WARRIOR', attackKind: 'melee', canHitFly: true }), 0, 'the melee blocker kept to its blocked enemy');
for (const [id, o] of [['t_rg2', { profession: 'SNIPER' }], ['t_ml2', { profession: 'WARRIOR', attackKind: 'melee', canHitFly: true }]]) {
const r = run(id, o);
assert.equal(r.fl, 0, `${o.profession}: kept to its blocked enemy`);
assert.ok(r.wk > 0, `${o.profession}: hit the walker it blocks`);
}
});
test('#4: a skywalker (起飞: blockFly) blocks flyers at the air block radius 0.8944', () => {
@@ -257,7 +260,8 @@ test('#4: 瑕光 S2 puts the enemy she blocks to sleep although it stands outsid
const id = 'chess_char_3_12_a';
const skillIndex = ds.rawChess(id).skills.find((s) => s.skillId === 'skchr_blemsh_2').index;
const h = makeBattle({
defs: { enemies: { enemy_x: foe('enemy_x') } }, timeLimit: 60,
// (a MANUAL 重装 skill: cast by the blocked enemy's hit — TAKE_DAMAGE)
defs: { enemies: { enemy_x: foe('enemy_x', { atk: 100, bat: 1 }) } }, timeLimit: 60,
units: [{ chessId: id, row: 9, col: 6, skillIndex }], enemies: [{ key: 'enemy_x', time: 0 }],
});
const u = h.unit(id);
+606
View File
@@ -0,0 +1,606 @@
// User playtest #6, workstream WF (combat rules) — reports #17, #14, #18.
//
// #17 "两个干员之间卡了敌人,靠后的干员死亡之后敌人确实会被靠前的干员阻挡了,但是只有敌人攻击干员,靠前的干员不会攻击那个敌人":
// PRTS 选择器 "可以选择且优先选择阻挡单位" — and the user's rule after playtest #6, "阻挡了就一定要能打到": officially the
// collision pushes a blocked enemy to its blocker's front, so EVERY blocker — a ranged operator on a melee tile included
// (the playtest #5 QA had made that melee-only, PRTS 索敌的概念 "阻挡(近战限定)") — attacks the enemies it blocks, in
// range or not, whatever its facing, and takes them first. The real sim does for every blocking chess of the pool in
// a plain block and in the user's layout, whether the take-over happens at once or once the enemy slid behind the
// front operator's tile (the regressions below); only healers and 停止攻击 skills never hit it.
// Fixed on the way: 流形's melee copy (data position ALL) ignored the enemy it held outside its range; heavy slows
// (沼泽 −50 + 寒霜 −50) took ASPD down to 10 (interval 10 × BAT) — officially ASPD never drops below 20 (PRTS 数值范围
// ATTACK_SPEED 默认下限 20; 游戏数据基础 "攻击速度属性实际被限制了下限为20"), so it attacks every 5 × BAT.
// #14 "敌人的重量以及可以推动敌人的干员技能的力度…感觉野鬃干员开技能能把推动好多敌人": official 位移 = 力度 − 重量 (PRTS 游戏数据基础
// §重量公式, 推与拉): a 中力 push moves weight 0 / 1 / 2 / 3 enemies 2.14 / 1.7 / 0.44 / 0.12 tiles and heavier ones not at
// all; the remake pushed 1 tile scaled by 25 % per missing level, so weight 3–5 enemies still flew 0.75–0.25 tiles.
// #18 "炎祐错误的吃到了医疗干员造成的治疗": PRTS “炎佑” 天赋 "{{特殊机制|我方单位}},{{异常效果|孤立}}" — 孤立 = "无法被同阵营选中"
// (PRTS 异常效果 ALLY_TARGET_FREE); summons holding 禁疗 (HEAL_FREE) are never healed either.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource, hasGeneratedData } from '../../server/sim/simdata.js';
import { effectiveProfile } from '../../server/sim/ai.js';
import { PUSH_TILES, PUSH_TILES_EFFECT, PULL_STOP_RADIUS, ASPD_MIN, COLS } from '../../server/sim/constants.js';
import { alliesInGridOf } from '../../server/sim/content/kits/tier1.js';
import { Unit } from '../../server/sim/units.js';
import { spawnYanyou, TOKEN_IDS } from '../../server/sim/content/tokens.js';
const REAL = { skip: !hasGeneratedData() };
const approx = (a, b, eps, msg) => assert.ok(Math.abs(a - b) <= eps, `${msg ?? ''} ${a} ≈ ${b}`);
// =====================================================================================================================
// #17 — the enemy held between two operators
const REAR = 'test_rear_a';
const foe = (key, o = {}) => enemyRec({ key, hp: 1e8, speed: 1, atk: 0, ...o });
/**
* The user's layout on act2 m01: the lane goes up column 7 from the lower gate row; the front operator F stands on
* (10,7), the rear one R on (11,7). F's block is filled by decoys so the next enemy E walks through it and is held by R —
* between the two. The decoys die, then R dies: F takes E over. Returns { h, F, E, hits, couldAttack } over `secs` s.
*/
function heldBetween(chessId, dir, secs = 8, { late = false } = {}) {
const ds = getDefaultSource();
const cap = ds.rawChess(chessId).stats.blockCnt;
const enemies = { enemy_e: foe('enemy_e') };
const spawns = [];
for (let i = 0; i < cap; i++) { enemies[`enemy_x${i}`] = foe(`enemy_x${i}`); spawns.push({ key: `enemy_x${i}`, time: i * 0.6 }); }
spawns.push({ key: 'enemy_e', time: cap * 0.6 + 0.3 });
const h = makeBattle({
stageId: 'act2autochess_m01', timeLimit: 200, hooks: ['damaged'], captureNoisy: true,
defs: { chess: { [REAR]: chessRec({ id: REAR, stats: { atk: 0, blockCnt: 1, maxHp: 1e9, respawnTime: 999 }, skill: null }) }, enemies },
units: [{ chessId, row: 10, col: 7, dir }, { chessId: REAR, row: 11, col: 7, dir }],
enemies: spawns,
});
const F = h.unit(chessId), R = h.unit(REAR);
const decoys = () => h.b.enemies.filter((e) => e.defId.startsWith('enemy_x'));
const E = () => h.b.enemies.find((e) => e.defId === 'enemy_e');
const held = h.runUntil(() => decoys().length === cap && decoys().every((x) => x.blockedBy === F) && E()?.blockedBy === R, 40);
if (!held) return null; // (a summoner's own summon took a decoy: not this layout)
const e = E();
if (!late) {
for (const x of decoys()) h.b.loseHp(x, 1e12);
h.step(2);
h.b.loseHp(R, 1e12);
} else {
// the rear operator dies while the front one is still full: the enemy walks on past it, and the front one frees a
// slot once the enemy is beyond its own tile but still inside the contact radius (0.52–0.69 tile from its centre)
h.b.loseHp(R, 1e12);
let ok = false;
for (let i = 0; i < 120 && e.alive; i++) {
h.step();
const d = Math.hypot(e.x - F.x, e.y - F.y);
if (d > 0.52 && d < 0.69) { ok = true; break; }
}
if (!ok) return null;
for (const x of decoys()) h.b.loseHp(x, 1e12);
}
h.step(2);
const t0 = h.b.time;
let couldAttack = 0, inRange = false;
const end = t0 + secs;
while (h.b.time < end && e.alive) {
h.step();
const p = effectiveProfile(F);
if (F.canAct && !p.noAttack && !(p.noAttackUnlessSkill && !F.skill?.active) && !F.s.flags.disarm && p.dmgType !== 'heal' && e.blockedBy === F) couldAttack += 1 / 30;
if (F.rangeKeySet?.has(Math.round(e.y) * COLS + Math.round(e.x))) inRange = true;
}
const hits = h.hooksOf('damaged').filter((c) => c.source === F && c.target === e && c.t > t0).length;
return { h, F, E: e, hits, couldAttack, inRange };
}
test('#17 the user\'s case on act2 m01: the front operator takes over the enemy held between the two AND attacks it — every blocking chess, 4 directions', REAL, () => {
// the held enemy stands ≈ 0.29 tile behind the front operator's centre, on its own tile: a melee unit may always hit
// it (blocked-first), a ranged one on a melee tile has it in range too (its own tile) and hits it by its own order
const ds = getDefaultSource();
const blockers = Object.values(ds.raw.chess).filter((c) => c.stats && c.visible !== false && !c.isGolden && c.stats.blockCnt > 0);
assert.ok(blockers.filter((c) => c.position === 'MELEE').length >= 50, 'every melee blocker of the pool');
const branches = new Set();
let checked = 0;
for (const c of blockers) {
for (const dir of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
const r = heldBetween(c.chessId, dir);
if (!r) continue;
checked++;
branches.add(c.subProfessionId);
if (r.E.alive && r.E.blockedBy !== r.F) {
// only a status that cannot be blocked (妮芙's 恐惧, 浮空 …) or a displacement out of contact lets it go
const f = r.E.s.flags;
assert.ok(f.fear || f.unblockable || f.levitate || f.attract || Math.hypot(r.E.x - r.F.x, r.E.y - r.F.y) > 0.7071, `${c.name} ${dir}: takes the enemy over`);
continue;
}
// healers, and skills that say 停止攻击 (泡泡 “挨打”, 凯瑟琳 战火淬炼 …) / attack only while active (阵法术师), are the
// only reasons not to hit it
if (r.couldAttack >= 2) assert.ok(r.hits > 0, `${c.name} (${c.subProfessionId}, ${c.position}) ${dir}: attacks the enemy it took over (could attack ${r.couldAttack.toFixed(1)} s)`);
}
}
assert.ok(checked >= 350, `layouts checked: ${checked}`);
assert.ok(branches.size >= 45, `branches: ${branches.size}`);
});
test('#17 a late take-over (the enemy slid behind the front operator\'s tile): every blocker hits it, ranged ones on melee tiles included (the user\'s rule)', REAL, () => {
// the rear operator dies while the front one is full; the front one frees a slot when the enemy is 0.52–0.69 tile past
// its centre (outside its tile, inside the 0.7071 contact radius) and takes it over. Every blocking chess of the pool,
// the hidden ones too (见行者, 崖心, 百炼嘉维尔), × 4 directions
const ds = getDefaultSource();
const blockers = Object.values(ds.raw.chess).filter((c) => c.stats && !c.isGolden && c.stats.blockCnt > 0 && (c.visible !== false || c.isHidden));
assert.ok(blockers.some((c) => c.isHidden && c.position === 'MELEE'), 'hidden melee chess included');
let melee = 0, ranged = 0, rangedOut = 0;
const branches = new Set();
for (const c of blockers) {
for (const dir of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
const r = heldBetween(c.chessId, dir, 8, { late: true });
if (!r || r.couldAttack < 2) continue; // (no take-over, or a 停止攻击 skill / healer: never attacks)
branches.add(c.subProfessionId);
if (c.position === 'MELEE') melee++; else { ranged++; if (!r.inRange) rangedOut++; }
assert.ok(r.hits > 0, `${c.name} (${c.subProfessionId}, ${c.position}) ${dir}: hits the enemy it took over behind its tile${r.inRange ? '' : ', out of its range'} (could attack ${r.couldAttack.toFixed(1)} s)`);
}
}
assert.ok(melee >= 150, `melee take-overs checked: ${melee}`);
assert.ok(ranged >= 10 && rangedOut >= 10, `ranged take-overs checked: ${ranged} (${rangedOut} out of range)`);
assert.ok(branches.size >= 40, `branches: ${branches.size}`);
});
/**
* A plain block on act2 m01: the blocker alone on (10,7) facing `dir`, one walker coming up the lane. Returns
* { F, E, hits, couldAttack, inRange } over `secs` s once it blocks, or null (a summon of its own took the walker).
*/
function plainBlock(chessId, dir, secs = 8) {
const h = makeBattle({
stageId: 'act2autochess_m01', timeLimit: 200, hooks: ['damaged'], captureNoisy: true,
defs: { enemies: { enemy_e: foe('enemy_e') } },
units: [{ chessId, row: 10, col: 7, dir }],
enemies: [{ key: 'enemy_e', time: 0.3 }],
});
const F = h.unit(chessId);
const E = () => h.b.enemies.find((e) => e.defId === 'enemy_e');
if (!h.runUntil(() => E()?.blockedBy === F, 40)) return null;
const e = E();
const t0 = h.b.time;
let couldAttack = 0, inRange = true;
while (h.b.time < t0 + secs && e.alive) {
h.step();
const p = effectiveProfile(F);
if (F.canAct && !p.noAttack && !(p.noAttackUnlessSkill && !F.skill?.active) && !F.s.flags.disarm && p.dmgType !== 'heal' && e.blockedBy === F) couldAttack += 1 / 30;
if (!F.rangeKeySet?.has(Math.round(e.y) * COLS + Math.round(e.x))) inRange = false;
}
const hits = h.hooksOf('damaged').filter((c) => c.source === F && c.target === e && c.t > t0).length;
return { F, E: e, hits, couldAttack, inRange };
}
test('#17 a plain block, every blocking chess × 4 facings: the blocker damages the enemy it blocks — in range or not (the user\'s rule)', REAL, () => {
// "阻挡了就一定要能打到" (user, after playtest #6): melee and ranged blockers alike, whichever way they face; healers and
// skills that stop attacking (泡泡 “挨打”, 凯瑟琳 战火淬炼 … — couldAttack) are the only exceptions
const ds = getDefaultSource();
const blockers = Object.values(ds.raw.chess).filter((c) => c.stats && !c.isGolden && c.stats.blockCnt > 0 && (c.visible !== false || c.isHidden));
let checked = 0, out = 0, ranged = 0;
for (const c of blockers) {
for (const dir of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
const r = plainBlock(c.chessId, dir);
if (!r || r.couldAttack < 2) continue;
checked++;
if (!r.inRange) out++;
if (c.position !== 'MELEE') ranged++;
assert.ok(r.hits > 0, `${c.name} (${c.subProfessionId}, ${c.position}) ${dir}: hits the enemy it blocks${r.inRange ? '' : ' (out of its range)'} — could attack ${r.couldAttack.toFixed(1)} s`);
}
}
assert.ok(checked >= 350, `blocks checked: ${checked}`);
assert.ok(out >= 50, `blocked enemies outside the blocker's range at some point: ${out}`);
assert.ok(ranged >= 20, `ranged blockers: ${ranged}`);
});
test('#17 skills that pick their own targets keep the enemy the operator blocks (QA residual): 迷迭香 S3, 荒芜拉普兰德 S2, 佩佩 S2', REAL, () => {
// the flat stage: the blocker on (9,6) faces LEFT, so the walker it stops coming from the gate (its right) stays outside
// its range while a still enemy on (9,4) stands inside it. These skills chose from the range only and never hit the
// blocked one again; 普罗旺斯 S2 keeps its official text ("普通攻击不再以生命值高于80%的敌人作为目标") — the exception
const ds = getDefaultSource();
const cases = [['迷迭香', 'skchr_rosmon_3', true], ['荒芜拉普兰德', 'skchr_whitw2_2', true], ['佩佩', 'skchr_pepe_2', true], ['普罗旺斯', 'skchr_prove_2', false]];
for (const [name, skillId, hitsIt] of cases) {
for (const gold of [false, true]) {
const c = Object.values(ds.raw.chess).find((x) => x.name === name && !!x.isGolden === gold);
const skillIndex = c.skills.findIndex((s) => s.skillId === skillId);
assert.ok(skillIndex >= 0, `${name} ${skillId}`);
const h = makeBattle({
timeLimit: 400, hooks: ['damaged'], captureNoisy: true,
defs: { enemies: { enemy_e: foe('enemy_e'), enemy_o: foe('enemy_o', { speed: 0 }) } },
units: [{ chessId: c.chessId, row: 9, col: 6, dir: 'LEFT', skillIndex }],
enemies: [{ key: 'enemy_e', time: 0.2 }, { key: 'enemy_o', time: 0.1, route: { motion: 'WALK', start: [9, 4], end: [9, 2], checkpoints: [] } }],
});
const F = h.unit(c.chessId);
const E = () => h.b.enemies.find((x) => x.defId === 'enemy_e');
assert.ok(h.runUntil(() => E()?.blockedBy === F, 40), `${name}: blocks the walker`);
const e = E();
h.step(30);
if (!F.skill.active) F.skill.activate('manual', { free: true });
assert.ok(F.skill.active && F.skill.id === skillId, `${name}: ${skillId} on`);
const t0 = h.b.time;
let outside = true;
while (h.b.time < t0 + 6) {
h.step();
if (F.rangeKeySet?.has(Math.round(e.y) * COLS + Math.round(e.x))) outside = false;
}
assert.ok(outside, `${name}: the blocked walker stays outside the range`);
const hits = h.hooksOf('damaged').filter((x) => x.t > t0 && x.source === F && x.target === e).length;
if (hitsIt) assert.ok(hits > 0, `${name}${gold ? ' (golden)' : ''} ${skillId}: hits the enemy it blocks (${hits})`);
else assert.equal(hits, 0, `${name} ${skillId}: official — no normal attack on an enemy above 80 % HP, blocked or not`);
checkInvariants(h.b);
}
}
});
test('#17 流形\'s melee copy (a summon of no fixed position) attacks the enemy it blocks outside its range', REAL, () => {
// 缪尔赛思's 流形 copies the melee guard; facing LEFT its copied range (its tile + the one to its left) never holds the
// enemy it stops coming from the gate side (≈ 0.71 tile to its right)
const guard = chessRec({ id: 'test_guard_a', profession: 'WARRIOR', position: 'MELEE', stats: { atk: 500, blockCnt: 2, maxHp: 1e6, def: 0 }, skill: null });
const h = makeBattle({
defs: { chess: { test_guard_a: guard }, enemies: { enemy_w: foe('enemy_w') } }, timeLimit: 120, hooks: ['damaged'], captureNoisy: true,
units: [
{ chessId: 'chess_char_6_11_a', row: 12, col: 3 },
{ chessId: 'test_guard_a', row: 12, col: 6 },
{ kind: 'token', tokenId: 'token_10030_mlyss_wtrman', row: 9, col: 6, ownerUid: 1, dir: 'LEFT' },
],
enemies: [{ key: 'enemy_w', time: 9 }],
});
const tok = () => h.b.allyUnits.find((x) => x.defId === 'token_10030_mlyss_wtrman' && x.alive);
assert.ok(h.runUntil(() => tok()?.mem.mlyss && !tok().mem.mlyss.ranged, 9), 'a melee copy');
const e = () => h.b.enemies.find((x) => x.defId === 'enemy_w');
assert.ok(h.runUntil(() => e()?.blockedBy === tok(), 30), 'the copy blocks the walker');
const t = tok();
assert.ok(!t.rangeKeySet.has(Math.round(e().y) * 21 + Math.round(e().x)), 'outside its range (behind its facing)');
const t0 = h.b.time;
h.run(4);
assert.ok(h.hooksOf('damaged').some((c) => c.source === t && c.target === e() && c.t > t0), 'it hits the enemy it blocks');
checkInvariants(h.b);
});
test('#17 heavy slows: ASPD never drops below 20 (PRTS 数值范围) — two −50 slows on a BAT 1.2 unit give a 6 s interval, not 12 s', () => {
assert.equal(ASPD_MIN, 20);
const u = new Unit({ id: 1, side: 'ally', kind: 'op', base: { maxHp: 1000, atk: 100, def: 0, res: 0, aspd: 100, bat: 1.2 } });
u.alive = true;
u.buffs.push({ key: 'terrain:mire', stacks: 1, mods: { aspd: -50 } }, { key: 'ab:frost', stacks: 1, mods: { aspd: -50 } });
u.markDirty();
assert.equal(u.s.aspd, 20);
approx(u.s.interval, 6, 1e-9, 'interval = 1.2 × 100 / 20');
u.buffs.push({ key: 'more', stacks: 1, mods: { aspd: -300 } });
u.markDirty();
assert.equal(u.s.aspd, 20, 'still 20 under any slow');
});
test('#17 the user\'s case under heavy slows: the front blocker that took the enemy over hits it every 5 × BAT', () => {
// the held enemy between two operators on act2 m01 (as above); the front one stands on 沼泽 next to a 寒霜 aura
// (ASPD −50 each): officially it still attacks every 1.2 × 100 / 20 = 6 s — v2.4.1 swung every 12 s
const front = chessRec({ id: 'test_front_a', profession: 'TANK', subProfessionId: 'guardian', position: 'MELEE', stats: { atk: 100, def: 0, blockCnt: 1, maxHp: 1e9, bat: 1.2 }, skill: null });
const rear = chessRec({ id: REAR, stats: { atk: 0, blockCnt: 1, maxHp: 1e9, respawnTime: 999 }, skill: null });
const h = makeBattle({
stageId: 'act2autochess_m01', timeLimit: 200, hooks: ['damaged'], captureNoisy: true,
defs: { chess: { test_front_a: front, [REAR]: rear }, enemies: { enemy_x0: foe('enemy_x0'), enemy_e: foe('enemy_e') } },
units: [{ chessId: 'test_front_a', row: 10, col: 7 }, { chessId: REAR, row: 11, col: 7 }],
enemies: [{ key: 'enemy_x0', time: 0 }, { key: 'enemy_e', time: 0.9 }],
});
const F = h.unit('test_front_a'), R = h.unit(REAR);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x0');
const E = () => h.b.enemies.find((e) => e.defId === 'enemy_e');
assert.ok(h.runUntil(() => x()?.blockedBy === F && E()?.blockedBy === R, 40), 'held between the two');
h.b.loseHp(x(), 1e12);
h.b.loseHp(R, 1e12);
h.step(2);
assert.equal(E().blockedBy, F, 'taken over');
h.b.addBuff(F, { key: 'terrain:mire', persist: true, mods: { aspd: -50 } });
h.b.addBuff(F, { key: 'ab:frost', persist: true, mods: { aspd: -50 } });
assert.equal(F.s.aspd, 20);
const t0 = h.b.time;
h.run(30);
const hits = h.hooksOf('damaged').filter((c) => c.source === F && c.target === E() && c.t > t0).length;
assert.ok(hits >= 4 && hits <= 6, `≈ 30 / 6 hits in 30 s (got ${hits}; ASPD 10 would give ≤ 3)`);
});
// =====================================================================================================================
// #14 — 力度 vs 重量
const PUSH = [[-3, 0], [-2, 0.12], [-1, 0.44], [0, 1.7], [1, 2.14], [2, 2.96], [3, 3.53], [4, 3.53]];
function lane(extraEnemies = {}, units = []) {
const enemies = {};
for (let m = 0; m <= 10; m++) enemies[`enemy_m${m}`] = foe(`enemy_m${m}`, { speed: 0, mass: m });
return makeBattle({ defs: { enemies: { ...enemies, ...extraEnemies } }, units, content: 'full', autoFinish: false, timeLimit: 60, hooks: ['damaged'], captureNoisy: true });
}
test('#14 the official push table: 受力等级 = 力度 − 重量 (PRTS 游戏数据基础 §重量公式 / 推与拉)', () => {
for (const [lvl, tiles] of PUSH) {
if (lvl >= -2 && lvl <= 3) approx(PUSH_TILES[lvl], tiles, 1e-9, `table ${lvl}`);
}
const h = lane();
h.step();
for (let m = 0; m <= 6; m++) {
for (const force of [-1, 0, 1, 2, 3]) {
const e = h.spawn(`enemy_m${m}`, { pos: [10, 3] });
assert.equal(h.b.forceLevel(e, force), force - m);
const want = PUSH.find(([l]) => l === Math.max(-3, Math.min(4, force - m)))[1];
const moved = h.b.push(e, force, { from: { x: 2, y: 10 } });
approx(moved, want, 1e-9, `force ${force} vs weight ${m}`);
approx(e.x, 3 + want, 1e-9, 'radial: straight away from the pusher');
h.b.loseHp(e, 1e12);
}
}
// 失重 counts (current weight); 失衡免疫 and leaders never move
const w = h.spawn('enemy_m3', { pos: [10, 3] });
h.b.addBuff(w, { key: 'weightless', mods: { massFlat: -1 } });
approx(h.b.push(w, 1, { from: { x: 2, y: 10 } }), 0.44, 1e-9, '失重 weight 3 → 2');
const f = h.spawn('enemy_m0', { pos: [11, 3] });
h.b.addBuff(f, { key: 'noDisp', flags: { noDisplace: true } });
assert.equal(h.b.push(f, 5, { from: { x: 2, y: 11 } }), 0, '失衡免疫');
checkInvariants(h.b);
});
test('#14 directional pushes: > 45° off the direction or nearer than 0.25 → radial with 受力等级 −2, unless fixed (见行者 / 圣聆初雪)', () => {
const h = lane();
h.step();
const from = { x: 3, y: 10 }, right = { x: 1, y: 0 };
const a = h.spawn('enemy_m1', { pos: [10, 4] });
approx(h.b.push(a, 1, { from, dir: right }), 1.7, 1e-9, 'straight ahead: 中力 vs 1 = 1.7 along the direction');
approx(a.y, 10, 1e-9);
const b = h.spawn('enemy_m1', { pos: [11, 3.5] }); // 63° off the direction
const moved = h.b.push(b, 1, { from, dir: right });
approx(moved, 0.12, 1e-9, 'radial, 受力等级 0 − 2 = −2');
assert.ok(b.y > 11, 'pushed away from the pusher, not along its direction');
const c = h.spawn('enemy_m1', { pos: [11, 3.5] });
approx(h.b.push(c, 1, { from, dir: right, fixed: true }), 1.7, 1e-9, 'fixed: along the direction, full force');
approx(c.y, 11, 1e-9);
// 见行者 S2 (PRTS 备注 waives only the angle): 63° off → along the direction at full force; nearer than 0.25 → radial −2
const d = h.spawn('enemy_m1', { pos: [12, 3.5] });
approx(h.b.push(d, 1, { from: { x: 3, y: 11 }, dir: right, fixedAngle: true }), 1.7, 1e-9, 'fixedAngle: angle waived');
approx(d.y, 12, 1e-9);
const n = h.spawn('enemy_m1', { pos: [9, 3.1] });
approx(h.b.push(n, 1, { from: { x: 3, y: 9 }, dir: right, fixedAngle: true }), 0.12, 1e-9, 'fixedAngle: < 0.25 tile still radial −2');
const n2 = h.spawn('enemy_m1', { pos: [8, 3.1] });
approx(h.b.push(n2, 1, { from: { x: 3, y: 8 }, dir: right, fixed: true }), 1.7, 1e-9, 'fixed (圣聆初雪): < 0.25 tile waived too');
checkInvariants(h.b);
});
test('#14 见行者 S2 惊爆射击: angle waived, a target nearer than 0.25 tile radial −2 — and only a real wall lengthens the stun', REAL, () => {
const id = 'chess_char_3_07_a';
const ds = getDefaultSource();
const bb = ds.rawChess(id).skill.bb;
const run = (pos) => {
const h = makeBattle({
defs: { enemies: { enemy_t: foe('enemy_t', { speed: 0, mass: 0 }) } }, units: [{ chessId: id, row: 10, col: 3, dir: 'RIGHT', carryState: { sp: 1e3 } }],
enemies: [{ key: 'enemy_t', pos }], autoFinish: false, timeLimit: 30, hooks: ['statusApplied'], captureNoisy: true,
});
const u = h.unit(id);
assert.ok(h.runUntil(() => h.hooksOf('statusApplied').some((c) => c.source === u && c.status === 'stun'), 6), 'cast');
const st = h.hooksOf('statusApplied').find((c) => c.source === u && c.status === 'stun');
return { e: h.b.enemies[0], stun: st.duration };
};
// straight ahead, open ground: 中力 vs weight 0 = 受力等级 1 — a 特效 push (PRTS 推与拉 names 见行者's skills 特效类):
// 1.987 tiles along her direction, the short stun
const a = run([10, 4]);
approx(a.e.x, 4 + PUSH_TILES_EFFECT[1], 1e-6, 'ahead: 1.987');
approx(a.stun, bb['forcer_s_2[hit_directly].stun'], 1e-9, 'open ground: hit_directly stun');
// on her own tile, nearer than 0.25: radial at 受力等级 1 − 2 = −1 (0.374), still the short stun (no wall)
const b = run([10.1, 3.1]);
approx(Math.hypot(b.e.x - 3.1, b.e.y - 10.1), PUSH_TILES_EFFECT[-1], 1e-6, 'near: radial −2');
approx(b.stun, bb['forcer_s_2[hit_directly].stun'], 1e-9, 'near target: not a wall');
});
test('#14 特效 pushes (PRTS 推与拉: one frame less of travel than 弹道 ones): the 特效 column, and 见行者 S1 护身射击 uses it', REAL, () => {
const EFFECT = [[-3, 0], [-2, 0.085], [-1, 0.374], [0, 1.562], [1, 1.987], [2, 2.773], [3, 3.331], [4, 3.331]];
const h = lane();
h.step();
for (let m = 0; m <= 4; m++) {
const e = h.spawn(`enemy_m${m}`, { pos: [10, 3] });
const want = EFFECT.find(([l]) => l === Math.max(-3, Math.min(4, 1 - m)))[1];
approx(h.b.pushDistance(e, 1, { effect: true }), want, 1e-9, `distance: 中力 vs weight ${m}`);
approx(h.b.push(e, 1, { from: { x: 2, y: 10 }, effect: true }), want, 1e-9, `push: 中力 vs weight ${m}`);
h.b.loseHp(e, 1e12);
}
// 见行者 S1 (generic kit, 推击手 directional push on her next attack, bb.force 1) on a weight-0 enemy ahead: 1.987, not 2.14
const id = 'chess_char_3_07_a';
const k = makeBattle({
defs: { enemies: { enemy_t: foe('enemy_t', { speed: 0, mass: 0 }) } }, units: [{ chessId: id, row: 10, col: 3, dir: 'RIGHT', skillIndex: 0, carryState: { sp: 1e3 } }],
enemies: [{ key: 'enemy_t', pos: [10, 4] }], autoFinish: false, timeLimit: 30,
});
const u = k.unit(id);
assert.equal(u.skill.id, 'skchr_forcer_1');
k.step();
const e = k.b.enemies[0];
assert.ok(e && k.runUntil(() => e.x > 4.01, 6), 'pushed');
k.step(2);
approx(e.x, 4 + PUSH_TILES_EFFECT[1], 1e-6, '特效 column');
});
test('#14 锏 S3 归于宁静 slashes and pulls air units too (PRTS 备注 "※可对空。不会拖拽自身中心半径0.6708范围内的敌人")', REAL, () => {
const id = 'chess_char_6_19_a';
const h = makeBattle({
defs: { enemies: { enemy_fly: foe('enemy_fly', { speed: 0, mass: 0, motion: 'FLY' }), enemy_near: foe('enemy_near', { speed: 0, mass: 0 }) } },
units: [{ chessId: id, row: 10, col: 4, dir: 'RIGHT' }], enemies: [{ key: 'enemy_fly', pos: [10, 6] }, { key: 'enemy_near', pos: [10, 4.5] }],
autoFinish: false, timeLimit: 30, hooks: ['damaged'], captureNoisy: true,
});
h.step();
const u = h.unit(id);
assert.equal(u.skill.id, 'skchr_blkkgt_3');
const fly = h.b.enemies.find((x) => x.defId === 'enemy_fly'), near = h.b.enemies.find((x) => x.defId === 'enemy_near');
assert.ok(fly.isFlying);
assert.ok(u.skill.activate('test', { free: true }), 'S3 on');
h.run(4.5);
const slashes = h.hooksOf('damaged').filter((c) => c.source === u && c.target === fly && c.dmg?.isSkill).length;
assert.ok(slashes >= 10, `the flyer is slashed (${slashes} skill hits)`);
// 中力 pull vs weight 0 (受力等级 1): all the way, to the 急停 radius around her centre
approx(Math.hypot(fly.x - u.x, fly.y - u.y), PULL_STOP_RADIUS, 1e-6, 'pulled in to 0.6708 from her centre');
approx(near.x, 4.5, 1e-9, 'an enemy inside the 0.6708 radius is not pulled');
});
test('#14 pulls: 受力等级 ≥ 0 all the way to the 急停 radius, −1 → 35 % of the way, −2 → 0.03, ≤ −3 → nothing; the puller\'s own blocked enemy stays', () => {
const guard = chessRec({ id: 'test_puller_a', stats: { atk: 0, blockCnt: 0, maxHp: 1e6 }, skill: null });
const h = makeBattle({ defs: { chess: { test_puller_a: guard }, enemies: Object.fromEntries([0, 1, 2, 3, 4].map((m) => [`enemy_m${m}`, foe(`enemy_m${m}`, { speed: 0, mass: m })])) },
units: [{ chessId: 'test_puller_a', row: 10, col: 3 }], content: 'none', autoFinish: false, timeLimit: 60 });
h.step();
const u = h.unit('test_puller_a');
const start = 7;
const expect = { 0: start - 3 - PULL_STOP_RADIUS, 1: start - 3 - PULL_STOP_RADIUS, 2: 0.35 * (start - 3.5), 3: 0.03, 4: 0 };
for (const m of [0, 1, 2, 3, 4]) {
const e = h.spawn(`enemy_m${m}`, { pos: [10, start] });
approx(h.b.pullToFront(e, u, 1), expect[m], 1e-6, `中力 vs weight ${m}`);
h.b.loseHp(e, 1e12);
}
const g = h.spawn('enemy_m0', { pos: [10, 3.7] });
g.blockedBy = u; u.blocking.push(g);
assert.equal(h.b.pullToFront(g, u, 3), 0, 'already held in front of it');
assert.equal(g.blockedBy, u);
});
test('#14 流形 S3 pulse: the enemy a melee copy blocks is not pulled (so not released) and is stunned at every pulse', REAL, () => {
const guard = chessRec({ id: 'test_guard_a', profession: 'WARRIOR', position: 'MELEE', stats: { atk: 500, blockCnt: 2, maxHp: 1e6, def: 0 }, skill: null });
for (const mass of [0, 2]) {
const h = makeBattle({
defs: { chess: { test_guard_a: guard }, enemies: { enemy_w: foe('enemy_w', { mass }) } }, timeLimit: 120, captureNoisy: true,
units: [
{ chessId: 'chess_char_6_11_a', row: 12, col: 3, skillIndex: 2 },
{ chessId: 'test_guard_a', row: 12, col: 6 },
{ kind: 'token', tokenId: 'token_10030_mlyss_wtrman', row: 9, col: 6, ownerUid: 1, dir: 'LEFT' },
],
enemies: [{ key: 'enemy_w', time: 9 }],
});
const tok = () => h.b.allyUnits.find((x) => x.defId === 'token_10030_mlyss_wtrman' && x.alive);
assert.ok(h.runUntil(() => tok()?.mem.mlyss && !tok().mem.mlyss.ranged, 9), 'a melee copy');
const E = () => h.b.enemies.find((x) => x.defId === 'enemy_w');
assert.ok(h.runUntil(() => E()?.blockedBy === tok(), 30), 'blocked by the copy');
const u = h.unit('chess_char_6_11_a');
assert.equal(u.skill.id, 'skchr_mlyss_3');
assert.ok(u.skill.activate('test', { free: true }), 'S3 on');
const e = E(), x0 = e.x, y0 = e.y;
let stunned = 0, released = 0;
for (let i = 0; i < 30 * 6; i++) { h.step(); if (e.s.flags.stun) stunned++; if (e.blockedBy !== tok()) released++; }
assert.equal(released, 0, `weight ${mass}: stays blocked`);
assert.equal(stunned, 180, `weight ${mass}: stunned through every pulse`);
approx(Math.hypot(e.x - x0, e.y - y0), 0, 1e-9, 'not pulled');
}
});
test('#14 the user\'s case: 野鬃 S2 夹枪冲锋 pushes by 力度 − 重量 — light enemies far, weight ≥ 4 not at all', REAL, () => {
const ds = getDefaultSource();
const id = 'chess_char_1_19_a';
assert.equal(ds.rawChess(id).skill.bb['attack@force'], 1, 'data: 中等力度 = 1');
// the displacement of her first skill hit: the enemy's position before the attack and right after it (the melee hit and
// its push resolve inside the attack)
const moved = (mass) => {
const pushes = [];
const h = makeBattle({
defs: { enemies: { enemy_t: foe('enemy_t', { speed: 0, mass }) } }, units: [{ chessId: id, row: 10, col: 3, carryState: { sp: 1e3 } }],
enemies: [{ key: 'enemy_t', pos: [10, 4] }], autoFinish: false, timeLimit: 30, hooks: [],
setup(b) {
let x0 = null;
b.on('beforeAttack', (c) => { if (c.attacker.defId === id && c.isSkill) x0 = c.targets[0]?.x ?? null; }, { priority: -2000 });
b.on('attack', (c) => { if (c.attacker.defId === id && c.isSkill && x0 != null) { pushes.push(c.targets[0].x - x0); x0 = null; } }, { priority: -2000 });
},
});
const u = h.unit(id);
assert.ok(h.runUntil(() => pushes.length > 0, 5), 'a skill hit');
assert.ok(u.skill.active);
return pushes[0];
};
const want = { 0: 2.14, 1: 1.7, 2: 0.44, 3: 0.12, 4: 0, 5: 0, 6: 0 };
for (const [m, d] of Object.entries(want)) {
const got = moved(Number(m));
approx(got, d, 1e-6, `weight ${m}`);
}
});
// =====================================================================================================================
// #18 — 炎佑 (孤立) and the 禁疗 summons are never healed
const medic = (o = {}) => chessRec({
id: 'test_medic_a', profession: 'MEDIC', subProfessionId: 'physician', position: 'RANGED', dmgType: 'heal',
rangeGrid: [...Array(9)].flatMap((_, r) => [...Array(21)].map((__, c) => [r - 4, c - 10])), stats: { atk: 400, maxHp: 1e6, blockCnt: 1, bat: 0.5 }, skill: null, ...o,
});
test('#18 炎佑 holds 孤立 (PRTS “炎佑” 天赋): medics never heal it, auras never pick it — while they heal an injured operator', REAL, () => {
const ds = getDefaultSource();
assert.deepEqual(ds.rawToken(TOKEN_IDS.yanyou).abnormal, ['isolated'], 'data: tokens.json abnormal');
const h = makeBattle({ defs: { chess: { test_medic_a: medic(), test_hurt_a: chessRec({ id: 'test_hurt_a', stats: { atk: 0, maxHp: 1e5 }, skill: null }) } },
units: [{ chessId: 'test_medic_a', row: 10, col: 5 }, { chessId: 'test_hurt_a', row: 12, col: 3 }], autoFinish: false, timeLimit: 60, hooks: ['heal'], captureNoisy: true });
h.step();
const [y] = spawnYanyou(h.b, 'p1', { atk: 0, hp: 5000 });
const m = h.unit('test_medic_a'), op = h.unit('test_hurt_a');
assert.ok(y.s.flags.isolated && y.s.flags.noHeal, '孤立 flags');
y.hp = y.s.maxHp * 0.3;
op.hp = op.s.maxHp * 0.6;
assert.ok(!h.b.alliesInGrid(m).includes(y), 'no ally selection picks it');
assert.equal(h.b.heal(m, y, 1000), 0, 'the heal pipeline refuses it');
h.run(5);
approx(y.hp, y.s.maxHp * 0.3, 1e-6, '炎佑 not healed');
assert.ok(op.hp > op.s.maxHp * 0.6, 'the injured operator is');
assert.ok(!h.hooksOf('heal').some((c) => c.target === y), 'no heal event on 炎佑');
checkInvariants(h.b);
});
test('#18 the summons that hold 禁疗 (PRTS summon pages) are not healed by others: data list + 狼群 next to a medic', REAL, () => {
const ds = getDefaultSource();
const healFree = ['token_10015_dusk_drgn', 'token_10019_nearl2_sword', 'token_10017_skadi2_dedant', 'token_10011_beewax_oblisk',
'token_10030_mlyss_wtrman', 'token_10028_vigil_wolf', 'token_10012_rosmon_shield', 'token_10040_siege2_vlion', 'token_10058_sbell2_icetgt'];
for (const id of healFree) assert.deepEqual(ds.rawToken(id).abnormal, ['healFree'], id);
assert.deepEqual(ds.rawToken('token_10039_ulpia_block').abnormal, ['isolated']);
for (const id of ['token_10000_silent_healrb', 'token_10006_vodfox_doll', 'token_10022_kazema_shadow', 'char_605_cmedic']) assert.deepEqual(ds.rawToken(id).abnormal, [], id);
const h = makeBattle({ defs: { chess: { test_medic_a: medic() } },
units: [{ chessId: 'chess_char_3_19_a', row: 12, col: 3 }, { chessId: 'test_medic_a', row: 11, col: 3 }, { kind: 'token', tokenId: 'token_10028_vigil_wolf', row: 10, col: 5, ownerUid: 1 }],
autoFinish: false, timeLimit: 60, hooks: ['heal'], captureNoisy: true });
h.step();
const w = h.b.allyUnits.find((x) => x.defId === 'token_10028_vigil_wolf' && x.alive);
assert.ok(w && w.s.flags.noHeal, '禁疗');
w.hp = w.s.maxHp * 0.5;
h.run(4);
approx(w.hp, w.s.maxHp * 0.5, 1e-6, '狼群 not healed');
assert.ok(!h.hooksOf('heal').some((c) => c.target === w && c.source !== w));
});
test('#18 圣聆初雪\'s 保护目标(冻结状态) holds 禁疗 (PRTS 备注 "持有禁疗、无法撤退"): a medic next to it does not heal it', REAL, () => {
const h = makeBattle({ defs: { chess: { test_medic_a: medic() } },
units: [{ chessId: 'chess_char_6_02_a', row: 12, col: 3 }, { chessId: 'test_medic_a', row: 11, col: 3 }],
autoFinish: false, timeLimit: 60, hooks: ['heal'], captureNoisy: true });
h.step();
const ice = h.b.spawnToken(h.unit('chess_char_6_02_a'), 'token_10058_sbell2_icetgt', 10, 5);
assert.ok(ice && ice.alive && ice.s.flags.noHeal, '禁疗');
ice.hp = ice.s.maxHp * 0.5;
h.run(4);
approx(ice.hp, ice.s.maxHp * 0.5, 1e-6, 'not healed');
assert.ok(!h.hooksOf('heal').some((c) => c.target === ice && c.source !== ice));
});
test('#18 安洁莉娜 兼职工作 is an HP-regen attribute (PRTS 备注 "不受治疗加成和禁疗影响"): the 禁疗 狼群 gets it; 炎佑 (孤立) is never selected', REAL, () => {
const h = makeBattle({
units: [{ chessId: 'chess_char_3_19_a', row: 12, col: 3 }, { chessId: 'chess_char_5_20_a', row: 11, col: 3, carryState: { sp: 0 } },
{ kind: 'token', tokenId: 'token_10028_vigil_wolf', row: 10, col: 5, ownerUid: 1 }],
autoFinish: false, timeLimit: 60,
});
h.step();
const [y] = spawnYanyou(h.b, 'p1', { atk: 0, hp: 5000 });
h.run(2);
const ag = h.unit('chess_char_5_20_a');
assert.ok(!ag.skill.active, 'skill off');
const w = h.b.allyUnits.find((x) => x.defId === 'token_10028_vigil_wolf' && x.alive);
assert.ok(w.s.flags.noHeal, '狼群 holds 禁疗');
assert.equal(w.findBuff('aglina:parttime')?.mods.hpRegen, 20, '禁疗 does not stop the regen attribute');
assert.ok(w.s.hpRegen >= 20);
assert.ok(!y.findBuff('aglina:parttime') && !y.findBuff('aglina:field'), '孤立: no ally talent selects 炎佑');
assert.ok(h.unit('chess_char_3_19_a').findBuff('aglina:field'), 'the operators get 加速力场');
});
test('#18 孤立 in every ally selection: 刺玫\'s 荆藤庇荫 picks the operator in her range, not 炎佑 (higher max HP)', REAL, () => {
const h = makeBattle({
defs: { chess: { test_op_a: chessRec({ id: 'test_op_a', stats: { atk: 0, maxHp: 3000 }, skill: null }) } },
units: [{ chessId: 'chess_char_1_06_a', row: 10, col: 3, dir: 'RIGHT' }, { chessId: 'test_op_a', row: 10, col: 4 }], autoFinish: false, timeLimit: 60,
});
h.step();
const [y] = spawnYanyou(h.b, 'p1', { atk: 0, hp: 50000 });
const cm = h.unit('chess_char_1_06_a'), op = h.unit('test_op_a');
assert.ok(h.b.relocate(y, 10, 5), '炎佑 inside her range');
assert.ok(y.s.maxHp > op.s.maxHp);
assert.deepEqual(alliesInGridOf(h.b, cm).filter((a) => a !== cm), [op], 'the range selection skips 炎佑');
assert.ok(cm.skill.activate('test', { free: true }), 'skill on');
assert.equal(cm.mem.protege, op, 'protégé = the operator');
assert.ok(!y.buffs.some((b) => b.key.startsWith('vendla')), '炎佑 not taunted');
assert.equal(h.b.allySelectable(y, cm), false);
assert.equal(h.b.allySelectable(y, y), true, 'its own effects still reach it');
checkInvariants(h.b);
});
+297
View File
@@ -0,0 +1,297 @@
// test/sim/playtest6-enemies.test.js — user playtest #6, workstream WG (enemies), real enemy data in the real sim:
// #13 “萨科塔之翼”'s 恐惧 (the user's "战栗(小范围乱飞)"): below half HP it fears itself for 5 s (SelfFear, ×1.5 speed) —
// official 恐惧 = "无法被阻挡并四散逃跑" (ba.fear); a self-inflicted fear has no 恐惧可达地块, so the unit keeps picking
// a random point of its own tile (±0.25) and flies there (PRTS 诱发移动 §恐惧). It used to hang still. Also
// “萨科塔之眼” / “萨科塔昂首” (same talent) and an operator's fear (runs away from the source, fan of ±45°).
// #12 萨卡兹枯朽战车 / 尖端: initial SP 2 = max ⇒ the FIRST attack is 秽蚀轰击 (100 % + a 【污染秽蚀】 of radius 1.7 for 10 s),
// then every 3rd; 污染秽蚀 = 50 / 25 true damage per second on low / high ground, same-name zones do not stack
// (PRTS 萨卡兹枯朽战车 / 萨卡兹枯朽战士). It used to pollute only from the 3rd attack, radius 1 [ASSUMED], HP loss, stacking.
// Review follow-up: the 恐惧 and 诱导 walks re-plan from where the enemy stands after a push (Battle.displace), an
// obstacle change or (诱导) an outranking 恐惧 — a stale path walked through walls (real stages m01–m04).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { makeBattle, chessRec } from '../helpers/battleHarness.js';
import * as enemiesMod from '../../server/sim/content/enemies.js';
import * as bossesMod from '../../server/sim/content/bosses.js';
import { reachableTiles, FEAR_RADIUS } from '../../server/sim/fear.js';
import { createRng } from '../../server/sim/rng.js';
import { ALLY_COLLIDER_RADIUS } from '../../server/sim/constants.js';
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
const tb = (key, k) => E[key].talents.bb[k];
const WALL = (id, o = {}) => chessRec({ id, profession: o.profession ?? 'TANK', position: o.position, stats: { atk: 0, maxHp: 1e7, def: 0, res: 0, blockCnt: o.block ?? 3 }, rangeGrid: [[0, 0]], skill: null });
const NOATK = () => ({ trait: { noAttack: true } });
function arena(o = {}) {
const { chess = {}, kits = {}, ...rest } = o;
return makeBattle({
content: 'generic', extraContent: [enemiesMod, bossesMod], seed: 11, autoFinish: false, timeLimit: 600,
defs: { chess: { t_wall: WALL('t_wall'), t_wall2: WALL('t_wall2'), t_hi: WALL('t_hi', { profession: 'SNIPER', position: 'RANGED', block: 0 }), ...chess } },
kits: { t_wall: NOATK, t_wall2: NOATK, t_hi: NOATK, ...kits }, ...rest,
});
}
/** Positions of `e` over `secs` game seconds (one sample per tick). */
function track(h, e, secs) {
const out = [];
const n = Math.round(secs * 30);
for (let i = 0; i < n; i++) { h.step(); out.push({ t: h.b.time, x: e.x, y: e.y, moving: e.moving, blocked: !!e.blockedBy, atk: e.stats.attacks }); }
return out;
}
const pathLen = (p) => p.reduce((s, q, i) => (i ? s + Math.hypot(q.x - p[i - 1].x, q.y - p[i - 1].y) : 0), 0);
/** Sign changes of the horizontal movement (ignoring standstill). */
function turns(p) {
let n = 0, last = 0;
for (let i = 1; i < p.length; i++) {
const dx = p[i].x - p[i - 1].x;
if (Math.abs(dx) < 1e-6) continue;
const s = dx > 0 ? 1 : -1;
if (last && s !== last) n++;
last = s;
}
return n;
}
describe('#13 “萨科塔” self-fear: flutters at random inside a small area, then flies on', () => {
for (const key of ['enemy_10083_hlbird', 'enemy_10084_hlegle', 'enemy_10085_hllevi_2']) {
test(`${key} ${E[key].name}: 5 s of random flight inside its tile (±0.25), ×1.5 speed, then its route again`, () => {
const h = arena({ units: [{ chessId: 't_hi', row: 11, col: 2 }] });
const e = h.spawn(key, { routeIndex: 2 }); // flat stage fly lane (9,10) → (9,2)
h.run(4); // on its way (x ≈ 10 − 0.45·4)
assert.equal(e.motion, 'FLY');
const x0 = e.x;
h.b.dealDamage(h.unit('t_hi'), e, { amount: e.s.maxHp * 0.6, type: 'true' });
assert.ok(e.s.flags.fear, 'feared');
const tr = Math.round(e.y), tc = Math.round(e.x);
const fear = tb(key, 'SelfFear.fear');
const p = track(h, e, fear - 0.1);
const settled = p.filter((q) => q.t > h.b.time - fear + 1.2);
for (const q of p) assert.ok(Math.abs(q.x - tc) <= 0.5 + 1e-6 && Math.abs(q.y - tr) <= 0.5 + 1e-6, `stays on its tile: ${q.x.toFixed(2)},${q.y.toFixed(2)}`);
for (const q of settled) assert.ok(Math.abs(q.x - tc) <= 0.25 + 1e-6 && Math.abs(q.y - tr) <= 0.25 + 1e-6, `inside the 0.5 square: ${q.x.toFixed(2)},${q.y.toFixed(2)}`);
const len = pathLen(p);
const speed = e.base.moveSpeed * 0.5 * tb(key, 'SelfFear.move_speed');
assert.ok(len > 0.6 * speed * fear, `keeps flying (${len.toFixed(2)} tiles in ${fear} s, speed ${speed})`);
assert.ok(turns(p) >= 3, `changes direction at random (${turns(p)} turns)`);
assert.ok(p.filter((q) => q.moving).length > 0.8 * p.length, 'animated as moving');
assert.ok(Math.abs(p[p.length - 1].x - x0) < 0.6, 'does not advance along its route meanwhile');
h.run(0.2);
assert.ok(!e.s.flags.fear, 'the fear ended');
const xe = e.x;
h.run(3);
assert.ok(e.x < xe - 0.8 * e.s.moveSpeed * 0.5 * 3, `flies on toward the goal (${xe.toFixed(2)} → ${e.x.toFixed(2)})`);
});
}
test('deterministic: the same seed flies the same wander', () => {
const run = () => {
const h = arena({ units: [{ chessId: 't_hi', row: 11, col: 2 }] });
const e = h.spawn('enemy_10083_hlbird', { routeIndex: 2 });
h.run(3);
h.b.dealDamage(null, e, { amount: e.s.maxHp * 0.6, type: 'true' });
return track(h, e, 4).map((q) => `${q.x.toFixed(6)},${q.y.toFixed(6)}`).join(' ');
};
assert.equal(run(), run());
});
});
describe('#13 恐惧 from an operator: unblockable, flees into the fan away from the source', () => {
test('a walking enemy feared by an operator west of it runs east (±45°), never attacks, then walks its route again', () => {
const walker = { key: 'enemy_walker', name: 'w', rank: 'NORMAL', applyWay: 'MELEE', stats: { maxHp: 1e7, atk: 100, def: 0, res: 0, moveSpeed: 1, bat: 1, aspd: 100, rangeRadius: 0, blockCnt: 1, massLevel: 1, lpr: 1, dmgType: 'phys', motion: 'WALK', immunities: {}, otherImmunities: [] }, talents: { bb: {} }, skills: [], abilities: [], tags: [] };
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }], defs: { chess: { t_wall: WALL('t_wall') }, enemies: { enemy_walker: walker } } });
const e = h.spawn('enemy_walker', { routeIndex: 0 });
const u = h.unit('t_wall');
assert.ok(h.runUntil(() => e.blockedBy === u, 20), 'blocked first');
h.b.applyStatus(e, 'fear', { duration: 4, source: u });
assert.equal(e.blockedBy, null, 'released');
const tiles = reachableTiles(h.b, e, e.findBuff('fear').data.fear);
assert.ok(tiles.length > 3, `a fan of reachable tiles (${tiles.length})`);
for (const k of tiles) {
const r = Math.floor(k / 21), c = k % 21;
const vx = c - e.x, vy = r - e.y, L = Math.hypot(vx, vy);
assert.ok(L <= FEAR_RADIUS + 1e-6 && (L < 1e-6 || vx / L >= Math.SQRT1_2 - 1e-6), `(${r},${c}) inside the eastern fan`);
assert.ok(h.b.grid.walkable(r, c, true) && h.b.grid.tile(r, c).special !== 'end');
}
const d0 = Math.hypot(e.x - u.x, e.y - u.y);
const a0 = e.stats.attacks;
const p = track(h, e, 3.9);
assert.ok(p.every((q) => !q.blocked), 'never blocked while feared');
assert.equal(e.stats.attacks, a0, 'no attack while feared');
assert.ok(pathLen(p) > 1.5, 'it runs');
assert.ok(Math.hypot(e.x - u.x, e.y - u.y) > d0 + 0.5, 'farther from the source');
for (const q of p) assert.ok(h.b.grid.walkable(Math.round(q.y), Math.round(q.x), true), 'on walkable ground');
h.run(0.3);
assert.ok(!e.s.flags.fear);
assert.ok(h.runUntil(() => e.blockedBy === u, 20), 'walks back its route and is blocked again');
});
test('恐惧 outranks 诱导 (PRTS 诱移优先级)', () => {
const walker = { key: 'enemy_walker', name: 'w', rank: 'NORMAL', applyWay: 'MELEE', stats: { maxHp: 1e7, atk: 0, def: 0, res: 0, moveSpeed: 1, bat: 1, aspd: 100, rangeRadius: 0, blockCnt: 1, massLevel: 1, lpr: 1, dmgType: 'none', motion: 'WALK', immunities: {}, otherImmunities: [] }, talents: { bb: {} }, skills: [], abilities: [], tags: [] };
const h = arena({ units: [{ chessId: 't_hi', row: 11, col: 2 }], defs: { chess: { t_hi: WALL('t_hi', { profession: 'SNIPER', position: 'RANGED', block: 0 }) }, enemies: { enemy_walker: walker } } });
const e = h.spawn('enemy_walker', { routeIndex: 0, pos: [9, 6], mods: { speedMul: 1 } });
h.step();
h.b.applyStatus(e, 'attract', { duration: 3, point: [12, 6] });
h.b.applyStatus(e, 'fear', { duration: 3, source: null }); // no source: no reachable tile, flutters on its tile
const r0 = Math.round(e.y);
track(h, e, 2.5);
assert.equal(Math.round(e.y), r0, 'stays on its tile instead of walking to the attract point');
});
});
describe('#13 恐惧 / 诱导 walks re-plan when something else moves the enemy (review follow-up)', () => {
const WALKER = { key: 'enemy_walker', name: 'w', rank: 'NORMAL', applyWay: 'MELEE', stats: { maxHp: 1e7, atk: 0, def: 0, res: 0, moveSpeed: 1, bat: 1, aspd: 100, rangeRadius: 0, blockCnt: 1, massLevel: 0, lpr: 1, dmgType: 'none', motion: 'WALK', immunities: {}, otherImmunities: [] }, talents: { bb: {} }, skills: [], abilities: [], tags: [] };
const STAGES = ['act2autochess_m01', 'act2autochess_m02', 'act2autochess_m03', 'act2autochess_m04'];
/** A walker on a real stage at a deterministic walkable tile of the lower lanes. */
function realWalker(stageId, seed, pick) {
const h = makeBattle({ stageId, seed, autoFinish: false, timeLimit: 300, content: 'generic', defs: { enemies: { enemy_walker: WALKER } },
routes: [{ motion: 'WALK', start: [9, 10], end: [9, 2], checkpoints: [] }] });
const g = h.b.grid;
const walk = [];
for (let r = 9; r <= 12; r++) for (let c = 2; c <= 18; c++) if (g.walkable(r, c) && g.tile(r, c).special !== 'end') walk.push([r, c]);
const e = h.spawn('enemy_walker', { routeIndex: 0, pos: walk[pick(walk.length)] });
h.step();
return { h, g, e, walk };
}
/** Ticks (over `secs`) the enemy spent on a tile it cannot walk, `when(e)` filtering the ticks counted. */
function offGround(h, g, e, secs, when = () => true) {
let n = 0;
for (let i = 0; i < Math.round(secs * 30); i++) { h.step(); if (when(e) && !g.walkable(Math.round(e.y), Math.round(e.x), true)) n++; }
return n;
}
test('a feared walker pushed mid-hop (Battle.displace) re-plans from where it lands: it never cuts through walls', () => {
const rng = createRng(5);
let pushed = 0;
const bad = [];
for (const stageId of STAGES) {
for (let n = 0; n < 60; n++) {
const { h, g, e } = realWalker(stageId, n + 1, (len) => (n * 7 + 3) % len);
const a1 = rng() * Math.PI * 2, a2 = rng() * Math.PI * 2;
h.b.applyStatus(e, 'fear', { duration: 4, source: { x: e.x + Math.cos(a1), y: e.y + Math.sin(a1), side: 'ally' } });
h.run(0.3 + rng());
if (h.b.displace(e, { x: Math.cos(a2), y: Math.sin(a2) }, 1.5, { force: 3 }) > 0) pushed++;
if (offGround(h, g, e, 4)) bad.push(`${stageId}#${n + 1}`);
}
}
assert.ok(pushed > 150, `pushes landed (${pushed})`);
assert.deepEqual(bad, [], 'walked onto an unwalkable tile after the push');
});
test('an obstacle appearing on a feared walker\'s path (grid.version) is walked around, not into', () => {
let tried = 0;
for (let seed = 1; seed <= 60; seed++) {
const h = arena({ seed, defs: { enemies: { enemy_walker: { ...WALKER, stats: { ...WALKER.stats, massLevel: 1 } } } } });
const e = h.spawn('enemy_walker', { routeIndex: 0, pos: [10, 8] });
h.step();
h.b.applyStatus(e, 'fear', { duration: 6, source: { x: 6, y: 10, side: 'ally' } }); // flee east
h.step();
const m = e.mem.fearMove;
const p = m && m.pts && m.pts[m.i];
if (!p) continue;
const dx = p.x - e.x, dy = p.y - e.y, d = Math.hypot(dx, dy);
if (d < 1.2) continue;
const r = Math.round(e.y + dy / d), c = Math.round(e.x + dx / d); // the tile one step ahead
if (r === Math.round(e.y) && c === Math.round(e.x)) continue;
h.b.setObstacle(r, c, true);
tried++;
const t = track(h, e, 3);
assert.ok(t.every((q) => Math.round(q.y) !== r || Math.round(q.x) !== c), `seed ${seed}: kept off the new obstacle (${r},${c})`);
assert.ok(pathLen(t) > 1, `seed ${seed}: still running`);
}
assert.ok(tried >= 20, `obstacles placed ahead in ${tried} runs`);
});
test('诱导 after an outranking 恐惧, and 诱导 after a push, re-plan from where the enemy stands (no wall cutting)', () => {
const rng = createRng(77);
const afterFear = [], afterPush = [];
for (const stageId of STAGES) {
for (let n = 0; n < 60; n++) {
{
const { h, g, e, walk } = realWalker(stageId, n + 1, (len) => Math.floor(rng() * len));
h.b.applyStatus(e, 'attract', { duration: 14, point: walk[Math.floor(rng() * walk.length)] });
h.run(0.5 + rng() * 2);
const ang = rng() * Math.PI * 2;
h.b.applyStatus(e, 'fear', { duration: 2 + rng() * 2, source: { x: e.x + Math.cos(ang), y: e.y + Math.sin(ang), side: 'ally' } });
if (offGround(h, g, e, 10, (x) => !x.s.flags.fear)) afterFear.push(`${stageId}#${n + 1}`);
}
{
const { h, g, e, walk } = realWalker(stageId, n + 1, (len) => (n * 7 + 3) % len);
h.b.applyStatus(e, 'attract', { duration: 6, point: walk[(n * 13 + 5) % walk.length] });
h.run(0.3 + rng());
const a = rng() * Math.PI * 2;
h.b.displace(e, { x: Math.cos(a), y: Math.sin(a) }, 1.5, { force: 3 });
if (offGround(h, g, e, 4)) afterPush.push(`${stageId}#${n + 1}`);
}
}
}
assert.deepEqual(afterFear, [], 'the 诱导 walk after the 恐惧 cut through an unwalkable tile');
assert.deepEqual(afterPush, [], 'the 诱导 walk after a push cut through an unwalkable tile');
});
});
describe('#12 萨卡兹枯朽战车: 秽蚀轰击 first, 污染秽蚀 radius 1.7, true damage, no stacking', () => {
for (const key of ['enemy_1272_nhtank', 'enemy_1272_nhtank_2']) {
test(`${key} ${E[key].name}: attacks 1, 4, 7 are 秽蚀轰击 (100 % + zone r 1.7 / 10 s); the zone hits 50 / 25 true per second`, () => {
// (10,7) road = low ground; (11,7) raised to high ground (ranged tile) inside the zone
const flat = { rows: { 11: '##hrrrrhrrfrrrrrrrf##' } };
const h = arena({ flat, units: [{ chessId: 't_wall', row: 10, col: 7 }, { chessId: 't_hi', row: 11, col: 7 }], captureNoisy: true, hooks: ['damaged', 'attack'] });
h.step();
const e = h.spawn(key, { pos: [9, 8], routeIndex: 0, mods: { speedMul: 0 } });
h.runUntil(() => e.stats.attacks >= 7, 60);
h.run(1); // the 7th shot lands
const fx = h.eventsOf('fx').filter((f) => f[1] === 'zone' && f[4].kind === 'pollution');
assert.equal(fx.length, 3, `three zones in seven attacks (${fx.length})`);
for (const f of fx) { assert.equal(f[4].r, 1.7, 'radius 1.7 (PRTS)'); assert.equal(f[4].dur, E[key].skills[0].bb.projectile_life_time); }
const dmg = h.hooksOf('damaged').filter((c) => c.source === e);
const atk = e.s.atk;
const hits = dmg.filter((c) => c.dmg.isAttack && c.target === h.unit('t_wall')).map((c) => c.amount / atk);
assert.deepEqual(hits.slice(0, 7).map((x) => Math.round(x * 100) / 100), [1, 1, 1, 1, 1, 1, 1], 'unblocked: 100 % per attack');
const poll = dmg.filter((c) => (c.dmg.tags || []).includes('pollution'));
assert.ok(poll.length > 0 && poll.every((c) => c.type === 'true'), '真实伤害');
const lo = poll.filter((c) => c.target === h.unit('t_wall')), hi = poll.filter((c) => c.target === h.unit('t_hi'));
assert.ok(lo.length > 5 && lo.every((c) => Math.abs(c.amount - 50) < 1e-6), 'low ground 50 / s');
assert.ok(hi.length > 5 && hi.every((c) => Math.abs(c.amount - 25) < 1e-6), 'high ground 25 / s (it never targets the high ground)');
assert.equal(dmg.filter((c) => c.dmg.isAttack && c.target === h.unit('t_hi')).length, 0);
// the first zone is up before the second attack
const t1 = h.hooksOf('attack').filter((c) => c.attacker === e)[1].t;
assert.ok(lo.some((c) => c.t < t1 + 1e-6), 'polluting from the first attack on');
});
}
test('reach: its 2.2 range circle takes an operator whose 0.25 collider touches it (PRTS 作战机制 §碰撞体积) — 2.45 from the centre', () => {
// user follow-up (2026-10-01): "远程攻击射程挺远、伤害很高". The data range stays 2.2; the official target search
// uses the operators' colliders, which the remake measured from the centre (2.2 reached 13 tiles around it, 2.45 21)
assert.equal(ALLY_COLLIDER_RADIUS, 0.25);
assert.equal(E.enemy_1272_nhtank.stats.rangeRadius, 2.2);
const hitsOn = (r, c) => {
const h = arena({ units: [{ chessId: 't_wall', row: r, col: c }], captureNoisy: true, hooks: ['damaged'] });
h.step();
const e = h.spawn('enemy_1272_nhtank', { pos: [10, 5], routeIndex: 0, mods: { speedMul: 0 } });
h.run(20);
return h.hooksOf('damaged').filter((x) => x.source === e && x.dmg.isAttack).length;
};
assert.ok(hitsOn(10, 7) >= 4, 'd 2.0: in range');
assert.ok(hitsOn(11, 7) >= 4, 'd 2.236 > 2.2: in range through the collider (2.45)');
assert.ok(hitsOn(12, 6) >= 4, 'd 2.236 (the other diagonal)');
assert.equal(hitsOn(12, 7), 0, 'd 2.83 > 2.45: out of range');
assert.equal(hitsOn(10, 8), 0, 'd 3.0: out of range');
});
test('blocked: normal attacks hit ×2 (近战), the skill attack 100 %; overlapping 污染秽蚀 do not stack', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }], captureNoisy: true, hooks: ['damaged'] });
h.step();
const a = h.spawn('enemy_1272_nhtank', { pos: [9, 5.6], routeIndex: 0, mods: { speedMul: 0 } });
const b2 = h.spawn('enemy_1272_nhtank', { pos: [9, 5.7], routeIndex: 0, mods: { speedMul: 0 } });
h.runUntil(() => a.stats.attacks >= 4 && b2.stats.attacks >= 4, 60);
const w = h.unit('t_wall');
const blockedBy = (e) => h.hooksOf('damaged').filter((c) => c.source === e && c.dmg.isAttack && c.target === w).map((c) => Math.round((c.amount / e.s.atk) * 100) / 100);
const blocked = [a, b2].find((e) => e.blockedBy === w);
assert.ok(blocked, 'one of them is blocked');
assert.deepEqual(blockedBy(blocked).slice(0, 4), [1, 2, 2, 1], 'skill 100 %, melee 200 %, 200 %, skill 100 %');
// both tanks' zones cover the wall: one 50 per second, not two
const poll = h.hooksOf('damaged').filter((c) => c.target === w && (c.dmg.tags || []).includes('pollution'));
assert.ok(poll.length > 4);
for (let i = 1; i < poll.length; i++) assert.ok(poll[i].t - poll[i - 1].t > 1 - 1e-6, `one tick per second (${poll[i - 1].t.toFixed(2)} → ${poll[i].t.toFixed(2)})`);
});
});
+398
View File
@@ -0,0 +1,398 @@
// test/sim/playtest6-skills.test.js — user playtest #6, workstream WE (skill triggers and buffs), with the real data and kits.
// #15 "技能范围比攻击范围大的技能在敌人进入技能范围时不触发": PRTS 卫戍协议/帮助 §作战阶段 技能操作 — "携带拥有技能范围的技能
// (非攻击距离增加)的干员:不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其不可选中)时释放技能" (SKILL_RANGE);
// an attack-range change keeps the basic strategy; the class rows (重装 "不受技能范围影响,受到伤害时释放技能" …) cover every
// MANUAL skill of the class; "自动操作具有3s冷却"; GDGLOW_SKILL_2 "全场存在可选目标时释放技能".
// #16 "古米的治疗队友的技能在身边队友受伤时并没触发,古米接敌之后才触发": 备用军粮 is 自动触发 (no 技能策略); PRTS 备注 "此技能
// 在存在生命值不满的可治疗角色时可触发;技能触发后古米将切换至治疗模式…直至古米完成一次普通攻击的治疗". Audit: 塞雷娅
// S1 "仅在周围有符合血量条件的友方单位时可触发,触发时会替换当次攻击" — checked at her attack (≤ half HP), no waiting.
// #8 "新约能天使2技能给队友套护盾好像没生效": PRTS 备注 "选择的友方干员为攻击范围内仇恨值最高的我方干员"; "获得的屏障均以
// 自身生命上限为标准计算;屏障每秒衰减量为:初始屏障量/30".
// #3 "忍冬的叙拉古阵营隐身不会结束" (friend): the 叙拉古 隐匿 lasts its bond time; her S3 迷彩 (ba.camou "不阻挡时不成为敌方
// 普通攻击的目标"; PRTS 异常效果: "与'阻挡时解除'没有直接关系") keeps every enemy but the one she blocks off her, as
// 隐匿 does, lasts until her next cast ("直至下一次开启技能"), is not 隐匿 and no longer merges with a timed 隐匿 (伪装服).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import { absoluteRangeKeys, canTargetAlly } from '../../server/sim/targeting.js';
import { flagsOf } from '../../server/sim/snapshot.js';
import { AUTO_OP_COOLDOWN, COLS } from '../../server/sim/constants.js';
import { UF } from '../../shared/constants.js';
import { statusIconKey } from '../../public/js/render/style.js';
const ds = getDefaultSource();
const raw = (id) => ds.rawChess(id);
const skillOf = (id, skillId) => raw(id).skills.find((s) => s.skillId === skillId);
const approx = (a, b, msg = '', eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o });
const noGarrison = (...ids) => Object.fromEntries(ids.map((id) => [id, { ...raw(id), garrisonIds: [] }]));
const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); };
const HOOKS = ['damaged', 'heal', 'skillStart', 'skillEnd', 'attack', 'statusApplied'];
const run = (o) => makeBattle({ seed: 3, autoFinish: false, timeLimit: 300, hooks: HOOKS, captureNoisy: true, ...o });
const starts = (h, u) => h.hooksOf('skillStart').filter((c) => c.unit === u);
const fill = (u) => u.skill.gainSp(u.skill.spCost * u.skill.maxCharges + 1, 'test');
// =================================================================================================================
// #15 skill ranges
test('#15 德克萨斯 S2 剑雨 (skill range 13 tiles, attack range 2): an enemy entering the skill range casts it — no attack, stealthed ones count', () => {
const id = 'chess_char_1_08_a';
const stealthy = dummy('enemy_sneak');
for (const sneak of [false, true]) {
const h = run({ defs: { enemies: { enemy_d: dummy('enemy_d'), enemy_sneak: stealthy }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 4 }] });
const u = h.unit(id);
assert.equal(u.skill.id, 'skchr_texas_2');
assert.equal(u.skill.rule, 'SKILL_RANGE');
h.step();
fill(u);
h.run(1);
assert.equal(u.skill.activations, 0, 'nobody in the skill range');
// (10,6): two tiles ahead — inside 剑雨's range, outside her 2-tile attack range
const keys = new Set(absoluteRangeKeys(u.def.skill.rangeGrid, 10, 4, u.dir, 0));
assert.ok(keys.has(10 * COLS + 6) && !u.rangeKeySet.has(10 * COLS + 6));
const e = h.spawn(sneak ? 'enemy_sneak' : 'enemy_d', { pos: [10, 6] });
if (sneak) h.b.addBuff(e, { key: 'ab:stealth', flags: { stealth: true }, persist: true });
h.step(2);
assert.equal(u.skill.activations, 1, `cast at once${sneak ? ' (无视其不可选中: a stealthed enemy counts)' : ''}`);
assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u).length, 0, 'without attacking');
done(h);
}
});
test('#15 audit: every MANUAL chess skill with its own 技能范围 casts with an enemy only on a skill-range tile (normal + elite)', () => {
const pool = ds.chessIds().filter((id) => (raw(id).skills || []).length); // hidden chess too (effects bring them)
let n = 0;
for (const id of pool) {
for (const s of raw(id).skills || []) {
if (s.trigger?.rule !== 'SKILL_RANGE') continue;
const h = run({ defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 5, skillIndex: s.index }] });
const u = h.unit(id);
assert.equal(u.skill.id, s.skillId);
assert.equal(u.skill.rule, 'SKILL_RANGE', `${id} ${s.skillId}`);
h.step();
const skill = absoluteRangeKeys(s.rangeGrid, 10, 5, u.dir, 0).filter((k) => { const r = Math.floor(k / COLS), c = k % COLS; return r >= 9 && r <= 12 && c >= 2 && c <= 10 && k !== 10 * COLS + 5; });
const outside = skill.filter((k) => !u.rangeKeySet.has(k));
const k = (outside.length ? outside : skill)[0];
assert.ok(k != null, `${id} ${s.skillId}: a field tile in the skill range`);
fill(u);
h.spawn('enemy_d', { pos: [Math.floor(k / COLS), k % COLS] });
h.run(0.5);
assert.ok(u.skill.activations >= 1, `${id} ${s.skillId} (${s.name}): cast by an enemy on its skill range${outside.length ? ' outside the attack range' : ''}`);
done(h);
n++;
}
}
assert.ok(n >= 20, `${n} SKILL_RANGE skills`);
});
test('#15 an attack-range change ("攻击范围扩大") keeps the basic strategy: 银灰 S3 waits for an enemy in his initial range', () => {
const id = 'chess_char_4_22_a';
const h = run({ defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 3 }] });
const u = h.unit(id);
assert.equal(u.skill.id, 'skchr_svrash_3');
assert.equal(u.skill.rule, 'DEFAULT');
h.step();
fill(u);
const far = absoluteRangeKeys(u.def.skill.rangeGrid, 10, 3, u.dir, 0).find((k) => !u.rangeKeySet.has(k) && Math.floor(k / COLS) === 10);
h.spawn('enemy_d', { pos: [10, far % COLS] });
h.run(1);
assert.equal(u.skill.activations, 0, 'an enemy only in the expanded range: no cast (PRTS "非攻击距离增加")');
h.spawn('enemy_d', { pos: [10, 4] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), 'an enemy in the initial range: cast before the attack');
done(h);
});
test('#15 重装 "不受技能范围影响,受到伤害时释放技能": 瑕光 S3 ignores enemies in its skill range until she is hit', () => {
const id = 'chess_char_3_12_a';
const h = run({ defs: { enemies: { enemy_d: dummy('enemy_d'), enemy_hit: dummy('enemy_hit', { atk: 200, bat: 1 }) }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 4 }] });
const u = h.unit(id);
assert.equal(u.skill.id, 'skchr_blemsh_3');
assert.equal(u.skill.rule, 'TAKE_DAMAGE');
h.step();
fill(u);
h.spawn('enemy_d', { pos: [10, 5] });
h.run(2);
assert.equal(u.skill.activations, 0, 'enemies in range, attacked by her, but she is not hit');
h.spawn('enemy_hit', { pos: [10, 4] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), 'the first hit casts it');
assert.equal(starts(h, u)[0].reason, 'TAKE_DAMAGE');
done(h);
});
test('#15 the automatic operations cool down 3 s: 锏 S2 (2 charges, skill range) spends its charges 3 s apart', () => {
const id = 'chess_char_6_19_b';
const s = skillOf(id, 'skchr_blkkgt_2');
const h = run({ defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 4, skillIndex: s.index }], enemies: [{ key: 'enemy_d', pos: [10, 5] }] });
const u = h.unit(id);
assert.equal(u.skill.maxCharges, 2);
h.step();
fill(u);
assert.ok(h.runUntil(() => u.skill.activations === 1, 1));
const t1 = h.b.time;
h.run(AUTO_OP_COOLDOWN - 0.2);
assert.equal(u.skill.activations, 1, 'the stored charge waits');
assert.ok(h.runUntil(() => u.skill.activations === 2, 1));
assert.ok(h.b.time - t1 >= AUTO_OP_COOLDOWN - 1e-6);
done(h);
});
test('#15 "作战开始时部署的单位将进入冷却": a MANUAL skill ready at the battle start waits 3 s after the initial deployment (the engine default); an AUTO one does not', () => {
const rec = (id, skillType) => chessRec({ id, profession: 'SUPPORT', subProfessionId: 'bard', stats: { atk: 0 }, skill: { skillType, spCost: 10, initSp: 10, durationType: 'NONE', duration: 5, trigger: { rule: 'SP_FULL' } } });
// flags.startOpCooldown undefined = the Battle default the matches use (the harness otherwise turns it off)
const h = run({ flags: { startOpCooldown: undefined }, defs: { chess: { t_man: rec('t_man', 'MANUAL'), t_auto: rec('t_auto', 'AUTO') } },
units: [{ chessId: 't_man', row: 10, col: 3 }, { chessId: 't_auto', row: 10, col: 6 }] });
assert.equal(h.b.flags.startOpCooldown, AUTO_OP_COOLDOWN);
const man = h.unit('t_man'), auto = h.unit('t_auto');
h.step();
assert.ok(man.skill.ready && man.skill.rule === 'SP_FULL');
assert.equal(auto.skill.activations, 1, 'AUTO: at once');
assert.ok(h.runUntil(() => man.skill.activations === 1, 5));
assert.ok(h.b.time >= AUTO_OP_COOLDOWN - 1e-6, `MANUAL: not before ${AUTO_OP_COOLDOWN} s (${h.b.time})`);
done(h);
});
test('#15 GDGLOW_SKILL_2 "全场存在可选目标时释放技能": 荒芜拉普兰德 S3 casts with an enemy anywhere on the field, 纯烬艾雅法拉 S3 with an injured ally anywhere', () => {
const w = 'chess_char_6_18_a';
let h = run({ defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: noGarrison(w) }, units: [{ chessId: w, row: 12, col: 2 }] });
let u = h.unit(w);
assert.equal(u.skill.rule, 'GDGLOW_SKILL_2');
h.step();
fill(u);
h.run(1);
assert.equal(u.skill.activations, 0, 'no enemy');
h.spawn('enemy_d', { pos: [9, 10] }); // far outside her range
assert.ok(!u.rangeKeySet.has(9 * COLS + 10));
h.step(2);
assert.equal(u.skill.activations, 1, 'an enemy anywhere');
done(h);
const a = 'chess_char_6_20_a';
const mate = chessRec({ id: 't_mate', profession: 'WARRIOR', skill: null, stats: { maxHp: 10000 } });
h = run({ defs: { chess: { ...noGarrison(a), t_mate: mate } }, units: [{ chessId: a, row: 12, col: 2 }, { chessId: 't_mate', row: 9, col: 10 }] });
u = h.unit(a);
assert.equal(u.skill.rule, 'GDGLOW_SKILL_2');
h.step();
fill(u);
h.run(1);
assert.equal(u.skill.activations, 0, 'nobody to heal');
h.unit('t_mate').hp = 5000;
h.step(2);
assert.equal(u.skill.activations, 1, 'an injured ally anywhere');
done(h);
});
// =================================================================================================================
// #16 古米 / 塞雷娅 heal skills
test('#16 古米 备用军粮: an injured ally next to her casts it with no enemy on the field; her next attack heals that ally (normal + elite)', () => {
for (const id of ['chess_char_1_10_a', 'chess_char_1_10_b']) {
const mate = chessRec({ id: 't_mate', profession: 'WARRIOR', skill: null, stats: { maxHp: 10000, atk: 0 } });
const h = run({ defs: { chess: { ...noGarrison(id), t_mate: mate } }, units: [{ chessId: id, row: 10, col: 4 }, { chessId: 't_mate', row: 11, col: 5 }] });
const u = h.unit(id), m = h.unit('t_mate');
assert.equal(u.skill.id, 'skchr_sunbr_1');
h.step();
fill(u);
h.run(2);
assert.equal(u.skill.activations, 0, 'nobody injured');
m.hp = 4000;
assert.ok(h.runUntil(() => h.hooksOf('heal').some((c) => c.source === u && c.target === m), 2), 'healed without any enemy (before: only once she was hit)');
const heal = h.hooksOf('heal').find((c) => c.source === u && c.target === m);
const tb = raw(id).trait.bb;
const k = tb.hp_ratio != null && 0.4 < tb.hp_ratio ? tb.heal_scale : 1;
approx(heal.amount, u.s.atk * u.def.skill.bb.heal_scale * k, `${id} heal_scale × ATK`);
assert.equal(starts(h, u)[0].reason, 'SKILL_RANGE');
done(h);
}
});
test('#16 古米 备用军粮 with an enemy blocked: the cast turns her next attack into the heal (the enemy is not hit by it); no second charge on the same heal', () => {
const id = 'chess_char_1_10_b';
const mate = chessRec({ id: 't_mate', profession: 'WARRIOR', skill: null, stats: { maxHp: 10000, atk: 0 } });
const h = run({ defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: { ...noGarrison(id), t_mate: mate, t_mate2: { ...mate, chessId: 't_mate2', baseId: 't_mate2' } } },
units: [{ chessId: id, row: 10, col: 4 }, { chessId: 't_mate', row: 11, col: 4 }, { chessId: 't_mate2', row: 9, col: 4 }], enemies: [{ key: 'enemy_d', pos: [10, 4] }] });
const u = h.unit(id), m = h.unit('t_mate'), m2 = h.unit('t_mate2');
h.step();
assert.equal(u.skill.maxCharges, 2);
u.atkCd = 1.5; // the heal waits for her next attack
fill(u);
m.hp = 3000;
h.run(1);
assert.equal(starts(h, u).length, 1, 'one charge cast');
assert.equal(u.skill.charges, 1, 'the other stays stored while the heal waits');
const hits0 = h.hooksOf('damaged').filter((c) => c.source === u).length;
assert.ok(h.runUntil(() => h.hooksOf('heal').some((c) => c.source === u && c.target === m), 2));
assert.equal(h.hooksOf('damaged').filter((c) => c.source === u).length, hits0, 'that attack was the heal, not a hit');
m2.hp = 3000;
assert.ok(h.runUntil(() => h.hooksOf('heal').some((c) => c.source === u && c.target === m2), 5), 'the stored charge heals the next injured ally');
done(h);
});
test('#16 塞雷娅 S1 急救 (audit): checked at her attack — an ally of her area at ≤ half HP turns that attack into the heal; healed back meanwhile ⇒ she just attacks', () => {
// PRTS 备注 "此技能仅在周围有符合血量条件的友方单位时可触发,触发时会替换当次攻击"; text corrected to "血量小于等于一半"
const id = 'chess_char_5_11_a';
const s = skillOf(id, 'skchr_demkni_1');
const mate = chessRec({ id: 't_mate', profession: 'WARRIOR', skill: null, stats: { maxHp: 10000, atk: 0, blockCnt: 0 } });
const setup = (withEnemy) => {
const h = run({ defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: { ...noGarrison(id), t_mate: mate } },
units: [{ chessId: id, row: 10, col: 4, skillIndex: s.index }, { chessId: 't_mate', row: 10, col: 5 }],
enemies: withEnemy ? [{ key: 'enemy_d', pos: [10, 4] }] : [] });
h.step();
return [h, h.unit(id), h.unit('t_mate')];
};
let [h, u, m] = setup(false);
assert.equal(u.skill.rule, 'DEFAULT');
fill(u);
m.hp = 4000;
h.run(2);
assert.equal(u.skill.activations, 0, 'no enemy, no attack: no cast');
done(h);
[h, u, m] = setup(true);
const e = h.enemies()[0];
const hits = () => h.hooksOf('damaged').filter((c) => c.source === u && c.target === e).length;
fill(u);
m.hp = 6000;
assert.ok(h.runUntil(() => hits() >= 2, 5), '60 %: she attacks the enemy she blocks');
assert.equal(u.skill.activations, 0, 'and casts nothing');
// an ally at exactly half HP: her next attack is the heal (the enemy is not hit by it)
u.atkCd = 0.5;
m.hp = 5000;
const hits0 = hits();
assert.ok(h.runUntil(() => u.skill.activations === 1, 2));
assert.equal(starts(h, u)[0].reason, 'DEFAULT');
const heal = h.hooksOf('heal').find((c) => c.source === u && c.target === m);
assert.ok(heal, 'healed at once, in place of that attack');
approx(heal.amount, u.s.atk * s.bb.heal_scale);
assert.equal(hits(), hits0, 'that attack hit nobody');
assert.ok(!u.skill.pending);
// dropped to 40 %, healed back to 80 % before her attack: no cast, she keeps attacking (no heal mode left waiting)
fill(u);
u.atkCd = 1;
m.hp = 4000;
h.run(0.5);
m.hp = 8000;
const hits1 = hits();
h.run(4);
assert.equal(u.skill.activations, 1, 'no second cast');
assert.ok(hits() >= hits1 + 3, `she kept hitting the enemy (${hits() - hits1} hits in 4 s)`);
// the engine's safety net: a cast whose ally condition fails before the attack is withdrawn, its charge returned
m.hp = 4000;
u.atkCd = 5;
assert.ok(u.skill.activate('test'));
assert.ok(u.skill.pending && u.skill.charges === 0);
m.hp = 10000;
h.step();
assert.ok(!u.skill.pending && u.skill.charges === 1, 'withdrawn, the charge kept');
done(h);
});
// =================================================================================================================
// #8 新约能天使 S2
test('#8 新约能天使 S2 开火成瘾症: the ally is the highest-仇恨值 operator of her range (taunt, then the latest deployed); both shields = 180 % of their own max HP, −initial/30 per s', () => {
const id = 'chess_char_6_13_a';
const mate = (key, o = {}) => chessRec({ id: key, profession: 'SNIPER', skill: null, stats: { maxHp: 3000, atk: 0, aspd: 100, ...o } });
// rows deploy top first (row 11 before row 10): t_back (11,4) comes before t_front (10,5); both are in her range
const setup = (taunt) => {
const h = run({
defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: { ...noGarrison(id), t_back: mate('t_back', { aspd: 150 }), t_front: mate('t_front', { maxHp: 5000 }) } },
units: [{ chessId: id, row: 10, col: 3 }, { chessId: 't_back', row: 11, col: 4 }, { chessId: 't_front', row: 10, col: 5 }],
enemies: [{ key: 'enemy_d', pos: [10, 6] }],
});
const u = h.unit(id);
h.step();
if (taunt) h.b.addBuff(h.unit('t_back'), { key: 'test:taunt', mods: { taunt: 1 }, persist: true });
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 3));
return h;
};
let h = setup(false);
const u = h.unit(id), back = h.unit('t_back'), front = h.unit('t_front');
const bb = u.def.skill.bb;
assert.ok(front.aggroSeq > back.aggroSeq);
assert.ok(front.findBuff('angel2:stolen') && !back.findBuff('angel2:stolen'), 'the latest deployed (not the fastest attacker) is chosen');
approx(front.s.shield, front.s.maxHp * bb.shield_max_hp_ratio, 'her shield: 180 % of HER max HP');
approx(u.s.shield, u.s.maxHp * bb.shield_max_hp_ratio, 'his own: of his');
assert.ok(flagsOf(front) & UF.SHIELD, 'the ally shows the shield (b.snap flag: the HP-bar shield line)');
const st = h.eventsOf('status').find((e) => e[1] === front.id && e[2] === 'angel2:barrier' && e[3] === 1);
assert.ok(st && statusIconKey(st[2]) === 'shield', 'and the shield icon in its status row');
const s0 = front.s.shield;
h.run(3);
approx(front.s.shield, s0 * (1 - 3 / bb.shield_max_duration), 'decays by initial/30 per second', 0.02);
h.b.dealDamage(h.enemies()[0], front, { amount: 1000, type: 'true' });
assert.equal(front.hp, front.s.maxHp, 'the shield absorbs the damage');
done(h);
h = setup(true);
assert.ok(h.unit('t_back').findBuff('angel2:stolen'), 'a higher taunt level comes first');
done(h);
});
// =================================================================================================================
// #3 忍冬: 叙拉古 隐匿 and her S3 迷彩
test('#3 忍冬 with 6 叙拉古: the bond 隐匿 ends at 32 + 0.4 × layers s; her S3 迷彩 is not 隐匿, keeps all but the enemy she blocks off her, until the next cast', () => {
const id = 'chess_char_3_18_a', L = 20;
const h = run({
defs: { enemies: { enemy_f: dummy('enemy_f', { hp: 1 }), enemy_b: dummy('enemy_b'), enemy_r: dummy('enemy_r', { atk: 50, range: 3, applyWay: 'RANGED', bat: 1 }) }, chess: noGarrison(id) },
units: [{ chessId: id, row: 10, col: 4 }], bonds: { siracusaShip: { count: 6, active: true, tier: 2, layers: L } },
});
const u = h.unit(id);
h.step();
assert.ok(u.s.flags.stealth, '叙拉古 6: 隐匿 after deployment');
// a kill during 隐狐之艺 ⇒ 迷彩 at its end
fill(u);
h.spawn('enemy_f', { pos: [10, 5] });
assert.ok(h.runUntil(() => u.skill.active, 5));
h.runUntil(() => !u.skill.active, 20);
assert.ok(u.s.flags.camou, '迷彩 after the skill (a kill during it)');
h.runUntil(() => h.b.time >= 32 + 0.4 * L + 0.1, 60);
assert.ok(!u.s.flags.stealth, 'the bond 隐匿 is over at its time');
assert.ok(u.s.flags.camou && !u.blocking.length);
assert.ok(flagsOf(u) & UF.STEALTH, 'the 迷彩 shows (see-through)');
const r = h.spawn('enemy_r', { pos: [11, 6] });
assert.equal(canTargetAlly(r, u, true), false, 'and works (no ranged target)');
// an enemy she blocks: only that one may attack her — 迷彩, like 隐匿, is not lifted by blocking (PRTS 异常效果)
u.skill.charges = 0; u.skill.sp = 0; // (her SP is full again by now: no new cast on that enemy yet)
const b = h.spawn('enemy_b', { pos: [10, 4] });
h.step(2);
assert.equal(b.blockedBy, u);
assert.ok(flagsOf(u) & UF.STEALTH, 'blocking: still shown camouflaged');
assert.equal(canTargetAlly(b, u, true), true, 'the enemy she blocks may target her');
assert.equal(canTargetAlly(r, u, true), false, 'the ranged enemy still may not');
h.run(3);
assert.equal(h.hooksOf('damaged').filter((c) => c.source === r && c.target === u).length, 0, 'and does not');
// it lasts until her next cast
fill(u);
assert.ok(h.runUntil(() => u.skill.activations === 2, 5));
assert.ok(!u.s.flags.camou, 'the next cast ends it');
done(h);
});
test('#3 伪装服 (timed 隐匿) on 忍冬 is neither made permanent nor stripped by her S3 迷彩 (their keys used to collide)', () => {
const id = 'chess_char_3_18_a';
const item = 'chess_item_4_04_e_a';
const h = run({
defs: { enemies: { enemy_f: dummy('enemy_f', { hp: 1 }) }, chess: noGarrison(id) },
units: [{ chessId: id, row: 10, col: 4, items: [item] }],
});
const u = h.unit(id);
h.step();
fill(u);
h.spawn('enemy_f', { pos: [10, 5] });
assert.ok(h.runUntil(() => u.skill.active, 5));
h.runUntil(() => !u.skill.active, 20);
assert.ok(u.s.flags.camou);
h.b.dealDamage(null, u, { amount: 10, type: 'true' }); // 伪装服: the first damage taken ⇒ 隐匿 for its duration
const cloak = u.findBuff('stealth');
assert.ok(cloak && Number.isFinite(cloak.timeLeft) && u.s.flags.stealth, '伪装服 隐匿, timed');
h.run(cloak.timeLeft + 0.2);
assert.ok(!u.s.flags.stealth, 'the 隐匿 ends at its time although the 迷彩 lasts');
assert.ok(u.s.flags.camou);
// and her next cast removes the 迷彩 only (a fresh 伪装服 隐匿 would stay: another key)
h.b.applyStatus(u, 'stealth', { duration: 5, source: u });
fill(u);
h.spawn('enemy_f', { pos: [10, 5] });
assert.ok(h.runUntil(() => u.skill.activations === 2, 5));
assert.ok(!u.s.flags.camou && u.s.flags.stealth, 'the cast strips the 迷彩, not a 隐匿');
done(h);
});
+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), []);
});
}
+108
View File
@@ -0,0 +1,108 @@
// 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);
});
+7 -3
View File
@@ -131,7 +131,7 @@ test('ammo skills end when ammo runs out; ammoUsed fires per shot; no SP gain wh
assert.equal(h.hooksOf('skillEnd')[0].reason, 'ammo');
});
test('charges accumulate up to maxCharges and are spent one per cast', () => {
test('charges accumulate up to maxCharges and are spent one per cast, 3 s apart (the automatic operation cooldown)', () => {
const h = makeBattle({
defs: { chess: { t_sn: sniperWith({ duration: 0, spCost: 2, initSp: 0, maxChargeTime: 3, bb: { atk_scale: 3 } }) }, enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 't_sn', row: 10, col: 4 }], content: 'generic', autoFinish: false,
@@ -143,10 +143,14 @@ test('charges accumulate up to maxCharges and are spent one per cast', () => {
assert.equal(u.skill.sp, 2);
h.spawn('enemy_dummy', { pos: [10, 6] });
h.step(1);
const t1 = h.b.time;
assert.equal(u.skill.charges, 2);
assert.equal(u.skill.sp, 0, 'SP bar restarts after using a full stack');
h.run(1.05);
assert.ok(u.skill.activations >= 2);
// PRTS 卫戍协议/帮助 技能操作 "自动操作具有3s冷却": a MANUAL skill's next automatic cast comes ≥ 3 s later
h.run(2.8);
assert.equal(u.skill.activations, 1, 'not within 3 s although she attacks every second');
assert.ok(h.runUntil(() => u.skill.activations >= 2, 2));
assert.ok(h.b.time - t1 >= 3 - 1e-6);
});
test('instant skills with an attack override apply to exactly one attack', () => {