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:
1 parent
e6628f0cfe
commit
6464e4b46a
138 files changed
+7946
-1379
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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])}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)})`);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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), []);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user