Files
ark-stronghold-protocol/test/sim/playtest5_bossbody.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

231 lines
14 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 10 — "大体型的关底boss实际受击判定范围很小,是不是就一格,跟官方原版不一样,原版是很大的一片区域".
//
// Official (PRTS enemy pages, 天赋 "巨型单位:受击判定区域为长4.95、宽2.95的长方形,向上偏移1.0"; 假想敌:管 隐秘核心 also
// "向右偏移1.0"; 卫戍协议:盟约 下半/PRTS盟约记录: the season's 阿利斯泰尔 / “萨米的意志” have the same 4.95 × 2.95, up 1) and
// PRTS 作战机制 "巨型BOSS单位的每一个占据的格子都可以让其本身通过格子判定": a huge boss is hit anywhere on its rectangle —
// every tile it occupies counts for grid ranges, radius effects measure to the rectangle. Regular enemies are
// unchanged.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { bodyKeys, bodyDist, bodyInKeys, bodyOnTile, bodyTileReach, hitRect, normHitArea } from '../../server/sim/body.js';
import { COLS } from '../../server/sim/constants.js';
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
const tiles = (keys) => keys.map((k) => [Math.floor(k / COLS), k % COLS]).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const block = (r0, r1, c0, c1) => { const o = []; for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) o.push([r, c]); return o; };
test('data: the huge bosses carry the PRTS hit areas, everything else is a point', () => {
const want = {
enemy_9013_acstmk: { w: 4.95, h: 2.95, dx: 0, dy: 1 }, enemy_9013_acstmk_2: { w: 4.95, h: 2.95, dx: 0, dy: 1 },
enemy_9021_acduml: { w: 4.95, h: 2.95, dx: 0, dy: 1 }, enemy_9021_acduml_2: { w: 4.95, h: 2.95, dx: 1, dy: 1 },
enemy_1521_dslily: { w: 4.95, h: 2.95, dx: 0, dy: 1 }, enemy_9032_aclionk: { w: 4.95, h: 2.95, dx: 0, dy: 1 },
enemy_9033_acdeer: { w: 4.95, h: 2.95, dx: 0, dy: 1 },
};
for (const [k, v] of Object.entries(want)) assert.deepEqual(E[k].hitArea, v, k);
const others = Object.keys(E).filter((k) => E[k].hitArea && !want[k]);
assert.deepEqual(others, [], 'no other enemy is huge (假想敌:铳 / 卢西恩 / the parts are regular)');
for (const k of ['enemy_9017_achunt', 'enemy_9017_achunt_2', 'enemy_2016_csphtm', 'enemy_9014_acstma', 'enemy_9022_acdumm']) assert.equal(E[k].hitArea, undefined, k);
assert.equal(normHitArea({ w: 0, h: 1 }), null);
assert.equal(normHitArea(null), null);
});
test('geometry: 4.95 × 2.95 up 1 at the boss tile (3,10) occupies rows 3–5 × cols 8–12; 管 (隐秘核心) at (3,9) the same block', () => {
const boss = { x: 10, y: 3, hitArea: normHitArea(E.enemy_9013_acstmk.hitArea) };
assert.deepEqual(tiles(bodyKeys(boss)), block(3, 5, 8, 12));
const pipe2 = { x: 9, y: 3, hitArea: normHitArea(E.enemy_9021_acduml_2.hitArea) };
assert.deepEqual(tiles(bodyKeys(pipe2)), block(3, 5, 8, 12));
assert.ok(bodyOnTile(boss, 5, 12) && bodyOnTile(boss, 3, 8) && !bodyOnTile(boss, 2, 10) && !bodyOnTile(boss, 4, 13));
const R = hitRect(boss);
assert.deepEqual([R.x0, R.x1, R.y0, R.y1].map((v) => +v.toFixed(3)), [7.525, 12.475, 2.525, 5.475]);
assert.equal(bodyDist(boss, 10, 4), 0, 'inside');
assert.ok(Math.abs(bodyDist(boss, 13.475, 4) - 1) < 1e-9, '1 tile right of the edge');
assert.equal(bodyTileReach(boss, 4, 13), 1);
assert.equal(bodyTileReach(boss, 4, 12), 0);
// a regular enemy is its position's tile / a point
const e = { x: 6.4, y: 9.6 };
assert.deepEqual(tiles(bodyKeys(e)), [[10, 6]]);
assert.ok(bodyInKeys(e, new Set([10 * COLS + 6])) && !bodyInKeys(e, [10 * COLS + 7]));
assert.ok(Math.abs(bodyDist(e, 6.4, 10.6) - 1) < 1e-9);
});
// ---------------------------------------------------------------------------------------------------------------
// battles
const HUGE = { ...enemyRec({ key: 'enemy_huge', hp: 1e8, speed: 0, rank: 'BOSS' }), hitArea: { w: 4.95, h: 2.95, dx: 0, dy: 1 } };
const SMALL = enemyRec({ key: 'enemy_small', hp: 1e8, speed: 0 });
const blade = (id, rangeGrid = [[0, 0], [0, 1]]) => chessRec({ id, stats: { atk: 100, blockCnt: 0, maxHp: 1e7 }, rangeGrid, skill: null });
function bossField(units, key, pos = [3, 10], extra = {}) {
const h = makeBattle({
kind: 'boss', content: 'none', autoFinish: false, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
defs: { chess: { t_edge: blade('t_edge'), t_far: blade('t_far'), t_aoe: extra.aoe ?? blade('t_aoe') }, enemies: { enemy_huge: HUGE, enemy_small: SMALL } },
players: [{ playerId: 'p1', seat: 0, side: 'L', coords: 'field', units: units.map((u, i) => ({ uid: i + 1, kind: 'chess', ...u })), bonds: {} }],
});
h.step();
const e = h.spawn(key, { pos, routeIndex: 0 });
return { h, e };
}
const hitsOn = (h, e, u) => h.hooksOf('damaged').filter((c) => c.target === e && c.source === u).length;
test('#10: an operator whose range touches only the edge of the boss area hits it (and not a regular enemy there)', () => {
// (4,7) facing RIGHT covers (4,7),(4,8) — (4,8) is the left column of the boss body; the boss's own tile (3,10) is
// far
for (const [key, expectHit] of [['enemy_huge', true], ['enemy_small', false]]) {
const { h, e } = bossField([{ chessId: 't_edge', row: 4, col: 7, dir: 'RIGHT' }], key);
const u = h.unit('t_edge');
h.run(5);
assert.equal(hitsOn(h, e, u) > 0, expectHit, `${key}: ${hitsOn(h, e, u)} hits`);
checkInvariants(h.b);
}
});
test('#10: every tile of the 5 × 3 body counts; one tile outside it does not', () => {
// a 1-tile range operator (its own tile only) on the boss's corner tiles (5,12) and (3,8) hits; on (2,9) (below it) /
// (4,13) (right of it) it does not
const one = [[0, 0]];
for (const [r, c, want] of [[5, 12, true], [3, 8, true], [5, 8, true], [4, 13, false], [2, 9, false]]) {
const h = makeBattle({
kind: 'boss', content: 'none', autoFinish: false, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
defs: { chess: { t_one: blade('t_one', one) }, enemies: { enemy_huge: HUGE } },
players: [{ playerId: 'p1', seat: 0, side: 'L', coords: 'field', units: [{ uid: 1, kind: 'chess', chessId: 't_one', row: r, col: c }], bonds: {} }],
});
h.step();
const e = h.spawn('enemy_huge', { pos: [3, 10], routeIndex: 0 });
h.run(4);
assert.equal(hitsOn(h, e, h.unit('t_one')) > 0, want, `(${r},${c})`);
}
});
test('#10: radius effects reach the body — an AoE 1 tile beyond the rectangle edge hits it, a regular enemy there is not hit', () => {
for (const [key, want] of [['enemy_huge', 1], ['enemy_small', 0]]) {
const { h, e } = bossField([], key);
const got = h.b.enemiesInRadius(13.475, 4, 1.0).filter((x) => x === e).length;
assert.equal(got, want, key);
assert.equal(h.b.enemiesInRadius(13.6, 4, 1.0).filter((x) => x === e).length, 0, `${key}: beyond reach`);
// splash around a target is a 中点判定 (PRTS 作战机制): by position, the body does not count
assert.equal(h.b.enemiesInRadius(13.475, 4, 1.0, true).filter((x) => x === e).length, 0, `${key}: centre-point radius`);
assert.equal(h.b.enemiesInRadius(11, 3, 1.0, true).filter((x) => x === e).length, 1, `${key}: its position within r`);
}
});
test('#10 (follow-up): an operator\'s splash on an escort next to the huge boss does not reach the boss (中点判定)', () => {
// escort at (2,9): 0.525 below the boss rectangle (rows 2.525–5.475), 1.41 from the boss's position (3,10). A splash
// caster targeting only the escort (range: its tile) splashes 1.1 around it — by PRTS 作战机制 "中点判定…酒神1天赋的1.3
// 溅射半径" the boss (centre 1.41 away) is not hit; a regular enemy 1.0 away from the escort is
const aoe = chessRec({ id: 't_aoe', profession: 'CASTER', subProfessionId: 'splashcaster', dmgType: 'arts', stats: { atk: 100, blockCnt: 0, maxHp: 1e7 }, rangeGrid: [[0, 3]], skill: null });
const { h, e } = bossField([{ chessId: 't_aoe', row: 2, col: 6, dir: 'RIGHT' }], 'enemy_huge', [3, 10], { aoe });
const esc = h.spawn('enemy_small', { pos: [2, 9], routeIndex: 0 });
const near = h.spawn('enemy_small', { pos: [2, 10], routeIndex: 0 });
const u = h.unit('t_aoe');
assert.ok(h.runUntil(() => hitsOn(h, esc, u) > 0, 10), 'the caster hits the escort');
h.run(3);
assert.ok(hitsOn(h, near, u) > 0, 'a regular enemy 1.0 from the escort takes the splash');
assert.equal(hitsOn(h, e, u), 0, 'the huge boss (position 1.41 away, body 0.53 away) takes none');
checkInvariants(h.b);
});
test('#10: the enemy index lists a huge enemy once per query and unitsInGrid sees its body', () => {
const { h, e } = bossField([{ chessId: 't_far', row: 4, col: 3, dir: 'RIGHT' }], 'enemy_huge');
h.step();
const all = [];
for (let r = 0; r <= 5; r++) for (let c = 0; c <= 20; c++) all.push(r * COLS + c);
const u = h.unit('t_far');
assert.deepEqual(h.b.enemiesInKeys(all, u, { canHitFly: true }).map((x) => x.id), [e.id]);
assert.deepEqual(h.b.unitsInGrid(u, [[0, 5]]).map((x) => x.id), [e.id], '(4,8) is on the body');
assert.deepEqual(h.b.unitsInGrid(u, [[0, 4]]).map((x) => x.id), [], '(4,7) is not');
});
test('#10: the real 假想敌:胄 on the real boss stage — an operator on the 1×3 fence left of the pipe block hits it', () => {
// act2autochess_m01 boss field: floor cols 9–11 rows 3–5 framed by fence tiles (b, deployable) at cols 8 and 12;
// the boss stands at (3,10). A melee operator on the fence (4,8) facing RIGHT covers (4,8),(4,9) — both on the boss
// body.
const h = makeBattle({
kind: 'boss', stageId: 'act2autochess_m01', content: 'none', autoFinish: false, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
defs: { chess: { t_edge: blade('t_edge') } },
players: [{ playerId: 'p1', seat: 0, side: 'L', coords: 'field', units: [{ uid: 1, kind: 'chess', chessId: 't_edge', row: 4, col: 8, dir: 'RIGHT' }], bonds: {} }],
});
h.step();
const boss = h.spawn('enemy_9013_acstmk', { pos: [3, 10], routeIndex: 0, tag: 'boss', mods: { speedMul: 0 } });
assert.ok(boss.hitArea, 'the boss unit carries its hit area');
const u = h.unit('t_edge');
assert.equal(u.tileR, 4);
h.run(6);
assert.ok(hitsOn(h, boss, u) > 0, 'hits the boss');
checkInvariants(h.b);
});
// ---------------------------------------------------------------------------------------------------------------
// 自缚 + 无法被阻挡 — every 巨型单位 page lists "{{异常效果|自缚}} … 不可阻挡 / 无法被阻挡" (PRTS 假想敌:胄 / 假想敌:管 /
// 盐风主教昆图斯 / 阿利斯泰尔,帝国余晖 / “萨米的意志”; 异常效果 自缚 = 无法移动, not recognised as 束缚). The hit area moves
// with the boss, so a boss that walked would drag its 5 × 3 body off the pipe block (reviewer: the co-op 昆图斯 walked its
// route (3,10) → (2,2) and leaked at ≈ 280 game s).
const HUGE_TEMPLATES = [
['act1autochess_h07_01_s', 'enemy_9013_acstmk'], ['act1autochess_h07_03_s', 'enemy_9021_acduml'],
['act1autochess_h07_04_s', 'enemy_1521_dslily'], ['act1autochess_h07_06_s', 'enemy_9032_aclionk'],
['act1autochess_h07_07_s', 'enemy_9033_acdeer'], ['act1autochess_h08_01_s', 'enemy_9013_acstmk_2'],
['act1autochess_h08_03_s', 'enemy_9021_acduml_2'],
];
const realBoss = (waveTemplate, units = [], timeLimit = 60, two = false) => makeBattle({
kind: 'boss', stageId: 'act2autochess_m01', waveTemplate, content: 'full', autoFinish: false, timeLimit, hooks: ['enemyLeak'],
players: [
{ playerId: 'p1', seat: 0, side: 'L', coords: 'field', units, bonds: {} },
...(two ? [{ playerId: 'p2', seat: 1, side: 'R', coords: 'field', units: [], bonds: {} }] : []),
],
});
test('#10: every huge boss is 自缚 and unblockable (PRTS 天赋) — in its real template, from its spawn', async () => {
const { SELF_BOUND } = await import('../../server/sim/content/bosses.js');
assert.deepEqual([...SELF_BOUND].sort(), Object.keys(E).filter((k) => E[k].hitArea).sort(), 'the 巨型单位 are exactly the 自缚 leaders');
for (const [tpl, key] of HUGE_TEMPLATES) {
const h = realBoss(tpl);
h.run(2); // 管 spawns at 1 s
const e = h.b.enemies.find((x) => x.defId === key);
assert.ok(e, `${tpl}: ${key} spawned`);
assert.ok(e.s.flags.noMove && e.s.flags.unblockable, `${key}: 自缚 + 无法被阻挡`);
assert.ok(!e.s.flags.bind, `${key}: 自缚 is not recognised as 束缚`);
}
});
test('#10: the co-op 盐风主教昆图斯 stays on its pipe block (its route would walk it to (2,2)) and never leaks', () => {
const h = realBoss('act1autochess_h07_04', [], 900, true);
h.run(1);
const bosses = h.b.enemies.filter((e) => e.defId === 'enemy_1521_dslily');
const at = bosses.map((e) => [e.x, e.y]);
h.run(400);
const q = h.b.enemies.filter((e) => e.defId === 'enemy_1521_dslily');
assert.ok(q.length >= 1 && q.every((e) => e.alive), 'still on the field');
for (const [i, e] of bosses.entries()) assert.deepEqual([e.x, e.y], at[i], `copy ${i} did not move`);
assert.equal(h.hooksOf('enemyLeak').filter((c) => c.enemy && c.enemy.defId === 'enemy_1521_dslily').length, 0, 'no leak');
checkInvariants(h.b);
});
test('#10: an operator standing on the boss (a free-capacity blocker in contact) does not block it', () => {
const op = chessRec({ id: 't_wall', stats: { atk: 10, blockCnt: 3, maxHp: 1e9, def: 5000 }, rangeGrid: [[0, 0]], skill: null });
const h = makeBattle({
kind: 'boss', stageId: 'act2autochess_m01', content: 'full', autoFinish: false, timeLimit: 60, defs: { chess: { t_wall: op } },
players: [{ playerId: 'p1', seat: 0, side: 'L', coords: 'field', units: [{ uid: 1, kind: 'chess', chessId: 't_wall', row: 4, col: 8, dir: 'RIGHT' }], bonds: {} }],
});
h.step();
const u = h.unit('t_wall');
const boss = h.spawn('enemy_1521_dslily', { pos: [u.tileR, u.tileC], routeIndex: 0, tag: 'boss' });
h.run(3);
assert.equal(boss.blockedBy, null);
assert.equal(u.blocking.length, 0);
assert.deepEqual([boss.y, boss.x], [u.tileR, u.tileC], 'and it does not walk away either');
});
test('docs: SIM.md / DATA.md describe the hit areas and the block radius (user playtest #5)', () => {
const SIM = fs.readFileSync(new URL('../../docs/SIM.md', import.meta.url), 'utf8');
const DATA_MD = fs.readFileSync(new URL('../../docs/DATA.md', import.meta.url), 'utf8');
assert.match(SIM, /body\.js/);
assert.match(SIM, /受击判定区域为长4\.95、宽2\.95的长方形,向上偏移1\.0/);
assert.match(SIM, /BLOCK_RADIUS`: ground 0\.7071/);
assert.match(SIM, /Battle\.blockedTargets/);
assert.match(SIM, /自缚 \+ 无法被阻挡[\s\S]{0,80}SELF_BOUND/);
assert.match(DATA_MD, /\| `hitArea` \|/);
});