From b32c510069767cf6141cb9421c745ecfdb4e880c Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 23:43:28 +0800 Subject: [PATCH] =?UTF-8?q?feedback2=20WG:=20an=20enemy's=20=E9=9A=90?= =?UTF-8?q?=E5=8C=BF=20returns=203=20s=20after=20a=20block=20ends=20(0=20s?= =?UTF-8?q?=20/=201=20s=20where=20its=20PRTS=20page=20says=20so);=20a=20?= =?UTF-8?q?=E6=B7=B1=E6=B1=A0=E9=80=90=E7=81=AB=20knocked=20out=20while=20?= =?UTF-8?q?blocked=20leaves=20a=20revealed=20ember;=20=E5=B1=B1=E6=B5=B7?= =?UTF-8?q?=E4=BC=97=E5=A4=B4=E7=9B=AE's=20strong=20hit=20comes=20with=20i?= =?UTF-8?q?ts=20=E9=9A=90=E5=8C=BF=20(#43=20follow-up)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/sim/Battle.js | 37 +++++++++++---- server/sim/ai.js | 2 +- server/sim/constants.js | 7 +++ server/sim/content/enemies.js | 69 +++++++++++++++++++++++----- server/sim/snapshot.js | 11 +++-- server/sim/targeting.js | 19 +++++++- test/sim/feedback1-transform.test.js | 2 +- 7 files changed, 120 insertions(+), 27 deletions(-) diff --git a/server/sim/Battle.js b/server/sim/Battle.js index caa9b20f..6d24a293 100644 --- a/server/sim/Battle.js +++ b/server/sim/Battle.js @@ -24,14 +24,14 @@ // Robustness: every content callback and every step phase is wrapped; errors are logged once per key and the // battle continues. After MAX_INTERNAL_ERRORS the battle force-ends as a timeout. -import { TICK, ROWS, COLS, BLOCK_RADIUS_SQ, DP_DEFAULTS, DOWN_STATE, FORCED_EXIT, MAX_BATTLE_TIME, MAX_INTERNAL_ERRORS, COLD_FREEZE_DURATION, OBSTACLE_DEVICES, EVENT_BUFFER_CAP, BOSS_ROW_OFFSET, MAX_HOOK_DEPTH, MAX_ALIVE_ENEMIES, LEVITATE_HALF_WEIGHT, RESIST_DEFAULT, RESIST_PALSY_DECAY, PUSH_TILES, PUSH_TILES_EFFECT, PULL_WEAK_SHARE, PULL_CRAWL, PULL_ORIGIN, PULL_STOP_RADIUS, PUSH_DIRECTIONAL_MIN_DIST, AUTO_OP_COOLDOWN } from './constants.js'; +import { TICK, ROWS, COLS, BLOCK_RADIUS_SQ, DP_DEFAULTS, DOWN_STATE, FORCED_EXIT, MAX_BATTLE_TIME, MAX_INTERNAL_ERRORS, COLD_FREEZE_DURATION, OBSTACLE_DEVICES, EVENT_BUFFER_CAP, BOSS_ROW_OFFSET, MAX_HOOK_DEPTH, MAX_ALIVE_ENEMIES, LEVITATE_HALF_WEIGHT, RESIST_DEFAULT, RESIST_PALSY_DECAY, PUSH_TILES, PUSH_TILES_EFFECT, PULL_WEAK_SHARE, PULL_CRAWL, PULL_ORIGIN, PULL_STOP_RADIUS, PUSH_DIRECTIONAL_MIN_DIST, AUTO_OP_COOLDOWN, STEALTH_RESTORE } from './constants.js'; import { GEO, layerGainRoom } from '../../shared/constants.js'; import { createRng } from './rng.js'; import { Grid } from './grid.js'; import { Unit } from './units.js'; import { makeBuff, STATUS, RESIST_STATUSES } from './buffs.js'; import { dealDamage as pipeDamage, heal as pipeHeal, applyHpLoss, makeDamageInfo, reduceElement, palsyBuff, elementView, leaderHitCancelled } from './damage.js'; -import { absoluteRangeKeys, canTargetEnemy, extendedGrid, evadesGround } from './targeting.js'; +import { absoluteRangeKeys, canTargetEnemy, extendedGrid, evadesGround, enemyStealthed, stealthOffKey } from './targeting.js'; import { bodyKeys, bodyInKeys, bodyInRadius } from './body.js'; import { normDir, mirrorDir, localOrder, localBefore } from './dir.js'; import { ProjectileSystem } from './projectiles.js'; @@ -1169,13 +1169,33 @@ export class Battle { 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; - for (const e of u.blocking) if (e.blockedBy === u) e.blockedBy = null; + 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.js: 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 } }); + } } // ============================================================================================================= @@ -1609,17 +1629,18 @@ export class Battle { /** * The enemies within `r` (as enemiesInRadius) an ally-side area effect can select — PRTS 作战机制 §AOE伤害判定 "AOE的判定是 * 对攻击范围内的每个可以被选中的敌人进行判定", 隐匿 "隐匿状态下的单位一般无法被敌方的索敌机制和Buff选择器选中为目标": no - * untargetable enemy and no 隐匿 one unless revealed or blocked (tile selectors — enemiesInKeys / canTargetEnemy — already - * skip them). Flying and asleep enemies stay the caller's choice. Enemy-side effects on other enemies (auras, heals) and - * physical collisions keep enemiesInRadius. Player report #8 after 0.1.0 (the 逐火 余烬): until 0.1.1 profession splash - * and skill circles still reached an unblocked 隐匿 enemy. + * untargetable enemy and no 隐匿 one unless revealed, blocked or not hidden again yet after a block (targeting.js + * enemyStealthed; tile selectors — enemiesInKeys / canTargetEnemy — already skip them). Flying and asleep enemies stay + * the caller's choice. Enemy-side effects on other enemies (auras, heals) and physical collisions keep enemiesInRadius. + * Player report #8 after 0.1.0 (the 逐火 余烬): until 0.1.1 profession splash and skill circles still reached an + * unblocked 隐匿 enemy. */ foesInRadius(x, y, r, centre = false) { const out = this.enemiesInRadius(x, y, r, centre); let n = 0; for (const e of out) { const f = e.s.flags; - if (f.untargetable || (f.stealth && !f.reveal && !e.blockedBy)) continue; + if (f.untargetable || (f.stealth && enemyStealthed(e))) continue; out[n++] = e; } out.length = n; diff --git a/server/sim/ai.js b/server/sim/ai.js index 5ff91ed2..6c6ae55d 100644 --- a/server/sim/ai.js +++ b/server/sim/ai.js @@ -97,7 +97,7 @@ export function enforceBlockCapacity(b, u) { for (const e of u.blocking) used += e.blockWeight ?? 1; while (used > cap && u.blocking.length) { const e = u.blocking.pop(); - if (e.blockedBy === u) e.blockedBy = null; + if (e.blockedBy === u) { e.blockedBy = null; b._stealthSwitch(e); } // a released 隐匿 enemy hides again later used -= e.blockWeight ?? 1; } } diff --git a/server/sim/constants.js b/server/sim/constants.js index f3abb90f..74058e7a 100644 --- a/server/sim/constants.js +++ b/server/sim/constants.js @@ -31,6 +31,13 @@ export const ALLY_COLLIDER_RADIUS = 0.25; */ export const BLOCK_RADIUS = Object.freeze({ ground: 0.70709997, fly: 0.8944, device: 0.4472 }); export const BLOCK_RADIUS_SQ = Object.freeze({ ground: 0.49999037, fly: 0.79995137, device: 0.4472 * 0.4472 }); +/** + * An enemy's 隐匿 after a block ends (s): PRTS 作战机制 §隐匿 "对于绝大部分可隐匿的敌人而言,在被我方单位阻挡后会解除隐匿,不被 + * 阻挡的3秒后重新进入隐匿" / §隐匿与Buff的关系 "阻挡状态解除后3s开关重新被开启而恢复隐匿". An enemy page's "(解除阻挡N秒后 + * 恢复)" overrides it per 隐匿 source (buff `data.stealthRestore`: content/enemies.js). Battle._stealthSwitch; our + * operators' 隐匿 / 迷彩 are never lifted by blocking ("我方干员并不会因为阻挡而解除隐匿"). + */ +export const STEALTH_RESTORE = 3; /** Default projectile speed in tiles/s for ranged operators/enemies. */ export const PROJECTILE_SPEED = 12; /** diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index 9fd75b22..f1b9736c 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -57,7 +57,9 @@ // INVISIBLE — permanent `stealth` flag (engine: untargetable unless blocked or revealed). An operator's radius area // damage skips an unblocked one too (Battle.foesInRadius: profession splash around a struck target, // skill circles — PRTS 作战机制 §AOE伤害判定 "对攻击范围内的每个可以被选中的敌人进行判定"; until 0.1.1 it -// still hit it); tile selectors (enemiesInKeys) always skipped it. +// still hit it); tile selectors (enemiesInKeys) always skipped it. After a block it hides again only +// STEALTH_RESTORE (3) s later — or after the "(解除阻挡N秒后恢复)" of its PRTS page (STEALTH_RESTORE_BY_KEY, +// 清明's veil 0 s) — Battle._stealthSwitch / targeting.js enemyStealthed (until 0.1.2: at once). // REFLECTION — 折射 (ba.refraction "生效时,法术抗性+70"): RES +refracting.magic_resistance while NOT silenced // (the ability line is SILENCE-flagged: silencing turns it off); 镜膜 also gets max HP +100 % while on. // SPECIAL — mostly stats; prisoners, 穿刺手, 暴虐兵长, 镜卫, 动力装甲 … below. @@ -78,7 +80,7 @@ // Custom hook: 'lpLoss' {amount, reason, source} — leader "扣除目标生命" effects; also summed into result.lpLoss. import { TICK, MOVE_SCALE, ELEMENT, ATTACK_PAUSE, PROJECTILE_SPEEDS, ALLY_COLLIDER_RADIUS } from '../constants.js'; -import { canTargetAlly, sortAllyTargets, aggroCmp, evadesGround } from '../targeting.js'; +import { canTargetAlly, sortAllyTargets, aggroCmp, evadesGround, enemyStealthed } from '../targeting.js'; import { mitigate, periodicDamage } from '../damage.js'; // --------------------------------------------------------------------------------------------------------------- @@ -107,6 +109,23 @@ const TANK_ZONE_RADIUS = 1.7; const POLLUTION_INTERVAL = 1; /** "数个目标" of 假想敌:骨刺 while stealthed [ASSUMED]. */ const ACBUNN_TARGETS = 3; +/** + * 隐匿 that comes back sooner after a block than the general 3 s (PRTS 作战机制 §隐匿 "对于绝大部分可隐匿的敌人而言…不被阻挡的 + * 3秒后重新进入隐匿"; engine STEALTH_RESTORE): the enemy pages' 天赋 "隐匿(解除阻挡N秒后恢复)" — PRTS text search + * "解除阻挡0秒后恢复" (21 enemies; these 6 are in this mode) and "解除阻挡1秒后恢复" (the two 家族灭迹人), checked + * 2026-10-03. Every other stealthy enemy here has a plain "隐匿" (3 s), the 深池逐火 embers included. 清明 / 堂皇's veil + * ("获得隐匿(解除阻挡0秒后恢复)") is kitInvisShield's. + */ +const STEALTH_RESTORE_BY_KEY = Object.freeze({ + enemy_10031_cnvsld: 0, // 业余竞演者 + enemy_10034_cnvsax: 0, // 节日爵士乐手 + enemy_9008_acbunn: 0, // 假想敌:骨刺 + enemy_2034_sythef: 0, // 流泪小子 + enemy_2034_sythef_2: 0, // 流泪小子 (its stronger copy) + enemy_1389_winbab_2: 0, // 访问团强攻冠军 + enemy_1283_sgkill: 1, // 家族灭迹人 + enemy_1283_sgkill_2: 1, // 家族暗影灭迹人 +}); /** 假想敌:再生 shield aura radius (PRTS 假想敌:再生 天赋 "进入此形态时,使半径1.8范围内的其他敌方单位(无视其可选性)获得5层…护盾"). */ const ACPUPP_AURA_RADIUS = 1.8; /** 重弩突袭者 直击 reach along a row/column [ASSUMED]. */ @@ -572,8 +591,16 @@ const onTerrain = (b, u, terrain) => { const [r, c] = tileOf(u); return b.grid.t // --------------------------------------------------------------------------------------------------------------- // archetypes -/** 隐匿: permanent stealth. */ -const stealth = () => ({ spawn(b, e) { b.addBuff(e, { key: 'ab:stealth', flags: { stealth: true }, persist: true }); } }); +/** + * 隐匿: permanent stealth. Blocked it is lifted; it hides again STEALTH_RESTORE s after the block ends, or after its own + * "(解除阻挡N秒后恢复)" (STEALTH_RESTORE_BY_KEY → buff `data.stealthRestore`; Battle._stealthSwitch). + */ +const stealth = () => ({ + spawn(b, e, a, ab) { + const n = STEALTH_RESTORE_BY_KEY[ab.key]; + b.addBuff(e, { key: 'ab:stealth', flags: { stealth: true }, persist: true, data: n != null ? { stealthRestore: n } : {} }); + }, +}); /** 无法被阻挡. */ const unblockable = () => ({ spawn(b, e) { b.addBuff(e, { key: 'ab:unblockable', flags: { unblockable: true }, persist: true }); } }); /** @@ -883,9 +910,10 @@ function artsBarrier(amount, { key = 'ab:artsBarrier', whileUp = null } = {}) { * (PRTS 深池逐火战士 天赋 "被击倒后重生,持续1s,随后变为怨恨的余烬,1s内不移动且持有无敌+无法阻挡+失衡免疫"; PRTS 特殊机制 §重生) — * then the husk until `delay` s after that: `hits` HP of 特殊生命值机制 (every damage instance removes 1 — PRTS 特殊机制 * §特殊生命值机制; engine flag hitCount), no attack (缴械), walking its route on — 隐匿 (`stealthy`: targetable only while - * blocked, targeting.js canTargetEnemy — the 余烬 must be blocked to be beaten; drawn solid while blocked) - * and / or unblockable (`unblock`: 再生's 傀儡 "不可被阻挡"). Killing the husk is the real death; a husk still standing - * after `delay` s stands up in its first form with full HP, and every later knock-out starts it again ("一次又一次地站起"). + * blocked and for 3 s after a block ends, the block the knock-out itself releases included — targeting.js + * enemyStealthed; drawn solid meanwhile) and / or unblockable (`unblock`: 再生's 傀儡 "不可被阻挡"). Killing the husk is + * the real death; a husk still standing after `delay` s stands up in its first form with full HP, and every later + * knock-out starts it again ("一次又一次地站起"). * `onHusk(b, e)` runs as the husk begins, after the 重生 ("进入此形态时": 再生's shields). * User report after 0.1.0 (#8): the v2.5 ember stood still, stealthed AND unblockable — nobody could ever target it. * fx (setForm): 'ember' {id, hits, dur, form: 'husk'} at the knock-out, 'revive' {id, form: 'revived'} when it stands @@ -923,6 +951,13 @@ function husk({ hits, delay, stealthy = true, unblock = false, onHusk = null, ke hitCount(b, e, true); b.addBuff(e, { key, visible: true, persist: true, flags: { disarm: true, ...(stealthy ? { stealth: true } : {}), ...(unblock ? { unblockable: true } : {}) } }); b.addBuff(e, { key: `${key}:reborn`, duration: HUSK_REBIRTH, flags: { invulnerable: true, untargetable: true, unblockable: true, noMove: true, noDisplace: true } }); + // the 重生's 无法阻挡 ends a block at once (as applyStatus does for a status): a warrior knocked out while blocked + // leaves an ember whose 隐匿 is switched off until STEALTH_RESTORE (3) s after that block ended + // (Battle._stealthSwitch) — ~2 s after the 1 s 重生, which stays 无敌 + untargetable — so ranged operators and + // operator splash can finish it although its blocker took the next warrior meanwhile (players after 0.1.1: "the + // stealth monster revives forever"). [ASSUMED] the order: the 重生's cleanse first, then the ember's 隐匿 with the + // block-end switch (PRTS documents neither the order of 重生 vs that switch nor whether it survives the 重生) + b._unblock(e); if (e.route) e.route.pts = null; setForm(b, e, 'husk', 'ember', { hits, dur: HUSK_REBIRTH + delay }); b.after(HUSK_REBIRTH, () => { if (e.alive && a.state === 'husk') { rebirthCooldowns(e); if (onHusk) onHusk(b, e); } }, { owner: e }); @@ -1063,7 +1098,8 @@ const kitStun3 = (ab) => [nthAttackStatus(nthOf(ab.sk.stuncombat), 'stun', (ab.s const kitSelfFear = (ab) => [selfFear(ab)]; /** 深池逐火战士 / 精锐战士 / 护卫: knock-out ⇒ 1 s 重生 ⇒ a walking, 隐匿, disarmed 余烬 / 火灰 of prop_max_hp hits for `interval` s * (PRTS 深池逐火战士 天赋: "基础最大生命值临时变为5…具有特殊生命值机制,不进行攻击,获得隐匿、缴械,10s后若未被击倒则变回战士形态并恢复所有 - * 生命"); blocking it lifts the 隐匿, so its blocker (and every operator in range) can beat it. */ + * 生命"); blocking it lifts the 隐匿, so its blocker (and every operator in range) can beat it — also during the 3 s + * after a block ends, the warrior's own block that the knock-out releases included (Battle._stealthSwitch). */ const kitEmber = (ab) => [husk({ hits: T(ab, 'Revive[Trigger].prop_max_hp'), delay: T(ab, 'Revive[Trigger].interval') })]; const kitPolluted = (ab) => [{ death(c, b, e) { @@ -1311,7 +1347,8 @@ function kitInvisShield(ab, e) { return [...kitDeathSpawn()(ab), skill(s, (b, e2) => { const dur = s.bb.duration ?? T(ab, 'InvisibleShield.duration') ?? 0; b.fx('telegraph', { x: e2.x, y: e2.y, r, kind: 'invisShield', id: e2.id }); - for (const o of b.enemiesInRadius(e2.x, e2.y, r)) if (o !== e2 && dur > 0) b.addBuff(o, { key: 'ab:veiled', duration: dur, flags: { stealth: true }, visible: true }); + // "获得隐匿(解除阻挡0秒后恢复)" (PRTS 清明 / 堂皇 天赋): the veil's 隐匿 is back as soon as a block ends + for (const o of b.enemiesInRadius(e2.x, e2.y, r)) if (o !== e2 && dur > 0) b.addBuff(o, { key: 'ab:veiled', duration: dur, flags: { stealth: true }, visible: true, data: { stealthRestore: 0 } }); })]; } @@ -1331,10 +1368,18 @@ function kitCrossbow(ab) { }, { sil: true, cond: (b, e) => aligned(b, e).length > 0 })]; } +/** + * 山海众头目 / 山海众秘使 (PRTS 天赋 "隐匿,该隐匿每次生效后自身获得强击标记(不可叠加)"; 技能 破隐一击 "仅持有强击标记且被阻挡时可 + * 触发:对阻挡目标造成攻击力200%的物理普通伤害,技能开始时消耗强击标记"): the mark comes with its 隐匿 — at the spawn and every + * time it hides again (3 s after a block, Battle._stealthSwitch; or once a 反隐 ends) — and its next attack (a melee + * one: blocked) spends it at InvisibleCombat.atk_scale. A new block inside the 3 s gives no new mark (until 0.1.2 every + * block did). + */ function kitShadowKiller(ab) { const scale = (ab.sk.InvisibleCombat && ab.sk.InvisibleCombat.bb.atk_scale) || 1; return [stealth(), { - blocked(c, b, e, a) { a.power = true; }, // 隐匿效果失效 (blocked) → next attack ×scale + spawn(b, e, a) { a.power = true; a.on = true; }, + tick(b, e, a) { const on = enemyStealthed(e); if (on && !a.on) a.power = true; a.on = on; }, hitOut(c, b, e, a) { if (a.power && c.dmg.isAttack) c.dmg.amount *= scale; }, attack(c, b, e, a) { a.power = false; }, }]; @@ -1343,7 +1388,7 @@ function kitShadowKiller(ab) { function kitJazz(ab, e) { const s = ab.sk.fire; const r = (e.def.raw && e.def.raw.stats && e.def.raw.stats.rawRangeRadius) || 2.5; - const revealed = (u) => !!(u.blockedBy || u.s.flags.reveal); + const revealed = (u) => !enemyStealthed(u); // blocked, 反隐 (its 隐匿 returns 0 s after a block) return [stealth(), { spawn(b, e2) { e2.profile.noAttack = true; }, tick(b, e2) { e2.profile.noAttack = !revealed(e2); }, // 平时不攻击,失去隐匿时反击 @@ -1496,7 +1541,7 @@ function kitParasite(ab) { function kitBoneSpike() { return [stealth(), { before(c, b, e) { - if (e.blockedBy || e.s.flags.reveal) return; // 隐匿状态下同时攻击数个目标 + if (!enemyStealthed(e)) return; // 隐匿状态下同时攻击数个目标 (back 0 s after a block) const l = byPriority(e, targetsNear(b, e, e.base.rangeRadius)); if (l.length) c.targets = l.slice(0, ACBUNN_TARGETS); }, diff --git a/server/sim/snapshot.js b/server/sim/snapshot.js index 6b0d383e..e40bfd73 100644 --- a/server/sim/snapshot.js +++ b/server/sim/snapshot.js @@ -8,10 +8,12 @@ // dir = 'UP'|'RIGHT'|'DOWN'|'LEFT' (allies: the deploy direction, sim/dir.js); facing = its horizontal sign (±1). // items = an ally operator's equipped item ids (absent without any). // flags bits & anim codes come from shared/constants.js (UF / ANIM); an enemy's stealth bit = its 隐匿 is on (not while it -// is blocked or revealed), an ally's = 隐匿 / 迷彩 whatever it blocks. +// is blocked or revealed, nor in the seconds after a block before it hides again — targeting.js enemyStealthed), an +// ally's = 隐匿 / 迷彩 whatever it blocks. import { UF, ANIM } from '../../shared/constants.js'; import { DIE_ANIM_TIME, ATTACK_ANIM_TIME, DEPLOY_ANIM_TIME } from './constants.js'; +import { enemyStealthed } from './targeting.js'; const r2 = (v) => Math.round(v * 100) / 100; const r1 = (v) => Math.round(v * 10) / 10; @@ -60,9 +62,10 @@ export function flagsOf(u) { if (f.stun && !f.freeze && !f.sleep) bits |= UF.STUNNED; if (f.freeze) bits |= UF.FROZEN; // 隐匿 or 迷彩 (buffs.js camou), shown the see-through way. An ally keeps it while blocking (targeting.js - // canTargetAlly); an enemy's 隐匿 is off while it is blocked or revealed (PRTS 作战机制 §隐匿 "在被阻挡时开关会被关掉从而 - // 失去隐匿"; targeting.js canTargetEnemy): a blocked 逐火 余烬 is drawn solid while the team beats it - if (u.side === 'enemy' ? (f.stealth && !f.reveal && !u.blockedBy) || f.camou : f.stealth || f.camou) bits |= UF.STEALTH; + // canTargetAlly); an enemy's 隐匿 is off while it is blocked or revealed and until it hides again after a block (PRTS + // 作战机制 §隐匿 "在被阻挡时开关会被关掉从而失去隐匿,阻挡状态解除后3s开关重新被开启"; targeting.js enemyStealthed): a + // blocked 逐火 余烬 is drawn solid while the team beats it, and for 3 s after it slips away + if (u.side === 'enemy' ? (f.stealth && enemyStealthed(u)) || f.camou : f.stealth || f.camou) bits |= UF.STEALTH; if (u.skill && u.skill.active && u.skill.kind !== 'passive') bits |= UF.SKILL; if (u.s.shield > 0 || u.buffs.some((b) => b.shieldHits > 0)) bits |= UF.SHIELD; if (f.invulnerable) bits |= UF.INVULN; diff --git a/server/sim/targeting.js b/server/sim/targeting.js index bd12844a..91c0aa4f 100644 --- a/server/sim/targeting.js +++ b/server/sim/targeting.js @@ -55,12 +55,29 @@ export function tileKeyOf(u) { return r * COLS + c; } +/** Key of the buff that keeps an enemy's 隐匿 source `sourceKey` switched off after a block (Battle._stealthSwitch). */ +export const stealthOffKey = (sourceKey) => `stealthOff:${sourceKey}`; + +/** + * Is enemy `e`'s 隐匿 in effect — no ally selection picks it, operator splash skips it (Battle.foesInRadius), the b.snap + * stealth bit is set? Not while it is revealed (反隐, flag `reveal`), not while blocked, and not while every 隐匿 source + * it holds is still switched off after its last block (flag `stealthOff`: PRTS 作战机制 §隐匿 "在被我方单位阻挡后会解除隐匿, + * 不被阻挡的3秒后重新进入隐匿" — constants.js STEALTH_RESTORE, or the source's own "(解除阻挡N秒后恢复)"). + */ +export function enemyStealthed(e) { + const f = e.s.flags; + if (!f.stealth || f.reveal || e.blockedBy) return false; + if (!f.stealthOff) return true; + for (const b of e.buffs) if (b.flags && b.flags.stealth && !e.findBuff(stealthOffKey(b.key))) return true; + return false; +} + /** Can `attacker` (ally) target enemy `e` at all (ignoring range)? */ export function canTargetEnemy(attacker, e, profile) { if (!e.alive || e.hidden || !e.deployed) return false; const f = e.s.flags; if (f.untargetable || (f.sleep && !(profile && profile.hitSleep))) return false; - if (f.stealth && !f.reveal && !e.blockedBy) return false; + if (f.stealth && enemyStealthed(e)) return false; if (e.isFlying && !(profile && profile.canHitFly)) return false; if (profile && profile.groundOnly && e.isFlying) return false; return true; diff --git a/test/sim/feedback1-transform.test.js b/test/sim/feedback1-transform.test.js index aa2c7e57..26bb848b 100644 --- a/test/sim/feedback1-transform.test.js +++ b/test/sim/feedback1-transform.test.js @@ -572,7 +572,7 @@ describe('a blocked 隐匿 enemy is drawn solid (b.snap stealth bit: PRTS 作战 const tuple = () => h.b.snapshot().units.find((t) => t[0] === e.id); assert.ok(tuple()[7] & UF.STEALTH, 'in b.snap too'); assert.ok(h.runUntil(() => !!e.blockedBy, 10), 'walks into the wall'); - assert.ok(e.s.flags.stealth, 'the sim keeps the 隐匿 flag (it returns once the block ends)'); + assert.ok(e.s.flags.stealth, 'the sim keeps the 隐匿 flag (it returns 3 s after the block ends, §22.8)'); assert.equal(flagsOf(e) & UF.STEALTH, 0, 'blocked: drawn solid'); assert.equal(tuple()[7] & UF.STEALTH, 0); assert.ok(flagsOf(e) & UF.BLOCKED);