Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar

- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player;
  placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call)
- #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿
- #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text
- #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool,
  and the team LP reaching 0 first is a defeat (PRTS)
- #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones
- #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10)
- #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage
  triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range
- #9 official per-enemy model scales (威龙)
- #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding
- #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider
- #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes
- #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20
- #18 炎佑 is 孤立: no heals or ally buffs
- #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators
- DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites
  green; dev recordings regenerated
This commit is contained in:
sganggs committed 2026-10-01 19:08:23 +08:00
1 parent e6628f0cfe
commit 6464e4b46a
138 files changed
+7946 -1379

No files matched your search

+606
View File
@@ -0,0 +1,606 @@
// User playtest #6, workstream WF (combat rules) — reports #17, #14, #18.
//
// #17 "两个干员之间卡了敌人,靠后的干员死亡之后敌人确实会被靠前的干员阻挡了,但是只有敌人攻击干员,靠前的干员不会攻击那个敌人":
// PRTS 选择器 "可以选择且优先选择阻挡单位" — and the user's rule after playtest #6, "阻挡了就一定要能打到": officially the
// collision pushes a blocked enemy to its blocker's front, so EVERY blocker — a ranged operator on a melee tile included
// (the playtest #5 QA had made that melee-only, PRTS 索敌的概念 "阻挡(近战限定)") — attacks the enemies it blocks, in
// range or not, whatever its facing, and takes them first. The real sim does for every blocking chess of the pool in
// a plain block and in the user's layout, whether the take-over happens at once or once the enemy slid behind the
// front operator's tile (the regressions below); only healers and 停止攻击 skills never hit it.
// Fixed on the way: 流形's melee copy (data position ALL) ignored the enemy it held outside its range; heavy slows
// (沼泽 −50 + 寒霜 −50) took ASPD down to 10 (interval 10 × BAT) — officially ASPD never drops below 20 (PRTS 数值范围
// ATTACK_SPEED 默认下限 20; 游戏数据基础 "攻击速度属性实际被限制了下限为20"), so it attacks every 5 × BAT.
// #14 "敌人的重量以及可以推动敌人的干员技能的力度…感觉野鬃干员开技能能把推动好多敌人": official 位移 = 力度 − 重量 (PRTS 游戏数据基础
// §重量公式, 推与拉): a 中力 push moves weight 0 / 1 / 2 / 3 enemies 2.14 / 1.7 / 0.44 / 0.12 tiles and heavier ones not at
// all; the remake pushed 1 tile scaled by 25 % per missing level, so weight 3–5 enemies still flew 0.75–0.25 tiles.
// #18 "炎祐错误的吃到了医疗干员造成的治疗": PRTS “炎佑” 天赋 "{{特殊机制|我方单位}},{{异常效果|孤立}}" — 孤立 = "无法被同阵营选中"
// (PRTS 异常效果 ALLY_TARGET_FREE); summons holding 禁疗 (HEAL_FREE) are never healed either.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource, hasGeneratedData } from '../../server/sim/simdata.js';
import { effectiveProfile } from '../../server/sim/ai.js';
import { PUSH_TILES, PUSH_TILES_EFFECT, PULL_STOP_RADIUS, ASPD_MIN, COLS } from '../../server/sim/constants.js';
import { alliesInGridOf } from '../../server/sim/content/kits/tier1.js';
import { Unit } from '../../server/sim/units.js';
import { spawnYanyou, TOKEN_IDS } from '../../server/sim/content/tokens.js';
const REAL = { skip: !hasGeneratedData() };
const approx = (a, b, eps, msg) => assert.ok(Math.abs(a - b) <= eps, `${msg ?? ''} ${a} ≈ ${b}`);
// =====================================================================================================================
// #17 — the enemy held between two operators
const REAR = 'test_rear_a';
const foe = (key, o = {}) => enemyRec({ key, hp: 1e8, speed: 1, atk: 0, ...o });
/**
* The user's layout on act2 m01: the lane goes up column 7 from the lower gate row; the front operator F stands on
* (10,7), the rear one R on (11,7). F's block is filled by decoys so the next enemy E walks through it and is held by R —
* between the two. The decoys die, then R dies: F takes E over. Returns { h, F, E, hits, couldAttack } over `secs` s.
*/
function heldBetween(chessId, dir, secs = 8, { late = false } = {}) {
const ds = getDefaultSource();
const cap = ds.rawChess(chessId).stats.blockCnt;
const enemies = { enemy_e: foe('enemy_e') };
const spawns = [];
for (let i = 0; i < cap; i++) { enemies[`enemy_x${i}`] = foe(`enemy_x${i}`); spawns.push({ key: `enemy_x${i}`, time: i * 0.6 }); }
spawns.push({ key: 'enemy_e', time: cap * 0.6 + 0.3 });
const h = makeBattle({
stageId: 'act2autochess_m01', timeLimit: 200, hooks: ['damaged'], captureNoisy: true,
defs: { chess: { [REAR]: chessRec({ id: REAR, stats: { atk: 0, blockCnt: 1, maxHp: 1e9, respawnTime: 999 }, skill: null }) }, enemies },
units: [{ chessId, row: 10, col: 7, dir }, { chessId: REAR, row: 11, col: 7, dir }],
enemies: spawns,
});
const F = h.unit(chessId), R = h.unit(REAR);
const decoys = () => h.b.enemies.filter((e) => e.defId.startsWith('enemy_x'));
const E = () => h.b.enemies.find((e) => e.defId === 'enemy_e');
const held = h.runUntil(() => decoys().length === cap && decoys().every((x) => x.blockedBy === F) && E()?.blockedBy === R, 40);
if (!held) return null; // (a summoner's own summon took a decoy: not this layout)
const e = E();
if (!late) {
for (const x of decoys()) h.b.loseHp(x, 1e12);
h.step(2);
h.b.loseHp(R, 1e12);
} else {
// the rear operator dies while the front one is still full: the enemy walks on past it, and the front one frees a
// slot once the enemy is beyond its own tile but still inside the contact radius (0.52–0.69 tile from its centre)
h.b.loseHp(R, 1e12);
let ok = false;
for (let i = 0; i < 120 && e.alive; i++) {
h.step();
const d = Math.hypot(e.x - F.x, e.y - F.y);
if (d > 0.52 && d < 0.69) { ok = true; break; }
}
if (!ok) return null;
for (const x of decoys()) h.b.loseHp(x, 1e12);
}
h.step(2);
const t0 = h.b.time;
let couldAttack = 0, inRange = false;
const end = t0 + secs;
while (h.b.time < end && e.alive) {
h.step();
const p = effectiveProfile(F);
if (F.canAct && !p.noAttack && !(p.noAttackUnlessSkill && !F.skill?.active) && !F.s.flags.disarm && p.dmgType !== 'heal' && e.blockedBy === F) couldAttack += 1 / 30;
if (F.rangeKeySet?.has(Math.round(e.y) * COLS + Math.round(e.x))) inRange = true;
}
const hits = h.hooksOf('damaged').filter((c) => c.source === F && c.target === e && c.t > t0).length;
return { h, F, E: e, hits, couldAttack, inRange };
}
test('#17 the user\'s case on act2 m01: the front operator takes over the enemy held between the two AND attacks it — every blocking chess, 4 directions', REAL, () => {
// the held enemy stands ≈ 0.29 tile behind the front operator's centre, on its own tile: a melee unit may always hit
// it (blocked-first), a ranged one on a melee tile has it in range too (its own tile) and hits it by its own order
const ds = getDefaultSource();
const blockers = Object.values(ds.raw.chess).filter((c) => c.stats && c.visible !== false && !c.isGolden && c.stats.blockCnt > 0);
assert.ok(blockers.filter((c) => c.position === 'MELEE').length >= 50, 'every melee blocker of the pool');
const branches = new Set();
let checked = 0;
for (const c of blockers) {
for (const dir of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
const r = heldBetween(c.chessId, dir);
if (!r) continue;
checked++;
branches.add(c.subProfessionId);
if (r.E.alive && r.E.blockedBy !== r.F) {
// only a status that cannot be blocked (妮芙's 恐惧, 浮空 …) or a displacement out of contact lets it go
const f = r.E.s.flags;
assert.ok(f.fear || f.unblockable || f.levitate || f.attract || Math.hypot(r.E.x - r.F.x, r.E.y - r.F.y) > 0.7071, `${c.name} ${dir}: takes the enemy over`);
continue;
}
// healers, and skills that say 停止攻击 (泡泡 “挨打”, 凯瑟琳 战火淬炼 …) / attack only while active (阵法术师), are the
// only reasons not to hit it
if (r.couldAttack >= 2) assert.ok(r.hits > 0, `${c.name} (${c.subProfessionId}, ${c.position}) ${dir}: attacks the enemy it took over (could attack ${r.couldAttack.toFixed(1)} s)`);
}
}
assert.ok(checked >= 350, `layouts checked: ${checked}`);
assert.ok(branches.size >= 45, `branches: ${branches.size}`);
});
test('#17 a late take-over (the enemy slid behind the front operator\'s tile): every blocker hits it, ranged ones on melee tiles included (the user\'s rule)', REAL, () => {
// the rear operator dies while the front one is full; the front one frees a slot when the enemy is 0.52–0.69 tile past
// its centre (outside its tile, inside the 0.7071 contact radius) and takes it over. Every blocking chess of the pool,
// the hidden ones too (见行者, 崖心, 百炼嘉维尔), × 4 directions
const ds = getDefaultSource();
const blockers = Object.values(ds.raw.chess).filter((c) => c.stats && !c.isGolden && c.stats.blockCnt > 0 && (c.visible !== false || c.isHidden));
assert.ok(blockers.some((c) => c.isHidden && c.position === 'MELEE'), 'hidden melee chess included');
let melee = 0, ranged = 0, rangedOut = 0;
const branches = new Set();
for (const c of blockers) {
for (const dir of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
const r = heldBetween(c.chessId, dir, 8, { late: true });
if (!r || r.couldAttack < 2) continue; // (no take-over, or a 停止攻击 skill / healer: never attacks)
branches.add(c.subProfessionId);
if (c.position === 'MELEE') melee++; else { ranged++; if (!r.inRange) rangedOut++; }
assert.ok(r.hits > 0, `${c.name} (${c.subProfessionId}, ${c.position}) ${dir}: hits the enemy it took over behind its tile${r.inRange ? '' : ', out of its range'} (could attack ${r.couldAttack.toFixed(1)} s)`);
}
}
assert.ok(melee >= 150, `melee take-overs checked: ${melee}`);
assert.ok(ranged >= 10 && rangedOut >= 10, `ranged take-overs checked: ${ranged} (${rangedOut} out of range)`);
assert.ok(branches.size >= 40, `branches: ${branches.size}`);
});
/**
* A plain block on act2 m01: the blocker alone on (10,7) facing `dir`, one walker coming up the lane. Returns
* { F, E, hits, couldAttack, inRange } over `secs` s once it blocks, or null (a summon of its own took the walker).
*/
function plainBlock(chessId, dir, secs = 8) {
const h = makeBattle({
stageId: 'act2autochess_m01', timeLimit: 200, hooks: ['damaged'], captureNoisy: true,
defs: { enemies: { enemy_e: foe('enemy_e') } },
units: [{ chessId, row: 10, col: 7, dir }],
enemies: [{ key: 'enemy_e', time: 0.3 }],
});
const F = h.unit(chessId);
const E = () => h.b.enemies.find((e) => e.defId === 'enemy_e');
if (!h.runUntil(() => E()?.blockedBy === F, 40)) return null;
const e = E();
const t0 = h.b.time;
let couldAttack = 0, inRange = true;
while (h.b.time < t0 + secs && e.alive) {
h.step();
const p = effectiveProfile(F);
if (F.canAct && !p.noAttack && !(p.noAttackUnlessSkill && !F.skill?.active) && !F.s.flags.disarm && p.dmgType !== 'heal' && e.blockedBy === F) couldAttack += 1 / 30;
if (!F.rangeKeySet?.has(Math.round(e.y) * COLS + Math.round(e.x))) inRange = false;
}
const hits = h.hooksOf('damaged').filter((c) => c.source === F && c.target === e && c.t > t0).length;
return { F, E: e, hits, couldAttack, inRange };
}
test('#17 a plain block, every blocking chess × 4 facings: the blocker damages the enemy it blocks — in range or not (the user\'s rule)', REAL, () => {
// "阻挡了就一定要能打到" (user, after playtest #6): melee and ranged blockers alike, whichever way they face; healers and
// skills that stop attacking (泡泡 “挨打”, 凯瑟琳 战火淬炼 … — couldAttack) are the only exceptions
const ds = getDefaultSource();
const blockers = Object.values(ds.raw.chess).filter((c) => c.stats && !c.isGolden && c.stats.blockCnt > 0 && (c.visible !== false || c.isHidden));
let checked = 0, out = 0, ranged = 0;
for (const c of blockers) {
for (const dir of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
const r = plainBlock(c.chessId, dir);
if (!r || r.couldAttack < 2) continue;
checked++;
if (!r.inRange) out++;
if (c.position !== 'MELEE') ranged++;
assert.ok(r.hits > 0, `${c.name} (${c.subProfessionId}, ${c.position}) ${dir}: hits the enemy it blocks${r.inRange ? '' : ' (out of its range)'} — could attack ${r.couldAttack.toFixed(1)} s`);
}
}
assert.ok(checked >= 350, `blocks checked: ${checked}`);
assert.ok(out >= 50, `blocked enemies outside the blocker's range at some point: ${out}`);
assert.ok(ranged >= 20, `ranged blockers: ${ranged}`);
});
test('#17 skills that pick their own targets keep the enemy the operator blocks (QA residual): 迷迭香 S3, 荒芜拉普兰德 S2, 佩佩 S2', REAL, () => {
// the flat stage: the blocker on (9,6) faces LEFT, so the walker it stops coming from the gate (its right) stays outside
// its range while a still enemy on (9,4) stands inside it. These skills chose from the range only and never hit the
// blocked one again; 普罗旺斯 S2 keeps its official text ("普通攻击不再以生命值高于80%的敌人作为目标") — the exception
const ds = getDefaultSource();
const cases = [['迷迭香', 'skchr_rosmon_3', true], ['荒芜拉普兰德', 'skchr_whitw2_2', true], ['佩佩', 'skchr_pepe_2', true], ['普罗旺斯', 'skchr_prove_2', false]];
for (const [name, skillId, hitsIt] of cases) {
for (const gold of [false, true]) {
const c = Object.values(ds.raw.chess).find((x) => x.name === name && !!x.isGolden === gold);
const skillIndex = c.skills.findIndex((s) => s.skillId === skillId);
assert.ok(skillIndex >= 0, `${name} ${skillId}`);
const h = makeBattle({
timeLimit: 400, hooks: ['damaged'], captureNoisy: true,
defs: { enemies: { enemy_e: foe('enemy_e'), enemy_o: foe('enemy_o', { speed: 0 }) } },
units: [{ chessId: c.chessId, row: 9, col: 6, dir: 'LEFT', skillIndex }],
enemies: [{ key: 'enemy_e', time: 0.2 }, { key: 'enemy_o', time: 0.1, route: { motion: 'WALK', start: [9, 4], end: [9, 2], checkpoints: [] } }],
});
const F = h.unit(c.chessId);
const E = () => h.b.enemies.find((x) => x.defId === 'enemy_e');
assert.ok(h.runUntil(() => E()?.blockedBy === F, 40), `${name}: blocks the walker`);
const e = E();
h.step(30);
if (!F.skill.active) F.skill.activate('manual', { free: true });
assert.ok(F.skill.active && F.skill.id === skillId, `${name}: ${skillId} on`);
const t0 = h.b.time;
let outside = true;
while (h.b.time < t0 + 6) {
h.step();
if (F.rangeKeySet?.has(Math.round(e.y) * COLS + Math.round(e.x))) outside = false;
}
assert.ok(outside, `${name}: the blocked walker stays outside the range`);
const hits = h.hooksOf('damaged').filter((x) => x.t > t0 && x.source === F && x.target === e).length;
if (hitsIt) assert.ok(hits > 0, `${name}${gold ? ' (golden)' : ''} ${skillId}: hits the enemy it blocks (${hits})`);
else assert.equal(hits, 0, `${name} ${skillId}: official — no normal attack on an enemy above 80 % HP, blocked or not`);
checkInvariants(h.b);
}
}
});
test('#17 流形\'s melee copy (a summon of no fixed position) attacks the enemy it blocks outside its range', REAL, () => {
// 缪尔赛思's 流形 copies the melee guard; facing LEFT its copied range (its tile + the one to its left) never holds the
// enemy it stops coming from the gate side (≈ 0.71 tile to its right)
const guard = chessRec({ id: 'test_guard_a', profession: 'WARRIOR', position: 'MELEE', stats: { atk: 500, blockCnt: 2, maxHp: 1e6, def: 0 }, skill: null });
const h = makeBattle({
defs: { chess: { test_guard_a: guard }, enemies: { enemy_w: foe('enemy_w') } }, timeLimit: 120, hooks: ['damaged'], captureNoisy: true,
units: [
{ chessId: 'chess_char_6_11_a', row: 12, col: 3 },
{ chessId: 'test_guard_a', row: 12, col: 6 },
{ kind: 'token', tokenId: 'token_10030_mlyss_wtrman', row: 9, col: 6, ownerUid: 1, dir: 'LEFT' },
],
enemies: [{ key: 'enemy_w', time: 9 }],
});
const tok = () => h.b.allyUnits.find((x) => x.defId === 'token_10030_mlyss_wtrman' && x.alive);
assert.ok(h.runUntil(() => tok()?.mem.mlyss && !tok().mem.mlyss.ranged, 9), 'a melee copy');
const e = () => h.b.enemies.find((x) => x.defId === 'enemy_w');
assert.ok(h.runUntil(() => e()?.blockedBy === tok(), 30), 'the copy blocks the walker');
const t = tok();
assert.ok(!t.rangeKeySet.has(Math.round(e().y) * 21 + Math.round(e().x)), 'outside its range (behind its facing)');
const t0 = h.b.time;
h.run(4);
assert.ok(h.hooksOf('damaged').some((c) => c.source === t && c.target === e() && c.t > t0), 'it hits the enemy it blocks');
checkInvariants(h.b);
});
test('#17 heavy slows: ASPD never drops below 20 (PRTS 数值范围) — two −50 slows on a BAT 1.2 unit give a 6 s interval, not 12 s', () => {
assert.equal(ASPD_MIN, 20);
const u = new Unit({ id: 1, side: 'ally', kind: 'op', base: { maxHp: 1000, atk: 100, def: 0, res: 0, aspd: 100, bat: 1.2 } });
u.alive = true;
u.buffs.push({ key: 'terrain:mire', stacks: 1, mods: { aspd: -50 } }, { key: 'ab:frost', stacks: 1, mods: { aspd: -50 } });
u.markDirty();
assert.equal(u.s.aspd, 20);
approx(u.s.interval, 6, 1e-9, 'interval = 1.2 × 100 / 20');
u.buffs.push({ key: 'more', stacks: 1, mods: { aspd: -300 } });
u.markDirty();
assert.equal(u.s.aspd, 20, 'still 20 under any slow');
});
test('#17 the user\'s case under heavy slows: the front blocker that took the enemy over hits it every 5 × BAT', () => {
// the held enemy between two operators on act2 m01 (as above); the front one stands on 沼泽 next to a 寒霜 aura
// (ASPD −50 each): officially it still attacks every 1.2 × 100 / 20 = 6 s — v2.4.1 swung every 12 s
const front = chessRec({ id: 'test_front_a', profession: 'TANK', subProfessionId: 'guardian', position: 'MELEE', stats: { atk: 100, def: 0, blockCnt: 1, maxHp: 1e9, bat: 1.2 }, skill: null });
const rear = chessRec({ id: REAR, stats: { atk: 0, blockCnt: 1, maxHp: 1e9, respawnTime: 999 }, skill: null });
const h = makeBattle({
stageId: 'act2autochess_m01', timeLimit: 200, hooks: ['damaged'], captureNoisy: true,
defs: { chess: { test_front_a: front, [REAR]: rear }, enemies: { enemy_x0: foe('enemy_x0'), enemy_e: foe('enemy_e') } },
units: [{ chessId: 'test_front_a', row: 10, col: 7 }, { chessId: REAR, row: 11, col: 7 }],
enemies: [{ key: 'enemy_x0', time: 0 }, { key: 'enemy_e', time: 0.9 }],
});
const F = h.unit('test_front_a'), R = h.unit(REAR);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x0');
const E = () => h.b.enemies.find((e) => e.defId === 'enemy_e');
assert.ok(h.runUntil(() => x()?.blockedBy === F && E()?.blockedBy === R, 40), 'held between the two');
h.b.loseHp(x(), 1e12);
h.b.loseHp(R, 1e12);
h.step(2);
assert.equal(E().blockedBy, F, 'taken over');
h.b.addBuff(F, { key: 'terrain:mire', persist: true, mods: { aspd: -50 } });
h.b.addBuff(F, { key: 'ab:frost', persist: true, mods: { aspd: -50 } });
assert.equal(F.s.aspd, 20);
const t0 = h.b.time;
h.run(30);
const hits = h.hooksOf('damaged').filter((c) => c.source === F && c.target === E() && c.t > t0).length;
assert.ok(hits >= 4 && hits <= 6, `≈ 30 / 6 hits in 30 s (got ${hits}; ASPD 10 would give ≤ 3)`);
});
// =====================================================================================================================
// #14 — 力度 vs 重量
const PUSH = [[-3, 0], [-2, 0.12], [-1, 0.44], [0, 1.7], [1, 2.14], [2, 2.96], [3, 3.53], [4, 3.53]];
function lane(extraEnemies = {}, units = []) {
const enemies = {};
for (let m = 0; m <= 10; m++) enemies[`enemy_m${m}`] = foe(`enemy_m${m}`, { speed: 0, mass: m });
return makeBattle({ defs: { enemies: { ...enemies, ...extraEnemies } }, units, content: 'full', autoFinish: false, timeLimit: 60, hooks: ['damaged'], captureNoisy: true });
}
test('#14 the official push table: 受力等级 = 力度 − 重量 (PRTS 游戏数据基础 §重量公式 / 推与拉)', () => {
for (const [lvl, tiles] of PUSH) {
if (lvl >= -2 && lvl <= 3) approx(PUSH_TILES[lvl], tiles, 1e-9, `table ${lvl}`);
}
const h = lane();
h.step();
for (let m = 0; m <= 6; m++) {
for (const force of [-1, 0, 1, 2, 3]) {
const e = h.spawn(`enemy_m${m}`, { pos: [10, 3] });
assert.equal(h.b.forceLevel(e, force), force - m);
const want = PUSH.find(([l]) => l === Math.max(-3, Math.min(4, force - m)))[1];
const moved = h.b.push(e, force, { from: { x: 2, y: 10 } });
approx(moved, want, 1e-9, `force ${force} vs weight ${m}`);
approx(e.x, 3 + want, 1e-9, 'radial: straight away from the pusher');
h.b.loseHp(e, 1e12);
}
}
// 失重 counts (current weight); 失衡免疫 and leaders never move
const w = h.spawn('enemy_m3', { pos: [10, 3] });
h.b.addBuff(w, { key: 'weightless', mods: { massFlat: -1 } });
approx(h.b.push(w, 1, { from: { x: 2, y: 10 } }), 0.44, 1e-9, '失重 weight 3 → 2');
const f = h.spawn('enemy_m0', { pos: [11, 3] });
h.b.addBuff(f, { key: 'noDisp', flags: { noDisplace: true } });
assert.equal(h.b.push(f, 5, { from: { x: 2, y: 11 } }), 0, '失衡免疫');
checkInvariants(h.b);
});
test('#14 directional pushes: > 45° off the direction or nearer than 0.25 → radial with 受力等级 −2, unless fixed (见行者 / 圣聆初雪)', () => {
const h = lane();
h.step();
const from = { x: 3, y: 10 }, right = { x: 1, y: 0 };
const a = h.spawn('enemy_m1', { pos: [10, 4] });
approx(h.b.push(a, 1, { from, dir: right }), 1.7, 1e-9, 'straight ahead: 中力 vs 1 = 1.7 along the direction');
approx(a.y, 10, 1e-9);
const b = h.spawn('enemy_m1', { pos: [11, 3.5] }); // 63° off the direction
const moved = h.b.push(b, 1, { from, dir: right });
approx(moved, 0.12, 1e-9, 'radial, 受力等级 0 − 2 = −2');
assert.ok(b.y > 11, 'pushed away from the pusher, not along its direction');
const c = h.spawn('enemy_m1', { pos: [11, 3.5] });
approx(h.b.push(c, 1, { from, dir: right, fixed: true }), 1.7, 1e-9, 'fixed: along the direction, full force');
approx(c.y, 11, 1e-9);
// 见行者 S2 (PRTS 备注 waives only the angle): 63° off → along the direction at full force; nearer than 0.25 → radial −2
const d = h.spawn('enemy_m1', { pos: [12, 3.5] });
approx(h.b.push(d, 1, { from: { x: 3, y: 11 }, dir: right, fixedAngle: true }), 1.7, 1e-9, 'fixedAngle: angle waived');
approx(d.y, 12, 1e-9);
const n = h.spawn('enemy_m1', { pos: [9, 3.1] });
approx(h.b.push(n, 1, { from: { x: 3, y: 9 }, dir: right, fixedAngle: true }), 0.12, 1e-9, 'fixedAngle: < 0.25 tile still radial −2');
const n2 = h.spawn('enemy_m1', { pos: [8, 3.1] });
approx(h.b.push(n2, 1, { from: { x: 3, y: 8 }, dir: right, fixed: true }), 1.7, 1e-9, 'fixed (圣聆初雪): < 0.25 tile waived too');
checkInvariants(h.b);
});
test('#14 见行者 S2 惊爆射击: angle waived, a target nearer than 0.25 tile radial −2 — and only a real wall lengthens the stun', REAL, () => {
const id = 'chess_char_3_07_a';
const ds = getDefaultSource();
const bb = ds.rawChess(id).skill.bb;
const run = (pos) => {
const h = makeBattle({
defs: { enemies: { enemy_t: foe('enemy_t', { speed: 0, mass: 0 }) } }, units: [{ chessId: id, row: 10, col: 3, dir: 'RIGHT', carryState: { sp: 1e3 } }],
enemies: [{ key: 'enemy_t', pos }], autoFinish: false, timeLimit: 30, hooks: ['statusApplied'], captureNoisy: true,
});
const u = h.unit(id);
assert.ok(h.runUntil(() => h.hooksOf('statusApplied').some((c) => c.source === u && c.status === 'stun'), 6), 'cast');
const st = h.hooksOf('statusApplied').find((c) => c.source === u && c.status === 'stun');
return { e: h.b.enemies[0], stun: st.duration };
};
// straight ahead, open ground: 中力 vs weight 0 = 受力等级 1 — a 特效 push (PRTS 推与拉 names 见行者's skills 特效类):
// 1.987 tiles along her direction, the short stun
const a = run([10, 4]);
approx(a.e.x, 4 + PUSH_TILES_EFFECT[1], 1e-6, 'ahead: 1.987');
approx(a.stun, bb['forcer_s_2[hit_directly].stun'], 1e-9, 'open ground: hit_directly stun');
// on her own tile, nearer than 0.25: radial at 受力等级 1 − 2 = −1 (0.374), still the short stun (no wall)
const b = run([10.1, 3.1]);
approx(Math.hypot(b.e.x - 3.1, b.e.y - 10.1), PUSH_TILES_EFFECT[-1], 1e-6, 'near: radial −2');
approx(b.stun, bb['forcer_s_2[hit_directly].stun'], 1e-9, 'near target: not a wall');
});
test('#14 特效 pushes (PRTS 推与拉: one frame less of travel than 弹道 ones): the 特效 column, and 见行者 S1 护身射击 uses it', REAL, () => {
const EFFECT = [[-3, 0], [-2, 0.085], [-1, 0.374], [0, 1.562], [1, 1.987], [2, 2.773], [3, 3.331], [4, 3.331]];
const h = lane();
h.step();
for (let m = 0; m <= 4; m++) {
const e = h.spawn(`enemy_m${m}`, { pos: [10, 3] });
const want = EFFECT.find(([l]) => l === Math.max(-3, Math.min(4, 1 - m)))[1];
approx(h.b.pushDistance(e, 1, { effect: true }), want, 1e-9, `distance: 中力 vs weight ${m}`);
approx(h.b.push(e, 1, { from: { x: 2, y: 10 }, effect: true }), want, 1e-9, `push: 中力 vs weight ${m}`);
h.b.loseHp(e, 1e12);
}
// 见行者 S1 (generic kit, 推击手 directional push on her next attack, bb.force 1) on a weight-0 enemy ahead: 1.987, not 2.14
const id = 'chess_char_3_07_a';
const k = makeBattle({
defs: { enemies: { enemy_t: foe('enemy_t', { speed: 0, mass: 0 }) } }, units: [{ chessId: id, row: 10, col: 3, dir: 'RIGHT', skillIndex: 0, carryState: { sp: 1e3 } }],
enemies: [{ key: 'enemy_t', pos: [10, 4] }], autoFinish: false, timeLimit: 30,
});
const u = k.unit(id);
assert.equal(u.skill.id, 'skchr_forcer_1');
k.step();
const e = k.b.enemies[0];
assert.ok(e && k.runUntil(() => e.x > 4.01, 6), 'pushed');
k.step(2);
approx(e.x, 4 + PUSH_TILES_EFFECT[1], 1e-6, '特效 column');
});
test('#14 锏 S3 归于宁静 slashes and pulls air units too (PRTS 备注 "※可对空。不会拖拽自身中心半径0.6708范围内的敌人")', REAL, () => {
const id = 'chess_char_6_19_a';
const h = makeBattle({
defs: { enemies: { enemy_fly: foe('enemy_fly', { speed: 0, mass: 0, motion: 'FLY' }), enemy_near: foe('enemy_near', { speed: 0, mass: 0 }) } },
units: [{ chessId: id, row: 10, col: 4, dir: 'RIGHT' }], enemies: [{ key: 'enemy_fly', pos: [10, 6] }, { key: 'enemy_near', pos: [10, 4.5] }],
autoFinish: false, timeLimit: 30, hooks: ['damaged'], captureNoisy: true,
});
h.step();
const u = h.unit(id);
assert.equal(u.skill.id, 'skchr_blkkgt_3');
const fly = h.b.enemies.find((x) => x.defId === 'enemy_fly'), near = h.b.enemies.find((x) => x.defId === 'enemy_near');
assert.ok(fly.isFlying);
assert.ok(u.skill.activate('test', { free: true }), 'S3 on');
h.run(4.5);
const slashes = h.hooksOf('damaged').filter((c) => c.source === u && c.target === fly && c.dmg?.isSkill).length;
assert.ok(slashes >= 10, `the flyer is slashed (${slashes} skill hits)`);
// 中力 pull vs weight 0 (受力等级 1): all the way, to the 急停 radius around her centre
approx(Math.hypot(fly.x - u.x, fly.y - u.y), PULL_STOP_RADIUS, 1e-6, 'pulled in to 0.6708 from her centre');
approx(near.x, 4.5, 1e-9, 'an enemy inside the 0.6708 radius is not pulled');
});
test('#14 pulls: 受力等级 ≥ 0 all the way to the 急停 radius, −1 → 35 % of the way, −2 → 0.03, ≤ −3 → nothing; the puller\'s own blocked enemy stays', () => {
const guard = chessRec({ id: 'test_puller_a', stats: { atk: 0, blockCnt: 0, maxHp: 1e6 }, skill: null });
const h = makeBattle({ defs: { chess: { test_puller_a: guard }, enemies: Object.fromEntries([0, 1, 2, 3, 4].map((m) => [`enemy_m${m}`, foe(`enemy_m${m}`, { speed: 0, mass: m })])) },
units: [{ chessId: 'test_puller_a', row: 10, col: 3 }], content: 'none', autoFinish: false, timeLimit: 60 });
h.step();
const u = h.unit('test_puller_a');
const start = 7;
const expect = { 0: start - 3 - PULL_STOP_RADIUS, 1: start - 3 - PULL_STOP_RADIUS, 2: 0.35 * (start - 3.5), 3: 0.03, 4: 0 };
for (const m of [0, 1, 2, 3, 4]) {
const e = h.spawn(`enemy_m${m}`, { pos: [10, start] });
approx(h.b.pullToFront(e, u, 1), expect[m], 1e-6, `中力 vs weight ${m}`);
h.b.loseHp(e, 1e12);
}
const g = h.spawn('enemy_m0', { pos: [10, 3.7] });
g.blockedBy = u; u.blocking.push(g);
assert.equal(h.b.pullToFront(g, u, 3), 0, 'already held in front of it');
assert.equal(g.blockedBy, u);
});
test('#14 流形 S3 pulse: the enemy a melee copy blocks is not pulled (so not released) and is stunned at every pulse', REAL, () => {
const guard = chessRec({ id: 'test_guard_a', profession: 'WARRIOR', position: 'MELEE', stats: { atk: 500, blockCnt: 2, maxHp: 1e6, def: 0 }, skill: null });
for (const mass of [0, 2]) {
const h = makeBattle({
defs: { chess: { test_guard_a: guard }, enemies: { enemy_w: foe('enemy_w', { mass }) } }, timeLimit: 120, captureNoisy: true,
units: [
{ chessId: 'chess_char_6_11_a', row: 12, col: 3, skillIndex: 2 },
{ chessId: 'test_guard_a', row: 12, col: 6 },
{ kind: 'token', tokenId: 'token_10030_mlyss_wtrman', row: 9, col: 6, ownerUid: 1, dir: 'LEFT' },
],
enemies: [{ key: 'enemy_w', time: 9 }],
});
const tok = () => h.b.allyUnits.find((x) => x.defId === 'token_10030_mlyss_wtrman' && x.alive);
assert.ok(h.runUntil(() => tok()?.mem.mlyss && !tok().mem.mlyss.ranged, 9), 'a melee copy');
const E = () => h.b.enemies.find((x) => x.defId === 'enemy_w');
assert.ok(h.runUntil(() => E()?.blockedBy === tok(), 30), 'blocked by the copy');
const u = h.unit('chess_char_6_11_a');
assert.equal(u.skill.id, 'skchr_mlyss_3');
assert.ok(u.skill.activate('test', { free: true }), 'S3 on');
const e = E(), x0 = e.x, y0 = e.y;
let stunned = 0, released = 0;
for (let i = 0; i < 30 * 6; i++) { h.step(); if (e.s.flags.stun) stunned++; if (e.blockedBy !== tok()) released++; }
assert.equal(released, 0, `weight ${mass}: stays blocked`);
assert.equal(stunned, 180, `weight ${mass}: stunned through every pulse`);
approx(Math.hypot(e.x - x0, e.y - y0), 0, 1e-9, 'not pulled');
}
});
test('#14 the user\'s case: 野鬃 S2 夹枪冲锋 pushes by 力度 − 重量 — light enemies far, weight ≥ 4 not at all', REAL, () => {
const ds = getDefaultSource();
const id = 'chess_char_1_19_a';
assert.equal(ds.rawChess(id).skill.bb['attack@force'], 1, 'data: 中等力度 = 1');
// the displacement of her first skill hit: the enemy's position before the attack and right after it (the melee hit and
// its push resolve inside the attack)
const moved = (mass) => {
const pushes = [];
const h = makeBattle({
defs: { enemies: { enemy_t: foe('enemy_t', { speed: 0, mass }) } }, units: [{ chessId: id, row: 10, col: 3, carryState: { sp: 1e3 } }],
enemies: [{ key: 'enemy_t', pos: [10, 4] }], autoFinish: false, timeLimit: 30, hooks: [],
setup(b) {
let x0 = null;
b.on('beforeAttack', (c) => { if (c.attacker.defId === id && c.isSkill) x0 = c.targets[0]?.x ?? null; }, { priority: -2000 });
b.on('attack', (c) => { if (c.attacker.defId === id && c.isSkill && x0 != null) { pushes.push(c.targets[0].x - x0); x0 = null; } }, { priority: -2000 });
},
});
const u = h.unit(id);
assert.ok(h.runUntil(() => pushes.length > 0, 5), 'a skill hit');
assert.ok(u.skill.active);
return pushes[0];
};
const want = { 0: 2.14, 1: 1.7, 2: 0.44, 3: 0.12, 4: 0, 5: 0, 6: 0 };
for (const [m, d] of Object.entries(want)) {
const got = moved(Number(m));
approx(got, d, 1e-6, `weight ${m}`);
}
});
// =====================================================================================================================
// #18 — 炎佑 (孤立) and the 禁疗 summons are never healed
const medic = (o = {}) => chessRec({
id: 'test_medic_a', profession: 'MEDIC', subProfessionId: 'physician', position: 'RANGED', dmgType: 'heal',
rangeGrid: [...Array(9)].flatMap((_, r) => [...Array(21)].map((__, c) => [r - 4, c - 10])), stats: { atk: 400, maxHp: 1e6, blockCnt: 1, bat: 0.5 }, skill: null, ...o,
});
test('#18 炎佑 holds 孤立 (PRTS “炎佑” 天赋): medics never heal it, auras never pick it — while they heal an injured operator', REAL, () => {
const ds = getDefaultSource();
assert.deepEqual(ds.rawToken(TOKEN_IDS.yanyou).abnormal, ['isolated'], 'data: tokens.json abnormal');
const h = makeBattle({ defs: { chess: { test_medic_a: medic(), test_hurt_a: chessRec({ id: 'test_hurt_a', stats: { atk: 0, maxHp: 1e5 }, skill: null }) } },
units: [{ chessId: 'test_medic_a', row: 10, col: 5 }, { chessId: 'test_hurt_a', row: 12, col: 3 }], autoFinish: false, timeLimit: 60, hooks: ['heal'], captureNoisy: true });
h.step();
const [y] = spawnYanyou(h.b, 'p1', { atk: 0, hp: 5000 });
const m = h.unit('test_medic_a'), op = h.unit('test_hurt_a');
assert.ok(y.s.flags.isolated && y.s.flags.noHeal, '孤立 flags');
y.hp = y.s.maxHp * 0.3;
op.hp = op.s.maxHp * 0.6;
assert.ok(!h.b.alliesInGrid(m).includes(y), 'no ally selection picks it');
assert.equal(h.b.heal(m, y, 1000), 0, 'the heal pipeline refuses it');
h.run(5);
approx(y.hp, y.s.maxHp * 0.3, 1e-6, '炎佑 not healed');
assert.ok(op.hp > op.s.maxHp * 0.6, 'the injured operator is');
assert.ok(!h.hooksOf('heal').some((c) => c.target === y), 'no heal event on 炎佑');
checkInvariants(h.b);
});
test('#18 the summons that hold 禁疗 (PRTS summon pages) are not healed by others: data list + 狼群 next to a medic', REAL, () => {
const ds = getDefaultSource();
const healFree = ['token_10015_dusk_drgn', 'token_10019_nearl2_sword', 'token_10017_skadi2_dedant', 'token_10011_beewax_oblisk',
'token_10030_mlyss_wtrman', 'token_10028_vigil_wolf', 'token_10012_rosmon_shield', 'token_10040_siege2_vlion', 'token_10058_sbell2_icetgt'];
for (const id of healFree) assert.deepEqual(ds.rawToken(id).abnormal, ['healFree'], id);
assert.deepEqual(ds.rawToken('token_10039_ulpia_block').abnormal, ['isolated']);
for (const id of ['token_10000_silent_healrb', 'token_10006_vodfox_doll', 'token_10022_kazema_shadow', 'char_605_cmedic']) assert.deepEqual(ds.rawToken(id).abnormal, [], id);
const h = makeBattle({ defs: { chess: { test_medic_a: medic() } },
units: [{ chessId: 'chess_char_3_19_a', row: 12, col: 3 }, { chessId: 'test_medic_a', row: 11, col: 3 }, { kind: 'token', tokenId: 'token_10028_vigil_wolf', row: 10, col: 5, ownerUid: 1 }],
autoFinish: false, timeLimit: 60, hooks: ['heal'], captureNoisy: true });
h.step();
const w = h.b.allyUnits.find((x) => x.defId === 'token_10028_vigil_wolf' && x.alive);
assert.ok(w && w.s.flags.noHeal, '禁疗');
w.hp = w.s.maxHp * 0.5;
h.run(4);
approx(w.hp, w.s.maxHp * 0.5, 1e-6, '狼群 not healed');
assert.ok(!h.hooksOf('heal').some((c) => c.target === w && c.source !== w));
});
test('#18 圣聆初雪\'s 保护目标(冻结状态) holds 禁疗 (PRTS 备注 "持有禁疗、无法撤退"): a medic next to it does not heal it', REAL, () => {
const h = makeBattle({ defs: { chess: { test_medic_a: medic() } },
units: [{ chessId: 'chess_char_6_02_a', row: 12, col: 3 }, { chessId: 'test_medic_a', row: 11, col: 3 }],
autoFinish: false, timeLimit: 60, hooks: ['heal'], captureNoisy: true });
h.step();
const ice = h.b.spawnToken(h.unit('chess_char_6_02_a'), 'token_10058_sbell2_icetgt', 10, 5);
assert.ok(ice && ice.alive && ice.s.flags.noHeal, '禁疗');
ice.hp = ice.s.maxHp * 0.5;
h.run(4);
approx(ice.hp, ice.s.maxHp * 0.5, 1e-6, 'not healed');
assert.ok(!h.hooksOf('heal').some((c) => c.target === ice && c.source !== ice));
});
test('#18 安洁莉娜 兼职工作 is an HP-regen attribute (PRTS 备注 "不受治疗加成和禁疗影响"): the 禁疗 狼群 gets it; 炎佑 (孤立) is never selected', REAL, () => {
const h = makeBattle({
units: [{ chessId: 'chess_char_3_19_a', row: 12, col: 3 }, { chessId: 'chess_char_5_20_a', row: 11, col: 3, carryState: { sp: 0 } },
{ kind: 'token', tokenId: 'token_10028_vigil_wolf', row: 10, col: 5, ownerUid: 1 }],
autoFinish: false, timeLimit: 60,
});
h.step();
const [y] = spawnYanyou(h.b, 'p1', { atk: 0, hp: 5000 });
h.run(2);
const ag = h.unit('chess_char_5_20_a');
assert.ok(!ag.skill.active, 'skill off');
const w = h.b.allyUnits.find((x) => x.defId === 'token_10028_vigil_wolf' && x.alive);
assert.ok(w.s.flags.noHeal, '狼群 holds 禁疗');
assert.equal(w.findBuff('aglina:parttime')?.mods.hpRegen, 20, '禁疗 does not stop the regen attribute');
assert.ok(w.s.hpRegen >= 20);
assert.ok(!y.findBuff('aglina:parttime') && !y.findBuff('aglina:field'), '孤立: no ally talent selects 炎佑');
assert.ok(h.unit('chess_char_3_19_a').findBuff('aglina:field'), 'the operators get 加速力场');
});
test('#18 孤立 in every ally selection: 刺玫\'s 荆藤庇荫 picks the operator in her range, not 炎佑 (higher max HP)', REAL, () => {
const h = makeBattle({
defs: { chess: { test_op_a: chessRec({ id: 'test_op_a', stats: { atk: 0, maxHp: 3000 }, skill: null }) } },
units: [{ chessId: 'chess_char_1_06_a', row: 10, col: 3, dir: 'RIGHT' }, { chessId: 'test_op_a', row: 10, col: 4 }], autoFinish: false, timeLimit: 60,
});
h.step();
const [y] = spawnYanyou(h.b, 'p1', { atk: 0, hp: 50000 });
const cm = h.unit('chess_char_1_06_a'), op = h.unit('test_op_a');
assert.ok(h.b.relocate(y, 10, 5), '炎佑 inside her range');
assert.ok(y.s.maxHp > op.s.maxHp);
assert.deepEqual(alliesInGridOf(h.b, cm).filter((a) => a !== cm), [op], 'the range selection skips 炎佑');
assert.ok(cm.skill.activate('test', { free: true }), 'skill on');
assert.equal(cm.mem.protege, op, 'protégé = the operator');
assert.ok(!y.buffs.some((b) => b.key.startsWith('vendla')), '炎佑 not taunted');
assert.equal(h.b.allySelectable(y, cm), false);
assert.equal(h.b.allySelectable(y, y), true, 'its own effects still reach it');
checkInvariants(h.b);
});