Files
ark-stronghold-protocol/test/sim/playtest5_blocking.test.js
T
sganggs 15291c2293 Playtest #5 fixes: hovering enemies, target order, blocking hand-over, 联防 knocked-out, elements, huge-boss hit area, maps, gear icon, phone HUD, 至简 price
- #1 近地悬浮 enemies count as air units (Unit.isFlying): ground-only attacks, splash, terrain and traps skip them;
  PRTS 不可对空 skill effects made ground-only; 掠海漂移体 / 吉兆飞鳞 drop modes; the bot plans them as air
- #2 an operator knocked out before 联防 is fielded and force-exited (PRTS 强制退场): it lies on its tile with the
  redeploy ring and redeploys (full timer [ASSUMED]); partners, observers and the server fallback included
- #3 element audit of every operator, item and enemy: 海嗣 numbers are official; bursts are 无来源, 元素脆弱 only
  raises 元素伤害, 元素抗性 applied, 盟约·辅助干员 fills all three gauges, 菲莱 / 哈洛德 / 纯烬 / enemy kits fixed
- #4 blocking by the official contact radius (0.7071): another operator in contact takes over at once when the
  blocker dies, withdraws or is stunned; melee blockers attack what they block
- #5 enemy target order per PRTS 索敌: blocker, special priority, taunt, last deployed; initial deploy 从上到下>从左到右,
  operators before summons; special priorities break ties by taunt then deploy order
- #6 act2 m01's normal-field blower machines (row 13) are drawn in the normal / 联防 views
- #7 Final Assault prep legality reads the player's half of the boss field (fence tiles deployable); all 11 stages
  audited against the official level files
- #8 regular settings gear; #9 the prep camera keeps the bench and temp rows clear of the shop bar on phones
- #10 huge bosses have the official 4.95 × 2.95 hit area (sim/body.js) and are 自缚 / unblockable
- #11 至简 costs 1: 购买价格为N's bb.price is a discount off the tier price, applied before other price effects
- DESIGN §19 and the normative §3 / §5 / §6 / §7 lines, SIM / META / DATA / PLAYING updated; 2595 unit tests and
  every browser suite green; dev recordings regenerated
2026-09-30 19:12:36 +08:00

272 lines
15 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 #5 item 4 — "如果敌人被卡在两个干员之间的话,靠后的干员死亡敌人会直接走掉,而不是换成靠前面的干员接替阻挡".
//
// Official rule (PRTS 游戏数据基础 §阻挡半径, 作战机制 §碰撞体积与位置识别 "中点判定 … 案例: 阻挡"): an unblocked ground
// enemy whose position lies within the ground block radius 0.7071 (squared 0.49999037) of a ground operator's centre is
// blocked by it while the operator has free block capacity — checked continuously, so an enemy that overlaps an
// operator when its blocker dies (or when the operator's capacity frees up) is taken over at once. Flying enemies:
// air block radius 0.8944 (起飞 / blockFly units); devices (阻隔工事 / 障碍物): 0.4472.
//
// Flat stage: lower-gate enemies walk row 9 from the gate (9,10) to the goal (9,2), i.e. towards smaller columns.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { BLOCK_RADIUS } from '../../server/sim/constants.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
const FRONT = 'test_front_a', BACK = 'test_back_a';
const op = (id, blockCnt = 1) => chessRec({ id, stats: { atk: 0, blockCnt, maxHp: 1e6, respawnTime: 999 }, skill: null });
const foe = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 1, ...o });
function lane(units, enemies, defs = {}) {
return makeBattle({
defs: {
chess: { [FRONT]: op(FRONT, defs.frontBlock ?? 1), [BACK]: op(BACK, defs.backBlock ?? 1), ...(defs.chess || {}) },
enemies: { enemy_x: foe('enemy_x'), enemy_e: foe('enemy_e'), enemy_heavy: foe('enemy_heavy', { blockCnt: 2 }), ...(defs.enemies || {}) },
},
units, enemies, timeLimit: 200, content: 'none',
});
}
const kill = (h, u) => h.b.loseHp(u, 1e12);
test('the official block radii are the PRTS numbers', () => {
assert.ok(Math.abs(BLOCK_RADIUS.ground - 0.7071) < 1e-3);
assert.ok(Math.abs(BLOCK_RADIUS.fly - 0.8944) < 1e-3);
assert.ok(Math.abs(BLOCK_RADIUS.device - 0.4472) < 1e-3);
});
test('#4: the enemy between two operators is taken over by the front one when its blocker (the rear one) dies', () => {
// front (9,6) blocks X; E walks through the full front operator and is blocked by the rear one (9,5) — it now
// overlaps both. X dies (front has room again), then the rear blocker dies: E must stay, blocked by the front one.
const h = lane([{ chessId: FRONT, row: 9, col: 6 }, { chessId: BACK, row: 9, col: 5 }],
[{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
const front = h.unit(FRONT), back = h.unit(BACK);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
assert.ok(h.runUntil(() => x()?.blockedBy === front && e()?.blockedBy === back, 30), 'X blocked by the front, E by the rear operator');
const ex = e().x;
assert.ok(Math.hypot(ex - 6, e().y - 9) < BLOCK_RADIUS.ground, `E overlaps the front operator too (x=${ex.toFixed(3)})`);
kill(h, x());
h.step(2);
assert.equal(e().blockedBy, back, 'E stays with its blocker while the rear operator lives');
kill(h, back);
h.step(2);
assert.equal(e().blockedBy, front, 'the front operator takes E over');
h.run(5);
assert.equal(e().blockedBy, front);
assert.ok(Math.abs(e().x - ex) < 1e-6, 'E did not walk on');
checkInvariants(h.b);
});
test('#4: a withdrawn or stunned blocker hands its enemy to the operator it still touches', () => {
for (const how of ['retreat', 'stun']) {
const h = lane([{ chessId: FRONT, row: 9, col: 6 }, { chessId: BACK, row: 9, col: 5 }],
[{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
const front = h.unit(FRONT), back = h.unit(BACK);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
assert.ok(h.runUntil(() => x()?.blockedBy === front && e()?.blockedBy === back, 30));
kill(h, x());
if (how === 'retreat') h.b.retreat(back); else h.b.applyStatus(back, 'stun', { duration: 5 });
h.step(2);
assert.equal(e().blockedBy, front, how);
checkInvariants(h.b);
}
});
test('#4: the rear blocker dies while the front operator is still full — the enemy walks on (no room)', () => {
const h = lane([{ chessId: FRONT, row: 9, col: 6 }, { chessId: BACK, row: 9, col: 5 }],
[{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
const front = h.unit(FRONT), back = h.unit(BACK);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
assert.ok(h.runUntil(() => x()?.blockedBy === front && e()?.blockedBy === back, 30));
kill(h, back);
h.step(2);
assert.equal(e().blockedBy, null, 'no room at the front → E walks on');
const x0 = e().x;
h.run(1);
assert.ok(e().x < x0 - 0.2, 'E is walking again');
checkInvariants(h.b);
});
test('#4: an operator whose capacity frees up blocks an enemy that still overlaps it (past its tile, inside 0.7071)', () => {
const h = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
const front = h.unit(FRONT);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
assert.ok(h.runUntil(() => x()?.blockedBy === front, 30));
// E walks through the full operator; free the capacity once E has left the operator's tile (x < 5.5) but is still
// within the block radius
assert.ok(h.runUntil(() => e() && e().x < 5.42, 30));
assert.equal(e().blockedBy, null, 'passing through while full');
kill(h, x());
h.step(1);
assert.equal(e().blockedBy, front, 'taken as soon as the operator has room');
checkInvariants(h.b);
});
test('#4: pass-through when full — an enemy that never finds room walks past the operator', () => {
const h = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
const front = h.unit(FRONT);
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
assert.ok(h.runUntil(() => e() && e().x < 4.5, 40), 'E walked past the full operator');
assert.equal(e().blockedBy, null);
assert.equal(front.blocking.length, 1);
checkInvariants(h.b);
});
test('#4: contact happens at the block radius (the enemy stops ~0.71 tile from the operator, outside its tile)', () => {
const h = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_x', time: 0 }]);
const front = h.unit(FRONT);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
assert.ok(h.runUntil(() => x()?.blockedBy === front, 30));
const d = Math.hypot(x().x - 6, x().y - 9);
assert.ok(d <= BLOCK_RADIUS.ground + 1e-9 && d > BLOCK_RADIUS.ground - 0.05, `blocked at contact (d=${d.toFixed(3)})`);
checkInvariants(h.b);
});
test('#4: an operator beside the lane (one row off) never touches the enemies walking it', () => {
const h = lane([{ chessId: FRONT, row: 10, col: 6 }], [{ key: 'enemy_x', time: 0 }]);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
assert.ok(h.runUntil(() => x() && x().x < 4, 40));
assert.equal(h.hooksOf('blocked').length, 0);
});
test('#4: block weight — a 2-weight enemy needs 2 free block', () => {
const h1 = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_heavy', time: 0 }]);
const heavy1 = () => h1.b.enemies.find((e) => e.defId === 'enemy_heavy');
assert.ok(h1.runUntil(() => heavy1() && heavy1().x < 4.5, 40), 'block 1 cannot hold a 2-weight enemy');
assert.equal(heavy1().blockedBy, null);
const h2 = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_heavy', time: 0 }], { frontBlock: 2 });
assert.ok(h2.runUntil(() => h2.b.enemies[0]?.blockedBy === h2.unit(FRONT), 30), 'block 2 holds it');
});
test('#4: of two free operators in contact the nearer one blocks', () => {
// an enemy standing still between (9,6) and (9,5), nearer to (9,5)
const h = lane([{ chessId: FRONT, row: 9, col: 6 }, { chessId: BACK, row: 9, col: 5 }], [],
{ enemies: { enemy_still: foe('enemy_still', { speed: 0 }) } });
h.step(1);
const e = h.spawn('enemy_still', { pos: [9, 5.4] });
h.step(1);
assert.equal(e.blockedBy, h.unit(BACK));
checkInvariants(h.b);
});
test('#4: a blocker attacks the enemy it blocks even when it stands outside its range (可以选择且优先选择阻挡单位)', () => {
// facing LEFT (away from the gate): its range (9,6),(9,5) never holds the enemy blocked at x ≈ 6.71 (tile (9,7))
const h = makeBattle({
defs: { chess: { t_back: chessRec({ id: 't_back', stats: { atk: 100, blockCnt: 1, maxHp: 1e6 }, skill: null }) }, enemies: { enemy_x: foe('enemy_x') } },
units: [{ chessId: 't_back', row: 9, col: 6, dir: 'LEFT' }], enemies: [{ key: 'enemy_x', time: 0 }], timeLimit: 60,
hooks: ['damaged'], captureNoisy: true,
});
const u = h.unit('t_back');
const x = () => h.b.enemies[0];
assert.ok(h.runUntil(() => x()?.blockedBy === u, 30));
assert.ok(!u.rangeKeySet.has(Math.round(x().y) * 21 + Math.round(x().x)), 'outside its range');
h.run(3);
assert.ok(h.hooksOf('damaged').some((c) => c.source === u && c.target === x()), 'it hits its blocked enemy');
});
test('#4: a ranged operator on a melee tile blocks, but never attacks or casts for the enemy held behind it (阻挡(近战限定))', () => {
// PRTS 索敌的概念: "我方索敌优先级:阻挡(近战限定)"; "远程位干员通常而言不会优先攻击自己阻挡的目标;甚至如果这个目标被阻挡在
// 该干员的身后,该干员可能因为敌人未处于自身攻击范围而无法攻击到这个敌人". Facing LEFT, its range (9,6),(9,5) never holds
// the enemy blocked at x ≈ 6.71; a melee operator in the same spot hits it and casts its DEFAULT skill (帮助: "敌人被近战
// 干员自身阻挡" satisfies the target condition).
const run = (id, profession) => {
const h = makeBattle({
defs: { chess: { [id]: chessRec({ id, profession, stats: { atk: 100, blockCnt: 1, maxHp: 1e6 }, skill: { spCost: 1, initSp: 1, duration: 5 } }) }, enemies: { enemy_x: foe('enemy_x') } },
units: [{ chessId: id, row: 9, col: 6, dir: 'LEFT' }], enemies: [{ key: 'enemy_x', time: 0 }], timeLimit: 60,
content: 'generic', kits: { [id]: () => ({ skill: { kind: 'duration', duration: 5 } }) }, hooks: ['damaged'], captureNoisy: true,
});
const u = h.unit(id);
const x = () => h.b.enemies[0];
assert.ok(h.runUntil(() => x()?.blockedBy === u, 30), `${profession} blocks`);
assert.ok(!u.rangeKeySet.has(Math.round(x().y) * 21 + Math.round(x().x)), 'outside its range');
h.run(3);
checkInvariants(h.b);
return { hits: h.hooksOf('damaged').filter((c) => c.source === u && c.target === x()).length, casts: u.skill.activations, still: x().blockedBy === u };
};
const ranged = run('t_rg', 'SNIPER');
assert.equal(ranged.hits, 0, 'the ranged blocker never hits the enemy behind it');
assert.equal(ranged.casts, 0, 'nor casts its skill for it');
assert.ok(ranged.still, 'it still holds the enemy');
const melee = run('t_ml', 'WARRIOR');
assert.ok(melee.hits > 0, 'a melee blocker hits it');
assert.equal(melee.casts, 1, 'and casts its skill');
});
test('#4: "melee" is the deploy position — a 领主 with a ranged attack (银灰) still hits the enemy it holds behind it', () => {
// data position MELEE, attackKind ranged (领主 / 哨戒铁卫 / 要塞 / 钩索师 …): a melee unit for the blocked-first selector
const id = 'chess_char_4_22_a';
const h = makeBattle({
defs: { enemies: { enemy_x: foe('enemy_x') } }, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
units: [{ chessId: id, row: 9, col: 6, dir: 'LEFT' }], enemies: [{ key: 'enemy_x', time: 0 }],
});
const u = h.unit(id);
const x = () => h.b.enemies[0];
assert.equal(u.def.position, 'MELEE');
assert.equal(u.profile.attack, 'ranged', 'a ranged attack profile');
assert.ok(h.runUntil(() => x()?.blockedBy === u, 30), 'blocks');
assert.ok(!u.rangeKeySet.has(Math.round(x().y) * 21 + Math.round(x().x)), 'outside its range (behind it)');
h.run(4);
assert.ok(h.hooksOf('damaged').some((c) => c.source === u && c.target === x()), 'it hits the enemy it blocks');
});
test('#4: blocked-first is a melee priority — a ranged blocker shoots the enemy nearest the goal', () => {
// range (9,5)–(9,7) facing RIGHT: the walker it blocks stands on (9,7); a flyer passes over the operator towards the
// goal. The ranged operator picks by its own order (least remaining distance first), a melee one (able to hit air)
// keeps hitting the enemy it blocks.
const run = (id, o) => {
const h = makeBattle({
defs: {
chess: { [id]: chessRec({ id, rangeGrid: [[0, -1], [0, 0], [0, 1]], stats: { atk: 1, bat: 0.2, blockCnt: 1, maxHp: 1e6 }, skill: null, ...o }) },
enemies: { enemy_x: foe('enemy_x'), enemy_fl: foe('enemy_fl', { motion: 'FLY' }) },
},
units: [{ chessId: id, row: 9, col: 6 }], enemies: [{ key: 'enemy_x', time: 0 }, { key: 'enemy_fl', time: 5, route: 2 }], timeLimit: 60,
content: 'none', hooks: ['damaged'], captureNoisy: true,
});
const u = h.unit(id);
const walker = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
const flyer = () => h.b.enemies.find((e) => e.defId === 'enemy_fl');
assert.ok(h.runUntil(() => walker()?.blockedBy === u, 30), 'the walker is blocked');
assert.ok(h.runUntil(() => flyer() && flyer().x < 4.4, 30), 'the flyer passed');
assert.equal(walker().blockedBy, u);
return h.hooksOf('damaged').filter((c) => c.source === u && c.target === flyer()).length;
};
assert.ok(run('t_rg2', { profession: 'SNIPER' }) > 0, 'the ranged blocker shot the flyer while it was nearer the goal');
assert.equal(run('t_ml2', { profession: 'WARRIOR', attackKind: 'melee', canHitFly: true }), 0, 'the melee blocker kept to its blocked enemy');
});
test('#4: a skywalker (起飞: blockFly) blocks flyers at the air block radius 0.8944', () => {
const h = makeBattle({
defs: { chess: { t_sky: chessRec({ id: 't_sky', subProfessionId: 'skywalker', stats: { atk: 0, blockCnt: 2, maxHp: 1e6 }, skill: null }) },
enemies: { enemy_fl: foe('enemy_fl', { motion: 'FLY' }) } },
units: [{ chessId: 't_sky', row: 9, col: 6 }], enemies: [{ key: 'enemy_fl', route: 2 }], timeLimit: 60,
});
const sky = h.unit('t_sky');
const f = () => h.b.enemies[0];
assert.ok(h.runUntil(() => f()?.blockedBy === sky, 30), 'the flyer is blocked');
const d = Math.hypot(f().x - 6, f().y - 9);
assert.ok(d <= BLOCK_RADIUS.fly + 1e-9 && d > BLOCK_RADIUS.ground, `at the air radius (d=${d.toFixed(3)})`);
checkInvariants(h.b);
});
test('#4: 瑕光 S2 puts the enemy she blocks to sleep although it stands outside her tile (PRTS: 自身这格内…及自身阻挡的敌人)', () => {
const ds = getDefaultSource();
const id = 'chess_char_3_12_a';
const skillIndex = ds.rawChess(id).skills.find((s) => s.skillId === 'skchr_blemsh_2').index;
const h = makeBattle({
defs: { enemies: { enemy_x: foe('enemy_x') } }, timeLimit: 60,
units: [{ chessId: id, row: 9, col: 6, skillIndex }], enemies: [{ key: 'enemy_x', time: 0 }],
});
const u = h.unit(id);
const x = () => h.b.enemies[0];
assert.ok(h.runUntil(() => x()?.blockedBy === u, 30));
assert.notEqual(Math.round(x().x), 6, 'the blocked enemy is on the tile in front of her');
u.skill.gainSp(u.skill.spCost * u.skill.maxCharges + 1, 'test');
assert.ok(h.runUntil(() => u.skill.active, 5));
h.step();
assert.ok(x().s.flags.sleep, 'asleep');
});