Files
ark-stronghold-protocol/server/sim/battle/blocking.js
T
sganggs ac6454d171 feedback5: merge feedback4 (7ee815e) — W4D ported: a sleeping enemy cannot be blocked and takes no block slot (_checkBlock in server/sim/battle/blocking.js, the applyStatus release in battle/status.js, the plain-buff release re-applied to ai.js updateEnemy's cannot-act branch after WE1's swing cut, 瑕光's comment in kits/ops/chess_char_3_12-blemsh.js, buffs.js; 酒神's 迷狂牢笼 skips a sleeper too); golden: roster-003 / 005 / 013 / 032 / 038 / 039 / 042, bond-maniShip-high, solo-FUNNY-1, coop4-FUNNY-6, coop4-ABYSS-7, coop2-NORMAL-8-serverrun, coop2-NORMAL-70-diy, diy-131 / 132 moved
New on feedback4 since the last merge: W4D (bbe59b9, merged as 7ee815e) and the 0.1.4 version commit (30e7ace).
feedback5 keeps its own package.json / package-lock.json / APP_VERSION / README / DEPLOY version lines (0.2.0 sets
its own version later) and takes feedback4's CHANGELOG 0.1.4 entry, with W4D's line in it.

- Port (PRTS 异常效果 SLEEPING 沉睡 = 无法行动+无敌+不可阻挡; GitHub #140; the owner's decision of 2026-10-06):
  `_checkBlock` refuses `flags.sleep` (battle/blocking.js) and `applyStatus` releases an enemy that falls asleep
  (battle/status.js). Battle.js stays feedback5's façade, and tier3.js stays deleted (the 瑕光 comment is in her kits/ops
  file). ai.js: WE1 (89f730d) cuts a swing before enemyAttack on a stun / freeze / sleep. The cannot-act branch is still
  there, and a plain-buff `flags.sleep` still makes the enemy `stun` (units.js), so the plain-buff release stays in that
  branch, after the cut, with its comment rewritten for the new order.
- New on feedback5: 酒神's 迷狂牢笼 (kits/ops/op-phatm2.js cageBlock, O22) duplicated the old three-flag 不可阻挡 check.
  It now skips a sleeper too. No golden scenario spawns a 牢笼 over a sleeper: the corpus is identical with that line
  reverted. 予愿安洁莉娜's S3 helper is unchanged, since canTargetEnemy already drops sleepers.
- Readers that follow without code: blockedTargets (blocked-first targeting, the DEFAULT trigger, hitAllBlocked), the 要塞
  branch of acquireTargets (u.blocking), “庞贝”'s clock (blockedBy), the snapshot's UF.BLOCKED and the client's block
  marker (render/units.js). 死芒 S2 折朽 (自选) lets go a linked enemy that her 悲叹的仆役 held.
- Docs: DESIGN §24.9, its §24 intro entry, the §5.5 Blocking sentence (on feedback5's 阻挡半径倍率 text) and §22.8's
  "(since §24.9)", all as on feedback4; SIM.md as on feedback4 (the plain-buff sentence merged with feedback5's
  noNewBlock / undying text).
- Golden: a probe (counting successful sleeps on a blocked enemy, plain-buff sleeps and 牢笼 spawns over sleepers) ran
  over all 283 scenarios. Every scenario that moved sleeps blocked enemies; the counts are in brackets:
  roster-003 (小满 S2 [5]: same fight, blocked 27 → 29, snapshot hashes); roster-005 (elite 小满 S2 [3]: the 140 s
  timeout with 83 kills and 1 leak, where it used to clear at 135.43 s with 84); roster-013 (缇缇 S3 [11]: same kills,
  blocked 22 → 33); roster-032 (缇缇 S1 [10]: 71 kills instead of 68, 8 leaks instead of 11, 4 knock-outs instead of 8,
  萨尔贡 layers 41 → 35); roster-038 (elite 缇缇 S2 [1]: ends at 197.40 s instead of 197.27 s); roster-039 (瑕光 S2
  normal + elite [31 + 12]: same kills and counted leaks, the 窃贼 gets away, blocked 53 → 87); roster-042 (缇缇 S2 [3]:
  ends at 168.77 s instead of 166.93 s); bond-maniShip-high (小满 [1]: blocked 29 → 30, snapshot hashes); solo-FUNNY-1
  (ai_0's 缇缇 [3]: only ai_0's damage dealt); coop4-FUNNY-6 (ai_0's 缇缇 [15 + 56]: from round 9 ai_0's 精准 / 萨尔贡
  layers and boss damage move, ai_1 loses no LP from round 11, team LP 124 → 130); coop4-ABYSS-7 (ai_2's 小满 [18]: ai_2
  one kill fewer, ai_0 one more LP lost in round 9); coop2-NORMAL-8-serverrun (ai_1's 缇缇 [83]: only ai_1's damage dealt;
  WE1 had already turned this match into a loss); coop2-NORMAL-70-diy (the 自选 seat h_0's 缇缇 [171], ai_0's 缇缇 [74]
  and 小满 [19 + 21]: h_0's layers move from round 4, then its AI 托管 buys differently (boards from round 7); ai_0 246
  kills instead of 233, same end); diy-131 (死芒 S2 at tiers 5 and 6 [1 + 1]: snapshot hashes only); diy-132 (elite
  死芒 S2 [2]: ends at 199.50 s instead of 198.13 s, same kills). One scenario sleeps a blocked enemy and did not move:
  coop2-HARD-4 [1]. ai_1's 小满 lets go the enemy it holds; another enemy takes the slot; the sleeper wakes with no room
  and is blocked further on by 2_16_a. The match digest has no snapshot hashes, and its kills, leaks, damage and funds
  are identical. fields and standins did not move. Most outcomes differ from feedback4's list because feedback5's
  baselines differ (WE1's damage frame, etc.).
- Tests: test/sim/feedback4-sleep-unblock.test.js (from feedback4) passes 5/5 on the split layout. Every case fails with
  the three sim hunks reverted, and case 3 (plain buff) fails with only the ai.js release reverted. Targeted sim tests
  pass 184/184 (blocking, block targets, enemy swing, feedback2-enemy-attack-stand, combat / statuses, robustness,
  stun SP, professions, healers, fidelity …). Content tests plus kits_layout and docs-consistency pass 658/658 (kits
  t1t2 / t3 / t5 and alt t2 / t3 / t5 for 小满 / 瑕光 / 缇缇, 酒神, 死芒, garrisons, bosses, items). Stealth / liftoff /
  enemies pass 36/36. npm run golden: all 283 match. GOLDEN_FULL=1 test/golden.test.js passes 9/9 (no stored engine
  error). lint 0 errors, check:imports and typecheck clean.
2026-10-06 17:49:10 +08:00

129 lines
8.2 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.
// server/sim/battle/blocking.js — Battle methods: blocking: the contact rule (block radius, capacity, fenced tiles,
// 起飞), the blocked targets of a blocker, block release and the 隐匿 switch-off after a block ends.
// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle).
import { ROWS, COLS, BLOCK_RADIUS_SQ, STEALTH_RESTORE } from '../constants.js';
import { canTargetEnemy, stealthOffKey } from '../targeting.js';
export class BattleBlocking {
/**
* 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 (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. The air radius grows with the blocker's 阻挡半径倍率 (PRTS 游戏数据基础
* "飞行阻挡半径 = 0.8944 × 阻挡半径倍率"; mod `blockRadiusScale` = 倍率 − 1: 凯尔希·思衡托's 遗尘守望 / S1, +0.23); ground
* blocking ignores it (PRTS 数值范围 "对地面阻挡的单位不生效"). Below 1.5 tiles the 3×3 scan still finds every blocker.
* Never blocked: an enemy holding 不可阻挡 (PRTS 异常效果 BLOCK_FREE 「无法阻挡/被阻挡,自动解除阻挡」) — the flag itself
* (恐惧 / 诱导 carry it), 浮空, and 沉睡 (SLEEPING = 无法行动+无敌+不可阻挡: a sleeper takes no block slot, DESIGN §24.9);
* once it wakes it is blocked again only by a blocker with room.
*/
_checkBlock(e) {
if (e.blockedBy || e.hidden || !e.alive) return !!e.blockedBy;
const f = e.s.flags;
if (f.unblockable || f.levitate || f.fear || f.sleep) return false;
const r0 = Math.round(e.y), c0 = Math.round(e.x);
const w = e.blockWeight ?? 1;
let u = null, bd = Infinity;
const fly = e.isFlying;
for (let r = r0 - 1; r <= r0 + 1; r++) {
if (r < 0 || r >= ROWS) continue;
for (let c = c0 - 1; c <= c0 + 1; c++) {
// a diagonal neighbour's centre is ≥ √0.5 away: out of reach of the ground (and device) radius
if (c < 0 || c >= COLS || (!fly && r !== r0 && c !== c0)) continue;
const o = this._occ[r * COLS + c];
if (!o || !this._blockerFor(o, e, w)) continue;
const d2 = (e.x - c) * (e.x - c) + (e.y - r) * (e.y - r);
const k = fly ? 1 + (o.s.blockRadiusScale || 0) : 1;
const r2 = o.kind === 'device' ? BLOCK_RADIUS_SQ.device : fly ? BLOCK_RADIUS_SQ.fly * k * k : BLOCK_RADIUS_SQ.ground;
if (d2 < r2 && d2 < bd) { u = o; bd = d2; }
}
}
if (!u) return false;
e.blockedBy = u;
u.blocking.push(e);
e.moving = false;
if (this._hooks.blocked) this.emit('blocked', { blocker: u, enemy: e });
return true;
}
/**
* The enemies ally `u` blocks that it may target with `profile` — always selectable by their blocker, inside its range
* or not, whatever its facing ("可以选择且优先选择阻挡单位", PRTS 选择器; ai.js acquireTargets, the skills' DEFAULT
* trigger). That holds for a ranged operator on a melee tile too: the user's rule after playtest #6, "阻挡了就一定要能
* 打到" — officially the collision pushes a blocked enemy to its blocker's front, so whatever blocks an enemy can hit
* it (DESIGN §20; it replaces the playtest #5 QA's melee-only restriction). Only for attacks that hit enemies: a heal
* attack never asks — it keeps selecting injured allies while its unit blocks (PRTS 卫戍协议/帮助 "对于医疗干员
* (咒愈师分支除外),攻击目标为需要治疗的单位"; PRTS 选择器 adds only the blocked units its side can pick).
*/
blockedTargets(u, profile) {
const out = [];
if (!u || !u.blocking || !u.blocking.length) return out;
for (const e of u.blocking) if (e.blockedBy === u && canTargetEnemy(u, e, profile)) out.push(e);
return out;
}
/**
* 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 围墙 / 围栏 地形机制 "部署在其中的单位,若当前阻挡类型为'地面
* 阻挡'则无法阻挡敌人". The gate reads the tile's ground passability; on the stages the fenced tiles are the only low
* tiles ground units cannot pass. Air blocking (blockFly against flyers) stays [ASSUMED: PRTS restricts the rule to
* 地面阻挡]. 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). An airborne
* unit (起飞, flag `liftoff`: "不阻挡地面敌人…可以阻挡飞行敌人") blocks flyers only.
*/
_blockerFor(u, e, w) {
// flag `noNewBlock`: a unit that takes no new enemy by contact — it blocks only those content hands it (酒神's 迷狂牢笼,
// PRTS "只在生成/刷新时判定阻挡新的敌人"); the blocks it holds go on as usual
if (!u.alive || !u.deployed || u.hidden || u.s.flags.noBlock || u.s.flags.sleep || u.s.flags.noNewBlock) return false;
if (e.isFlying && !(u.s.flags.blockFly || (u.profile && u.profile.blockFly))) return false;
// ground enemies: only a ground unit that has not taken off (起飞 "不阻挡地面敌人": flag `liftoff`), standing on a tile
// ground units can pass (not a fenced 围墙 / 围栏 tile)
if (!e.isFlying && (!u.ground || u.s.flags.liftoff || this.grid.tile(u.tileR, u.tileC).pass !== 'ALL')) return false;
const cap = u.s.blockCnt;
if (cap <= 0) return false;
let used = 0;
for (const x of u.blocking) used += x.blockWeight ?? 1;
return used + w <= cap;
}
_unblock(e) {
const bl = e.blockedBy;
if (!bl) return;
const i = bl.blocking.indexOf(e);
if (i >= 0) bl.blocking.splice(i, 1);
e.blockedBy = null;
this._stealthSwitch(e);
}
/** Release every enemy blocked by ally `u` (death, retreat, block count drop, substitution…). */
releaseBlocked(u) {
if (!u || !u.blocking || !u.blocking.length) return;
const was = u.blocking;
u.blocking = [];
for (const e of was) if (e.blockedBy === u) { e.blockedBy = null; this._stealthSwitch(e); }
}
/**
* A block on enemy `e` just ended (every release goes through here: `_unblock`, `releaseBlocked`, ai.js
* enforceBlockCapacity). Each 隐匿 source it holds stays switched off for its restore time — PRTS 作战机制 §隐匿 "对于
* 绝大部分可隐匿的敌人而言,在被我方单位阻挡后会解除隐匿,不被阻挡的3秒后重新进入隐匿" (STEALTH_RESTORE), or the source's
* own "(解除阻挡N秒后恢复)" (buff `data.stealthRestore`, content/enemies/helpers.js STEALTH_RESTORE_BY_KEY: 0 s / 1 s on some enemy pages) — as a
* `stealthOff` buff per source; meanwhile it is targetable, operator splash reaches it and it is drawn solid
* (targeting.js enemyStealthed). A new block inside the window lifts it again and its end restarts the window. Our
* operators' 隐匿 / 迷彩 are never lifted by blocking (only enemies get here).
*/
_stealthSwitch(e) {
if (!e || e.side !== 'enemy' || !e.alive || !e.s.flags.stealth) return;
for (const b of e.buffs.slice()) {
if (!b.flags || !b.flags.stealth) continue;
const t = Number.isFinite(b.data?.stealthRestore) ? b.data.stealthRestore : STEALTH_RESTORE;
if (t > 0) this.addBuff(e, { key: stealthOffKey(b.key), duration: t, flags: { stealthOff: true } });
}
}
}