- #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
231 lines
14 KiB
JavaScript
231 lines
14 KiB
JavaScript
// 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` \|/);
|
||
});
|