Files
ark-stronghold-protocol/test/sim/playtest6-combat.test.js
T
sganggs 92a179e71e feedback5 WE2: remove the kits/tier1-6.js re-export shims and shared/standIn.js diyRecord (#5)
Nothing but tests imported them: kits_t3 / kits_alt_t3 / kits_t4 / kits_t5 read
kits/index.js TIER_KITS, playtest5_elements / playtest6-combat / facing the real modules,
simServe checks /sim/content/kits/index.js; test/backups.test.js composes the prototypes
through shared/diy.js diyRecordOf at their locked selection. check:imports passes.
Docs: kits/README.md, DATA.md. Golden: no scenario moved.
2026-10-06 16:07:49 +08:00

608 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/shared/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 air units and pulls a non-static one (PRTS 备注 "※可对空。不会拖拽自身中心半径0.6708范围内的敌人")', REAL, () => {
// a synthetic dynamic flyer: the air units of the mode are all 静态刚体 — hit, never moved (test/sim/feedback1b-displacement.test.js)
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);
});