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.
608 lines
37 KiB
JavaScript
608 lines
37 KiB
JavaScript
// 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);
|
||
});
|