- #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
272 lines
15 KiB
JavaScript
272 lines
15 KiB
JavaScript
// 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');
|
||
});
|