SIM.md Blocking: the 薄绿 S2 drag case was found while checking community report F4 (深巡 on a fenced tile), not reported by players; the passage now says so, keeps the surrounding base lines untouched and wraps to the file's width. The air-blocking reading (blockFly units on a fenced tile still block flyers) is marked [ASSUMED: PRTS restricts the rule to 地面阻挡] in SIM.md, Battle._blockerFor and the test file; the _blockerFor doc also states that the gate reads ground passability (on the stages the fenced tiles are the only low tiles ground units cannot pass). No behaviour change.
166 lines
10 KiB
JavaScript
166 lines
10 KiB
JavaScript
// Community report F4 after 0.1.0 — "干员深巡放在围栏围着的地块里不会主动攻击" (深巡, 哨戒铁卫, on a fenced tile).
|
||
//
|
||
// The fenced tiles are the official 围墙 (tile_fence_bound; gamedata stage_table tileInfo "可放置近战单位,敌方无法进入"):
|
||
// low ground, deployable, passable to flyers only, drawn with an orange railing around each fenced region (the normal
|
||
// fields of act1 m01–m04 / act2 m02–m04, the Final Assault halves, act2 m01's 1×3 pipe tiles).
|
||
//
|
||
// Not reproduced: 深巡's trait "能够阻挡三个敌人,可以进行远程攻击" (character_table char_4137_udflow, range 2-2 = her tile and
|
||
// the two in front) — she attacks every enemy on those tiles from a fenced tile like from any other: the sim targets by
|
||
// range tiles (no line-of-sight, path or blocking requirement for an attack). Locked below on every deployable fenced
|
||
// tile of every active stage, every direction that looks at walkable ground, normal and elite.
|
||
//
|
||
// Nearby rule fixed: PRTS 围墙 / 围栏 地形机制 "部署在其中的单位,若当前阻挡类型为'地面阻挡'则无法阻挡敌人" — a unit on a fenced
|
||
// tile blocks no ground enemy (Battle._blockerFor). Nothing walks onto such a tile, but a push / pull stops an enemy at
|
||
// the tile edge, 0.5 from the fenced unit — inside the ground block radius 0.7071 — and 0.1.0 let the fenced unit block
|
||
// it there (found while checking F4, with 薄绿 S2). Air blocking (blockFly against flyers) stays [ASSUMED: PRTS restricts
|
||
// the rule to 地面阻挡].
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import fs from 'node:fs';
|
||
import { makeBattle } from '../helpers/battleHarness.js';
|
||
import { canTargetEnemy } from '../../server/sim/targeting.js';
|
||
import { bodyInKeys } from '../../server/sim/body.js';
|
||
import { effectiveProfile } from '../../server/sim/ai.js';
|
||
import { rotateOffset } from '../../server/sim/dir.js';
|
||
|
||
const STAGES = JSON.parse(fs.readFileSync(new URL('../../data/stages.json', import.meta.url), 'utf8'));
|
||
const ACTIVE = Object.keys(STAGES).filter((id) => STAGES[id].active && STAGES[id].weight > 0);
|
||
const UDFLOW = 'chess_char_1_04_a', UDFLOW_E = 'chess_char_1_04_b';
|
||
const DIRS = ['RIGHT', 'UP', 'LEFT', 'DOWN'];
|
||
const glyph = (st, r, c) => st.rows[r]?.[c];
|
||
const walkable = (st, r, c) => r >= 9 && r <= 12 && c >= 0 && c <= 10 && !!st.tiles[glyph(st, r, c)]?.groundPassable;
|
||
|
||
/** Deployable fenced tiles of a stage's normal field (board = field coordinates there). */
|
||
const fencedTiles = (st) => st.deployTiles.normal.melee.filter(([r, c]) => glyph(st, r, c) === 'b');
|
||
|
||
/** Directions whose 2-2 line (her tile + two ahead) reaches walkable ground from (r, c). */
|
||
const laneDirs = (st, r, c) => DIRS.filter((d) => [[0, 1], [0, 2]].some(([dr, dc]) => { const [a, b] = rotateOffset(dr, dc, d); return walkable(st, r + a, c + b); }));
|
||
|
||
/**
|
||
* One battle: the operator alone on (r, c) facing `dir` against a real round template. Returns its attacks, the ticks a
|
||
* targetable enemy stood on its range tiles, the longest wait for an attack while one did and it could act (seconds
|
||
* beyond its attack interval) and the ticks it blocked anything.
|
||
*/
|
||
function watch(stageId, chessId, r, c, dir, { template = 'act1autochess_02', seconds = 45 } = {}) {
|
||
const h = makeBattle({ stageId, waveTemplate: template, units: [{ chessId, row: r, col: c, dir }], seed: 5 });
|
||
h.step(1);
|
||
const u = h.unit(chessId);
|
||
assert.ok(u && u.deployed && u.tileR === r && u.tileC === c, `${stageId} (${r},${c}) deployed`);
|
||
let attacks = 0, inRange = 0, blocked = 0, waitFrom = null, worst = 0;
|
||
h.b.on('attack', (ctx) => { if (ctx.attacker === u) { attacks++; waitFrom = null; } });
|
||
for (let i = 0; i < seconds * 30 && !h.b.finished; i++) {
|
||
h.step(1);
|
||
if (u.blocking.length) blocked++;
|
||
if (!u.alive || !u.deployed || !u.canAct || u.s.flags.disarm) { waitFrom = null; continue; }
|
||
const prof = effectiveProfile(u);
|
||
const set = u.rangeKeySet || new Set(u.rangeKeys);
|
||
const target = h.b.enemies.some((e) => e.alive && e.deployed && canTargetEnemy(u, e, prof) && bodyInKeys(e, set));
|
||
if (!target) { waitFrom = null; continue; }
|
||
inRange++;
|
||
if (waitFrom == null) waitFrom = h.b.time;
|
||
worst = Math.max(worst, h.b.time - waitFrom - u.s.interval);
|
||
}
|
||
return { attacks, inRange, blocked, worst };
|
||
}
|
||
|
||
test('F4 (not reproduced): 深巡 on every deployable fenced tile of every active stage attacks the enemies on her range tiles', () => {
|
||
let cases = 0, withTargets = 0;
|
||
for (const id of ACTIVE) {
|
||
const st = STAGES[id];
|
||
for (const [r, c] of fencedTiles(st)) {
|
||
for (const dir of laneDirs(st, r, c)) {
|
||
for (const chessId of [UDFLOW, UDFLOW_E]) {
|
||
const w = watch(id, chessId, r, c, dir);
|
||
const where = `${chessId} ${id} (${r},${c}) ${dir}: ${JSON.stringify(w)}`;
|
||
cases++;
|
||
// never idle with a target: an attack at the latest one interval (+ 0.2 s) after an enemy is on her tiles
|
||
assert.ok(w.worst <= 0.2, `idle with a target in range — ${where}`);
|
||
if (w.inRange > 0) { withTargets++; assert.ok(w.attacks > 0, `no attack — ${where}`); }
|
||
// nothing walks onto a fenced tile or within her reach there
|
||
assert.equal(w.blocked, 0, `blocked from a fenced tile — ${where}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
assert.ok(cases >= 150, `${cases} placements`);
|
||
assert.ok(withTargets >= cases * 0.8, `${withTargets} of ${cases} placements saw enemies on her range tiles`);
|
||
});
|
||
|
||
test('F4: the Final Assault fenced tiles (act2 m01 boss (3–5, 8), act1 m01 boss (4,7)) — 深巡 attacks the leaders and their escorts', () => {
|
||
const W = JSON.parse(fs.readFileSync(new URL('../../data/waves.json', import.meta.url), 'utf8'));
|
||
const run = (stageId, tpl, r, c, dir) => {
|
||
const h = makeBattle({ kind: W[tpl].kind, stageId, waveTemplate: tpl, timeLimit: 20, autoFinish: false,
|
||
sharedBoss: { hp: 1e9, maxHp: 1e9, damage() {} },
|
||
players: [{ playerId: 'p1', seat: 0, side: 'L', coords: 'field', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: UDFLOW, row: r, col: c, dir }], bonds: {} }] });
|
||
h.step(1);
|
||
const u = h.unit(UDFLOW);
|
||
let n = 0;
|
||
h.b.on('attack', (ctx) => { if (ctx.attacker === u) n++; });
|
||
h.run(20);
|
||
return n;
|
||
};
|
||
for (const r of [3, 4, 5]) assert.ok(run('act2autochess_m01', 'act1autochess_h07_01', r, 8, 'RIGHT') > 0, `act2 m01 (${r},8): the huge leader on her line`);
|
||
assert.equal(glyph(STAGES.act1autochess_m01, 4, 7), 'b');
|
||
assert.ok(run('act1autochess_m01', 'act1autochess_h07_03', 4, 7, 'RIGHT') > 0, 'act1 m01 boss (4,7)');
|
||
});
|
||
|
||
test('fix: a unit on a fenced tile (围墙) blocks no ground enemy pushed against the fence; on a road tile it does', () => {
|
||
const push = (stage) => {
|
||
const h = makeBattle({ stage, units: [{ chessId: UDFLOW, row: 11, col: 7, dir: 'RIGHT' }], autoFinish: false, timeLimit: 60, seed: 3 });
|
||
h.step(1);
|
||
const u = h.unit(UDFLOW);
|
||
const e = h.spawn('enemy_1007_slime', { pos: [11, 8], routeIndex: 0 });
|
||
h.step(2);
|
||
assert.equal(e.blockedBy, null, 'one tile away: not in contact');
|
||
const moved = h.b.displace(e, { x: -1, y: 0 }, 0.6);
|
||
assert.ok(Math.hypot(e.x - 7, e.y - 11) < 0.7071, `within the ground block radius (${e.x.toFixed(3)})`);
|
||
h.step(1);
|
||
return { h, u, e, moved };
|
||
};
|
||
// act1 m01: (11,7) is a 围墙 tile of the fenced block, (11,8) the col-8 road beside it
|
||
const fenced = STAGES.act1autochess_m01;
|
||
assert.equal(glyph(fenced, 11, 7), 'b');
|
||
assert.equal(fenced.tiles.b.tileKey, 'tile_fence_bound');
|
||
const a = push(fenced);
|
||
assert.ok(a.moved > 0.4 && a.moved < 0.6 && Math.round(a.e.x) === 8, `stopped at the fence edge (moved ${a.moved})`);
|
||
assert.equal(a.e.blockedBy, null, 'the enemy against the fence is not blocked by the unit on the fenced tile');
|
||
assert.equal(a.u.blocking.length, 0);
|
||
a.h.step(30);
|
||
assert.ok(a.e.alive && a.e.blockedBy === null && Math.hypot(a.e.x - 7.5, a.e.y - 11.05) > 0.2, 'it walks on');
|
||
// control: the same tile made a road — the pushed enemy is blocked as before
|
||
const road = { ...fenced, rows: fenced.rows.map((row, r) => (r === 11 ? `${row.slice(0, 7)}r${row.slice(8)}` : row)) };
|
||
const b = push(road);
|
||
assert.equal(b.e.blockedBy, b.u, 'on a road tile the same contact blocks');
|
||
});
|
||
|
||
test('fix, real kit: 薄绿 on a 围墙 tile drags enemies against the fence and blocks none of them (0.1.0: held them there)', () => {
|
||
// 薄绿 S2 聚能涡旋 drags enemies towards her (Battle.push inward); from act1 m01's fenced (11,7) facing the col-8 road the
|
||
// dragged enemies end at the fence edge, in contact with her — 0.1.0 blocked them there for most of the round
|
||
const BOLV = 'chess_char_3_08_a';
|
||
const h = makeBattle({ stageId: 'act1autochess_m01', waveTemplate: 'act1autochess_02', units: [{ chessId: BOLV, row: 11, col: 7, dir: 'RIGHT' }], seed: 9 });
|
||
h.step(1);
|
||
const u = h.unit(BOLV);
|
||
let casts = 0, contact = 0, blocked = 0;
|
||
h.b.on('skillStart', (ctx) => { if (ctx.unit === u) casts++; });
|
||
for (let i = 0; i < 45 * 30 && !h.b.finished; i++) {
|
||
h.step(1);
|
||
if (h.b.enemies.some((e) => e.alive && !e.isFlying && Math.hypot(e.x - 7, e.y - 11) < 0.7071)) contact++;
|
||
if (u.blocking.length) blocked++;
|
||
}
|
||
assert.ok(casts > 0 && contact > 30, `the drag brought ground enemies into contact (${casts} casts, ${contact} ticks)`);
|
||
assert.equal(blocked, 0, 'she blocks nothing from the fenced tile');
|
||
});
|
||
|
||
test('fix: air blocking stays — a blockFly unit on a fenced tile blocks a flyer over the fence', () => {
|
||
// [ASSUMED] PRTS's rule names only 地面阻挡 ("若当前阻挡类型为'地面阻挡'则无法阻挡敌人"), so air blocking is kept
|
||
const h = makeBattle({ stageId: 'act1autochess_m01', units: [{ chessId: UDFLOW, row: 11, col: 7, dir: 'RIGHT' }], autoFinish: false, timeLimit: 60, seed: 3 });
|
||
h.step(1);
|
||
const u = h.unit(UDFLOW);
|
||
h.b.addBuff(u, { key: 'test:blockFly', duration: Infinity, flags: { blockFly: true } });
|
||
const e = h.spawn('enemy_1005_yokai', { pos: [11, 7], routeIndex: 2 });
|
||
h.step(2);
|
||
assert.ok(e.isFlying);
|
||
assert.equal(e.blockedBy, u, 'the flyer over the fenced tile is blocked by the air blocker standing on it');
|
||
});
|