diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 8774fff9..4df17754 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -132,3 +132,4 @@ - 进入隐秘核心看的是全队的**激活层数总和**:后期可以针对性地堆层数。 - 层数到 999 就满了,多余的叠层没有用,可以把资源转去别的盟约。 - 领袖战注意**限伤**:单次 30 万以上的伤害会被整次作废。盟约叠到 999 层后,再叠加额外的伤害倍率(例如 999 层谢拉格再点亮奥术)反而可能让大招打出 0 伤害;队友的奥术等减益也会放大你的伤害。 +- **橙色围栏围起的地块**(官方「围墙」)可以部署干员,敌人走不进去:站在里面的干员照常攻击自己攻击范围内的敌人(摆放时选好朝向,让攻击范围对着敌人走的路),但**挡不住地面敌人**(被推、拉到围栏边的敌人也不会被它阻挡),也很少挨打——重装干员(如深巡)的手动技能要受到伤害才释放,在里面可能一直不放;只打自己脚下一格的干员(如角峰)在里面打不到任何敌人。 diff --git a/docs/SIM.md b/docs/SIM.md index 3894bb29..a63aad21 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -176,12 +176,17 @@ to a new pick. A `wait` leg keeps counting; when the fear ends the route re-plan user playtest #5 item 4): an unblocked, blockable enemy is blocked by an ally (or device) whose centre is within its block radius of the enemy's position — `constants.js BLOCK_RADIUS`: ground 0.7071 (compared as d² < 0.49999037, the tile's circumscribed circle), air 0.8944 (blockFly units against flyers), devices 0.4472 — while that blocker has free -capacity for the enemy's `blockWeight` (data `blockCnt`). It is checked every tick for every unblocked enemy, moving or -not: an enemy that overlaps an operator when its blocker dies / is withdrawn / is stunned, or when the operator's -blocked enemy dies, is taken over at once; an enemy that finds no room walks on (pass-through). Several blockers in -contact → the nearest [ASSUMED]. A head-on enemy therefore stops at contact, ~0.71 tile from the blocker's centre, on the -tile in front of it (PRTS 作战机制: a blocked enemy's collider does not enter the blocker's tile; the official few -hundredths of a tile of deceleration are not modelled), and **every blocker** — melee units (要塞 / 领主 / 哨戒铁卫 +capacity for the enemy's `blockWeight` (data `blockCnt`). A unit standing on a tile ground units cannot pass — the +fenced 围墙 / 围栏 tiles (`b`: low, deployable, flyers only) — blocks no ground enemy (PRTS 围墙 / 围栏 地形机制 "部署在 +其中的单位,若当前阻挡类型为'地面阻挡'则无法阻挡敌人"; `Battle._blockerFor`): nothing walks onto those tiles, but a push or +pull leaves an enemy at the fence edge, 0.5 from the unit — it walks on (community report F4 after 0.1.0: 薄绿 S2 on a +fenced tile held the dragged enemies there; test/sim/feedback1f-fence.test.js); air blocking (blockFly against flyers) +stays; a unit on a fenced tile still attacks whatever stands on its range tiles. It is checked every tick for every +unblocked enemy, moving or not: an enemy that overlaps an operator when its blocker dies / is withdrawn / is stunned, +or when the operator's blocked enemy dies, is taken over at once; an enemy that finds no room walks on (pass-through). +Several blockers in contact → the nearest [ASSUMED]. A head-on enemy therefore stops at contact, ~0.71 tile from the +blocker's centre, on the tile in front of it (PRTS 作战机制: a blocked enemy's collider does not enter the blocker's +tile; the official few hundredths of a tile of deceleration are not modelled), and **every blocker** — melee units (要塞 / 领主 / 哨戒铁卫 included), summons (流形's melee copy) and a ranged operator standing on a melee tile alike — may always target the enemies it blocks, in range or not, whatever its facing, and targets them first ("可以选择且优先选择阻挡单位", PRTS 选择器; the user's rule after playtest #6, "阻挡了就一定要能打到": officially the collision pushes a blocked enemy to its blocker's diff --git a/server/sim/Battle.js b/server/sim/Battle.js index 68187bb4..7eebc335 100644 --- a/server/sim/Battle.js +++ b/server/sim/Battle.js @@ -1066,7 +1066,8 @@ export class Battle { * Try to block enemy `e` where it stands. Returns true if it is (now) blocked. Official contact rule (constants.js * BLOCK_RADIUS, PRTS 游戏数据基础 §阻挡半径): the enemy's position within the blocker's block radius of the blocker's * centre (ground 0.7071 — the tile's circumscribed circle, reaching 0.21 tile into the side neighbours; air 0.8944; - * devices 0.4472), and the blocker has free capacity for the enemy's block weight. Checked every tick for every + * devices 0.4472), and the blocker has free capacity for the enemy's block weight (and may block it at all: a unit on + * a fenced tile blocks no ground enemy, _blockerFor). Checked every tick for every * unblocked enemy, moving or not, so an enemy overlapping an operator is taken over as soon as its blocker is gone or * the operator's capacity frees up (user playtest #5 item 4). Several blockers in contact → the nearest [ASSUMED], * ties → the first in row-then-column scan order. @@ -1113,11 +1114,18 @@ export class Battle { return out; } - /** Can ally/device `u` block enemy `e` (block weight `w`) right now — everything but the contact distance. */ + /** + * Can ally/device `u` block enemy `e` (block weight `w`) right now — everything but the contact distance. A unit on a + * tile ground units cannot pass blocks no ground enemy: the fenced tiles (围墙 tile_fence_bound / 围栏 tile_fence — + * low ground, deployable, passable to flyers only) — PRTS 围墙 / 围栏 地形机制 "部署在其中的单位,若当前阻挡类型为'地面 + * 阻挡'则无法阻挡敌人". Air blocking (blockFly against flyers) is not ground blocking and stays. Nothing walks onto such + * a tile, so it matters for an enemy pushed or pulled against the fence (Battle.displace stops it at the tile edge, + * 0.5 from the fenced unit — inside the ground block radius). + */ _blockerFor(u, e, w) { if (!u.alive || !u.deployed || u.hidden || u.s.flags.noBlock || u.s.flags.sleep) return false; if (e.isFlying && !(u.s.flags.blockFly || (u.profile && u.profile.blockFly))) return false; - if (!e.isFlying && !u.ground) return false; + if (!e.isFlying && (!u.ground || this.grid.tile(u.tileR, u.tileC).pass !== 'ALL')) return false; const cap = u.s.blockCnt; if (cap <= 0) return false; let used = 0; diff --git a/test/sim/feedback1f-fence.test.js b/test/sim/feedback1f-fence.test.js new file mode 100644 index 00000000..87585fc0 --- /dev/null +++ b/test/sim/feedback1f-fence.test.js @@ -0,0 +1,163 @@ +// 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. Air blocking (blockFly against flyers) is not ground blocking and stays. + +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', () => { + 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'); +});