feedback2 WG: tests for 余烬's 禁疗 and the 3 s 隐匿 window (embers included); flag list, DESIGN §5 / §21.4 and SIM.md follow

This commit is contained in:
sganggs committed 2026-10-03 23:43:28 +08:00
1 parent b32c510069
commit e66a1a8ddb
4 files changed
+385 -16

No files matched your search

+5 -5
View File
@@ -229,7 +229,7 @@ Base stats (from data): `maxHp, atk, def, res` (0–100), `aspd` (100 base), `ba
`Buff = { key, source, duration /*s, Infinity*/, stacks=1, maxStacks=1, refresh: 'replace'|'extend'|'stack', mods, flags, onTick?, onExpire?, tags[] }`
- `mods` keys (all optional, additive within a key): `atkFlat, atkPct, atkMul, defFlat, defPct, defMul, hpFlat, hpPct, hpMul, resFlat, resMul, aspd, batPct, blockCnt, rangeExtend (tiles), moveMul, dmgDealtMul (multiplicative, e.g. 1.2), dmgTakenMul, physTakenMul, artsTakenMul, trueTakenMul, defIgnoreFlat, defIgnorePct, resIgnoreFlat, resIgnorePct, dodgePhys, dodgeArts, healingDealtMul, healingTakenMul, spRecoveryFlat, spRecoveryMul, maxTargets (+n), shield (absorbs damage; consumed)`.
- `flags`: `stun, freeze, sleep, silence (no skills), disarm (no attacks), stealth (untargetable by enemies/ops unless blocked or revealed — an operator's radius area damage skips it too, `Battle.foesInRadius`, PRTS 作战机制 §AOE伤害判定, §21.19; an enemy's b.snap stealth bit is set only while its 隐匿 is on, §21.4), camou (an ally's 迷彩: like stealth for enemy targeting and on screen, but not 隐匿 for 隐匿 conditions — 叙拉古, 家族徽章; §20.2), liftoff (an ally's 起飞, 蒂比's skills: it blocks flyers only and has 对地规避 — no ground enemy selects it, so no selected damage or status of one lands, while what selects nobody still does: `ignoreSelect` / 无来源 — 无视无法选择 abilities, direct picks, flying units' blasts, a debuff's ticks; it stays a ground unit on its tile; `targeting.js evadesGround`, §21.22), invulnerable, unblockable, levitate, fear (enemy: cannot attack, unblockable, runs to random tiles of the ±45° fan away from the source; no source or itself as source = inside its own tile — `fear.js`, §20.4), taunt (+aggro), cold, noHeal (no heals: 禁疗 summons and 孤立 units; an HP-regen attribute still applies — PRTS 安洁莉娜 备注), isolated (孤立: no friendly selector picks it — no heal, buff, aura or talent of an ally, `Battle.allySelectable`; §20.3)`.
- `flags`: `stun, freeze, sleep, silence (no skills), disarm (no attacks), stealth (untargetable by enemies/ops unless blocked or revealed — an operator's radius area damage skips it too, `Battle.foesInRadius`, PRTS 作战机制 §AOE伤害判定, §21.19; an enemy's 隐匿 returns only 3 s after a block ends, or after its PRTS page's "(解除阻挡N秒后恢复)" — `stealthOff` buffs, `targeting.js enemyStealthed`, §22.8; an enemy's b.snap stealth bit is set only while its 隐匿 is on, §21.4), camou (an ally's 迷彩: like stealth for enemy targeting and on screen, but not 隐匿 for 隐匿 conditions — 叙拉古, 家族徽章; §20.2), liftoff (an ally's 起飞, 蒂比's skills: it blocks flyers only and has 对地规避 — no ground enemy selects it, so no selected damage or status of one lands, while what selects nobody still does: `ignoreSelect` / 无来源 — 无视无法选择 abilities, direct picks, flying units' blasts, a debuff's ticks; it stays a ground unit on its tile; `targeting.js evadesGround`, §21.22), invulnerable, unblockable, levitate, fear (enemy: cannot attack, unblockable, runs to random tiles of the ±45° fan away from the source; no source or itself as source = inside its own tile — `fear.js`, §20.4), taunt (+aggro), cold, noHeal (no heals from others: 禁疗 summons and 孤立 units; an HP-regen attribute still applies — PRTS 安洁莉娜 备注), healFree (禁疗 that stops the unit's own heals too, shown as the status 'healFree': 史尔特尔's 余烬 sets it with noHeal — only an HP-regen attribute and her S3's "无视禁疗" start heal pass, §22.7), isolated (孤立: no friendly selector picks it — no heal, buff, aura or talent of an ally, `Battle.allySelectable`; §20.3)`.
- **Status catalogue** (`buffs.js`, keys used by content): `stun`, `freeze` (stun + res −15), `cold` (aspd −30; a second cold while cold ⇒ freeze 3 s unless immune), `sleep` (stun; ends on damage? no: in AK sleep = untargetable & unable to act; keep AK: untargetable & inactive), `slow` (moveMul), `bind` (moveMul 0), `fragile` (dmgTakenMul), `artsFragile` (artsTakenMul), `silence`, `fear` (the 恐惧 walk, `fear.js`), `burnGauge`/`necrosis`/`neural` element gauges (see damage), `stealth`, `camou` (迷彩, its own keys: 忍冬 S3 `vulpis:camou` until her next cast, 寒芒克洛丝 S1), `reveal`.
- Immunities from enemy data (`stunImmune`, `silenceImmune`, `sleepImmune`, `frozenImmune`, `levitateImmune`) are honoured by `applyStatus`; a unit both `invulnerable` and `untargetable` (a 重生, a 永久无敌 leader part) takes no status from the other side at all (PRTS 无敌 "无法被不同阵营选中", §21.19). Flag `selfBound` = 自缚 (守墓石像's 转换模式, the 自缚 leaders) beside its `noMove`: 余 S2 never teleports such a unit (束缚 sets `noMove` only).
- Same-named debuffs on an enemy (奥术's arts damage taken, 灵知 坚冰, the 莱恩哈特 / 缄默德克萨斯 RES cuts): one instance per target whoever applies it, the strongest wins (`Battle.applyStrongest`; PRTS 作战机制 "同名buff…只能表现出一个" — strongest rather than latest [ASSUMED], §20.10). PRTS 盟约记录's current 奥术 note fits this under the default overlap; an equal re-hit refreshes the instance (an earlier PRTS wording forbade re-applying while one's own lasts) [ASSUMED, slightly stronger] (§21.7).
@@ -268,8 +268,8 @@ Engine helpers (content must use these, never touch internals): `battle.dealDama
- **Initial deployment** at t=0, free: operators row-major top→bottom then left→right (right-side boss player: right→left), then the summons (PRTS 卫戍协议/帮助 "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物"; `aggroSeq` ranks every start-of-battle summon after all operators) — a skill's placed summon (赫默 医疗探机, 巫恋 诅咒娃娃) included, once and free ("所有手动部署的召唤物,无视所属干员的持有状态…作战开始时立即部署一次"; afterwards it re-appears on its tile each time the owner's skill gives one, §20.1); on a shared field (联防, boss) the players' i-th operators deploy together [ASSUMED]. Deploy effects fire (`deploy` with `initial: true`). Then operators carried into 联防 knocked out (`carryState.down`) are forced out (`FORCED_EXIT`, HP 0, §19.3). Then `battleStart`, after which the forced-out operators' timers are re-read (redeploy-time effects that start with the battle, e.g. 机变 征召, cover them too).
- **DP**: per player, start 10, +1/s, cap 99; used only for auto-redeploy (dead op: after `respawnTime`, if the tile it lies on is free and DP ≥ cost ⇒ redeploy there with full HP, SP = initSp). A knocked-out operator stays **down** on the tile it fell on — its own home when it fell on another board piece's home (that piece on the field or not) and its home is free — until it redeploys there, and no ally deploys or moves onto that tile meanwhile (PRTS 卫戍协议/帮助 §作战阶段 单位部署; `Battle.downOn` / `restTile` / `_layBody`, §21.24; b.snap `down`, §18.3: counting / waiting for DP / its tile taken, a safeguard) — also one that entered 联防 knocked out (`FORCED_EXIT`, its full redeploy timer — confirmed by the user — with the battle-start redeploy effects, §19.3); summons, devices and enemies leave at once.
- **Blocking** (contact radius, PRTS 游戏数据基础 §阻挡半径 / 作战机制 "中点判定…案例:阻挡"; `constants.js BLOCK_RADIUS`, `Battle._checkBlock`, §19.2): an operator with free capacity blocks an unblocked, blockable enemy whose position lies within its block radius of its centre — ground 0.7071 (squared 0.49999037), 0.8944 for blockFly units vs flyers (起飞), devices 0.4472 (阻隔工事 / 障碍物); air units (FLY, 近地悬浮, 浮空) only by blockFly units. A ground enemy is never blocked by a unit standing on a tile ground units cannot pass (围墙 / 围栏: PRTS 地形机制 "部署在其中的单位,若当前阻挡类型为'地面阻挡'则无法阻挡敌人" — an enemy pushed or pulled against the fence walks on; air blocking there stays [ASSUMED], §21.23) nor by an airborne (起飞) unit, which blocks flyers only (§21.22). The nearest such operator wins (ties: scan order [ASSUMED]). It is re-checked every tick, moving or not, so another operator in contact with room takes the enemy over when its blocker dies, is withdrawn or is stunned, and an operator whose capacity frees up grabs an overlapping enemy; one that finds no room walks on. A head-on enemy stops at contact (≈ 0.71 tile), outside the blocker's tile. Enemy uses `blockCnt` capacity (default 1). Blocked enemies stop moving and attack their blocker if melee (or anyone in range if ranged, blocker first). **Every blocker** — melee units (重装要塞 / 领主 / 哨戒铁卫 included), summons and a ranged operator on a melee tile alike — whose attack hits enemies 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 front; `Battle.blockedTargets`, `targeting.js sortEnemyTargets`; §20.3 — it replaces the playtest #5 QA's melee-only restriction of §19.2). A healer (dmgType `heal`, or a skill attack turned into a heal) selects injured allies only and keeps healing while it blocks (PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位"; §21.17); 咒愈师 follow the blocker rule. The huge leaders are 自缚 + unblockable (§7).
- **Operator attacks**: every `interval` s if a valid target exists; targets = up to `maxTargets` (default 1) from `unitsInGrid(rangeGrid)` (a `rangeAoe` profile — 阵法术师, 轰击术师 — takes every selectable enemy in range, §21.18), priority: (1) the enemies it blocks (every blocker, in range or not, §20.3), (2) profession/trait priorities (e.g. fly-first snipers, `trait` text parsed by build-data into `targetPriority`), (3) enemy with the **least remaining path distance** to its goal, ties → earliest spawned. Melee (no ranged trait) and ground-only attacks cannot hit air units (`Unit.isFlying`: FLY, or a 近地悬浮 / 浮空 enemy — PRTS "算作空中单位", §19.1). Stealthed enemies can't be targeted unless blocked or revealed. Ranged ops fire projectiles (speed ~12 tiles/s, instant for melee/arts-beam classes as data indicates); damage applies on impact; a 锁定攻击范围 AoE (`rangeAoe`) strikes every selectable enemy on its range at once with instant hits (§21.18).
- **Healers** (`dmgType heal`): target the ally in range with the lowest HP% below 100%, never a 禁疗 / 孤立 unit (tokens.json `abnormal`, `noHeal`; §20.3), and never the enemies they block — also while blocking (§21.17); do nothing otherwise (SP still regenerates).
- **Operator attacks**: every `interval` s if a valid target exists; targets = up to `maxTargets` (default 1) from `unitsInGrid(rangeGrid)` (a `rangeAoe` profile — 阵法术师, 轰击术师 — takes every selectable enemy in range, §21.18), priority: (1) the enemies it blocks (every blocker, in range or not, §20.3), (2) profession/trait priorities (e.g. fly-first snipers, `trait` text parsed by build-data into `targetPriority`), (3) enemy with the **least remaining path distance** to its goal, ties → earliest spawned. Melee (no ranged trait) and ground-only attacks cannot hit air units (`Unit.isFlying`: FLY, or a 近地悬浮 / 浮空 enemy — PRTS "算作空中单位", §19.1). Stealthed enemies can't be targeted unless blocked or revealed — or within 3 s of a block's end (§22.8). Ranged ops fire projectiles (speed ~12 tiles/s, instant for melee/arts-beam classes as data indicates); damage applies on impact; a 锁定攻击范围 AoE (`rangeAoe`) strikes every selectable enemy on its range at once with instant hits (§21.18).
- **Healers** (`dmgType heal`): target the ally in range with the lowest HP% below 100%, never a 禁疗 / 孤立 unit (tokens.json `abnormal`, `noHeal`; §20.3; 史尔特尔 during 余烬, §22.7), and never the enemies they block — also while blocking (§21.17); do nothing otherwise (SP still regenerates).
- **Enemy AI**: follow the route (checkpoints; WALK paths recomputed on the grid when devices change); when blocked stop & fight (`dmgType 'none'` enemies never attack unless content arms them through `e.profile` — 转译基底's armed forms, §21.4); ranged enemies (`rangeRadius > 0`) attack ops whose 0.25 collider touches their radius (centre distance ≤ `rangeRadius` + 0.25, §3, §20.4): the enemy's own target rule filters the candidates first (`profile.canTarget`: 只攻击地面单位 …), then priority blocker → special priority → highest taunt → **latest deployed** (`aggroSeq`) — PRTS 索敌 "阻挡→特殊优先级→仇恨值→最早出现"; a stealthed ally is attacked only by the enemy it blocks (§19.1); an airborne (起飞) ally is never a ground enemy's selection — picks, area damage, zones and statuses skip it, while 无视无法选择 abilities, direct picks, debuff ticks and flying units still reach it (§21.22); they pause movement for `ATTACK_PAUSE = 0.35 s` per attack. FLY enemies ignore ground obstacles (only blockFly units block them) and follow their checkpoint route. A feared enemy runs between random 恐惧可达地块 away from the source (PRTS 诱发移动; `fear.js`); 恐惧 outranks 诱导; the 恐惧 / 诱导 walks re-plan from where the enemy stands after a push or an obstacle change, and the route re-plans when they end (§20.4).
- **Damage**: phys `max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 0.05·A)`; arts `max(A × (1 − R'/100), 0.05·A)` with `R' = max(0, R×(1−resIgnorePct) − resIgnoreFlat)`; true = A; 元素伤害 ('elemental') `max(A × (1 − 元素抗性/100), 0.05·A)`. Then × `dmgDealtMul` (source) × `dmgTakenMul` & type-specific taken multipliers (target) × `dmg.mul` — 元素伤害 takes 元素脆弱 (`elementalTakenMul`) alone, not `dmgTakenMul` (脆弱 = "受到的物理、法术、真实伤害提升"). Then **限伤**: on a leader (`isBoss` — the 10 bossInfoDict enemyIds and their mirrored copies; never parts, drones or escorts) in a `'boss'` / `'hidden'` battle a hit with `ceil(final) ≥ 300000` is cancelled — no HP or pool loss, no credit, no damage number, no `damaged` / kill (`shared/constants.js BOSS_HIT_LIMIT`, `damage.js leaderHitCancelled`, after every multiplier and the hit-step blocks, before HP shields; `loseHp` too — a part's 传递, a drone's death; the client's `MAX_BATTLE_DAMAGE`, §20.12). Then shields absorb; dodge checks for phys/arts. Element damage fills a gauge (1000; enemy leaders 2000) × `elemTakenMul` × max(0.05, 1 − 损伤抵抗/100), 损伤抵抗 = data `epResistance` (PRTS 元素; the 5 % floor of DMG_e, PRTS 游戏数据基础). An element fill on a target with no HP left is refused (damage.js `hasHp`: no burst on a corpse). A full gauge bursts with the side-specific official effects, as **无来源** damage (no damage-dealt multiplier or penetration of the unit that filled the gauge, which keeps the kill and the stats; §19.5) (sim/constants.js `ELEMENT`; operators: 灼燃 1200 arts + RES −20, 神经 stun 10 s then 1000 true, 凋亡 15 s of 阻回 (`noSp`: no SP gain of any kind) + 静默 with −1 SP/s and 100 arts/s, 侵蚀 DEF −100 then 800 phys; enemies: the "·我方" terms). Every burst starts a **爆发冷却** = its duration (灼燃 / 神经 / 侵蚀 10 s — enemies' 侵蚀 8 s — 凋亡 15 s) during which NO element of the unit fills or can be recovered; when it ends every gauge of the unit resets (§18.3).
- **Leak**: an enemy reaching its goal is removed, counted as leaked for its field owner (`sourcePlayerId` for unite). LP accounting is done by the match, not the battle.
@@ -1160,7 +1160,7 @@ Eighteen workstreams (WA–WR, each on its own branch from master 7f13473) were
- **#8 seen**: the 怨恨的余烬 could not be killed. **Cause**: the ember stood still, 隐匿 AND unblockable, so no operator could ever target it (a real R3 round on 战场#01: 0 of 7 killed, 7 leaked).
- **#8 official** (PRTS 深池逐火战士 / 护卫 天赋; `Revive[Trigger]` 5 / 10, `interval` 10; PRTS 特殊机制 §重生 / §特殊生命值机制; 作战机制 §隐匿): every knock-out → a 1 s 重生 (immobile, 无敌 + 无法阻挡 + 失衡免疫); then the ember — max HP 5 (护卫's 火灰 10), 1 per damage instance, no attack, 隐匿 + 缴械; not killed within 10 s → the warrior with full HP. A blocked enemy loses 隐匿.
- **#8 now**: the ember walks on; blocked, it is targetable and drawn solid (the stealth bit only while its 隐匿 is on). The same round: 7 / 7. An operator's radius area damage reaches it only while blocked or revealed (§21.19).
- **#8 now**: the ember walks on; blocked, it is targetable and drawn solid (the stealth bit only while its 隐匿 is on). The same round: 7 / 7. An operator's radius area damage reaches it only while blocked or revealed (§21.19) — and, since 0.1.2, for 3 s after a block ends (§22.8).
- **#5 seen**: 转译基底 played a "transform" clip and vanished. **Causes**: no clip sets for its forms (it stayed on A_* and died on B_Die, the 寻仇者's death clip); its first form took damage; and the client dropped the change fx in catch-up frames, hidden tabs, the pre-entry buffer and the render engine's stale-event drop (b.snap carries no form).
- **#5 official** (PRTS 转译基底·α 天赋; talents Passive / Mode_*_Passive / Mode_Fuchou_Anger): no attack; every damage instance cancelled; 失衡免疫 + control immunities; the 4th physical hit → 寻仇者, the 4th arts hit → 特战术师 (2 targets), a block → 幽灵 (unblockable, no attack — PRTS 幽灵 "不进行普通攻击,无法被阻挡"); once, 2 s.
- **#5 now**: A_Die_B / _C / _D, then B_* / D_* / C_*; no HP lost before the change (an HP loss stops at 1); the armed forms attack through `ai.js enemyAttack` (`e.profile`).
@@ -1168,7 +1168,7 @@ Eighteen workstreams (WA–WR, each on its own branch from master 7f13473) were
- **重生** (PRTS §重生 "清空自身身上除白名单外所有Buff"): `rebirthCleanse` removes ally-sourced buffs and source-less statuses, keeping `persist` talents, enemy-given buffs (锏's 抵抗) and source-less field state; its skills restart from their initial cooldown when it ends (§21.19).
- **Forms on the client**: `setForm` keeps `e.form` (UnitInfo `form`) and puts `form` on its fx (`fxForm`, the 'phase' fx too, so barrier / charge phases are no forms). No client stage drops a form fx: runner catch-up frames (`keepsState`), the hidden-tab backlog of the battle on screen (`hold`; past `HELD_MAX` superseded status / skill toggles are compacted away, `compactHeld`, and only a backlog still past it re-enters the view from the field meta), a battle that ended while hidden (delivered when the tab is shown; every batch keeps the game time it was drained at, `EV_SLICE`), the game screen's pre-entry buffer (`keepEarly`, also while a re-sent field meta re-enters the field on screen) and the render engine's queue (`render/interp.js isCosmeticEvent`: a form fx is never dropped as stale nor shed; one handed out late switches the model without its telegraph or a change clip that would already have ended, its closing clip shortened — the last three since the integration, §21.19). Late views: `renderInfo` keeps `form`. Closing clips are timed from the fx `dur` and keep the current form's death clip until the next form's fx.
- **Audit**: 锏 / 扎罗 had #5's look (fixed: their 重生 clips); 杰斯顿 → C2_*; 守墓石像 per PRTS (statue, then flight, §21.9); 再生: 1 s 重生, then shields within 1.8. No unit stays untargetable. “余音” stays unmapped (no source says which form is A).
- [ASSUMED]: the translator is immobile during its change and stays damage-cancelled; the HP-loss floor 1; form letters B / C / D; the 特战术师 form also hits flyers (the standalone PRTS 特战术师 "不会攻击飞行单位" may carry over — one line, `canTarget: (u) => !u.isFlying`); the ember's 10 s counts from the end of the 重生; 再生's 傀儡 walks; the statue does not attack (clip Sleep); 隐匿 returns at once after a block (PRTS: 3 s); the 重生 whitelist.
- [ASSUMED]: the translator is immobile during its change and stays damage-cancelled; the HP-loss floor 1; form letters B / C / D; the 特战术师 form also hits flyers (the standalone PRTS 特战术师 "不会攻击飞行单位" may carry over — one line, `canTarget: (u) => !u.isFlying`); the ember's 10 s counts from the end of the 重生; 再生's 傀儡 walks; the statue does not attack (clip Sleep); the 重生 whitelist. (隐匿 returned at once after a block until 0.1.2 — now 3 s, the knock-out's own release included: §22.8.)
- Tests: `test/sim/feedback1-transform.test.js`, `test/match/feedback1-runner-forms.test.js` (stalls, hidden tabs, a battle ending while hidden, compaction, the runner feeding the render buffer), `test/render/interp.test.js`, `test/render/forms.test.js`, `test/content/enemies_bosses.test.js`; opt-in `test/render/feedback1-forms.browser.test.js` (`RENDER_E2E=1`; blocking 1 s long tasks).
### 21.5 The 鸭爵 strategy's enemies (#7) — `content/enemies.js` (runWhenHit, 圆仔 kits), `content/bands/meta.js duckReplace / duckGlobal`, `Match.startFinalAssault` (event `routes` / `side`), `tools/build-data.mjs bandSwapEnemyKeys`
+16 -9
View File
@@ -302,7 +302,8 @@ locks): a 逐火 knocked out while feared is no unblockable ember — nor one fe
form; fx forms 'reborn' → 'form2'), `statue()` (守墓石像: melee only while blocked; first knock-out → 10 s unblockable,
immobile statue → a flyer with ranged arts attacks that skip flyers; forms 'stone' → 'fly'), `husk()` (talent
Revive[Trigger], every knock-out: 1 s 重生 — 无敌, 无法阻挡, immobile — then a hit-count husk that
walks its route on: the 深池逐火 余烬 / 火灰 are 隐匿 and disarmed, so only a blocked one can be targeted or struck by
walks its route on: the 深池逐火 余烬 / 火灰 are 隐匿 and disarmed, so only a blocked one — or one within 3 s of a block's
end, the warrior's own block that the knock-out releases included (§22.8 [ASSUMED] order) — can be targeted or struck by
operators (radius area damage too: `Battle.foesInRadius`, PRTS 作战机制 §AOE伤害判定 — until 0.1.1 it reached an unblocked
one); 假想敌:再生's 傀儡 is unblockable and, as it begins, shields the other enemies within 1.8; a husk
still standing after `Revive[Trigger].interval` s stands up again with full HP),
@@ -474,8 +475,8 @@ elemTakenMul elementalTakenMul healingDealtMul healingTakenMul spRecoveryMul red
A `rangeExtend` on a `persist` never-expiring buff is **permanent**: it also widens the initial range (§7.1). It widens
a running skill's range too, unless that skill's range ignores 攻击距离 (`targeting.noRangeExtend`; PRTS 数值范围 "根据配置
不同,任何范围都可以受/不受该属性影响" — 信仰搅拌机 S3 "此技能的攻击范围不受“攻击距离”属性影响").
**Flags:** `stun freeze sleep silence disarm stealth invulnerable unblockable levitate fear cold reveal bind noHeal
untargetable blockFly noMove noSp burstLock hidden attract float noDisplace isolated camou liftoff` (`float` = 近地悬浮 (an air
**Flags:** `stun freeze sleep silence disarm stealth stealthOff invulnerable unblockable levitate fear cold reveal bind noHeal
healFree untargetable blockFly noMove noSp burstLock hidden attract float noDisplace isolated camou liftoff` (`float` = 近地悬浮 (an air
unit, `Unit.isFlying`), `liftoff` = an ally's 起飞 (蒂比's skills; gamedata_const ba.liftoff "不阻挡地面敌人且不会被地面敌人攻击,
可以阻挡飞行敌人"): it blocks no ground enemy (`Battle._blockerFor`) and has 对地规避 — no ground enemy (not `isFlying`)
selects it, so no selected damage or status of one lands on it (`targeting.js evadesGround`), while what selects nobody
@@ -486,7 +487,12 @@ with no 对空 check, since it stays a ground unit on its tile (`unit.ground` un
ability selects it — no heal, buff, aura or talent pick from another ally (`battle.allySelectable` / `alliesFor`,
`alliesInGrid`; PRTS 选择器 可选判定), enemies still target it; a summon's tokens.json `abnormal` 'isolated' also sets
`noHeal`, 'healFree' (禁疗) sets `noHeal` — Battle._setupUnit, DATA.md tokens.json; 禁疗 keeps heals off only — an HP-regen
attribute such as 安洁莉娜's 兼职工作 still applies, PRTS 异常效果), `camou` = 迷彩 (below)). `taunt: true` as a flag counts
attribute such as 安洁莉娜's 兼职工作 still applies, PRTS 异常效果; no self-heal reaches those summons but 伺夜 S2's 恢复 of
its 禁疗 狼群, which the skill text grants), flag `healFree` = 禁疗 that also stops the unit's own heals (`noHeal` stops
only other units' heals and heal picks): 史尔特尔's 余烬 sets both, shown as the status 'healFree' — only an HP-regen
attribute (`heal` opts `regen`) and a heal that "无视禁疗" (`ignoreHealFree`: her S3's start heal) reach her (PRTS 异常效果
HEAL_FREE "受到的治疗量变为0"; DESIGN §22.7), `stealthOff` = an enemy 隐匿 source switched off after a block
(`stealthOff:<source buff key>`, below), `camou` = 迷彩 (below)). `taunt: true` as a flag counts
as +1 taunt level (DESIGN §5.3).
**Statuses** — `battle.applyStatus(target, key, { duration, source, value, force, refresh, point })` (returns true if
@@ -530,7 +536,7 @@ of coverage per 3 s), kept because the current wording no longer says so (feedba
| `tremble` (战栗) | 被阻挡后无法进行普通攻击: no normal attack **while blocked** (abilities still fire) | – |
| `palsy` (麻痹) | each stack cancels one enemy normal attack (max 3, lasts until consumed); refused by 麻痹免疫 (data `palsyImmune`) | stacks, default 1 |
| `disarm` | no normal attacks | – |
| `stealth` / `reveal` | 隐匿: untargetable unless blocked (an ally: only the enemy it blocks attacks it) / cancels stealth. An enemy's b.snap stealth bit is set only while its 隐匿 is on (not blocked, not revealed: drawn solid otherwise); an operator's radius area damage (`foesInRadius`) skips it too (PRTS 作战机制 §AOE伤害判定 "对攻击范围内的每个可以被选中的敌人进行判定"; until 0.1.1 the splash still hit it) | – |
| `stealth` / `reveal` | 隐匿: untargetable unless blocked (an ally: only the enemy it blocks attacks it) / cancels stealth. An enemy's 隐匿 also stays off after a block: each block's end (`Battle._stealthSwitch`, every release path) switches each of its 隐匿 sources off for `STEALTH_RESTORE` (3) s — PRTS 作战机制 §隐匿 "不被阻挡的3秒后重新进入隐匿" — or the source's own "(解除阻挡N秒后恢复)" (buff `data.stealthRestore`: 0 s for 业余竞演者, 节日爵士乐手, 假想敌:骨刺, 流泪小子, 访问团强攻冠军 and 清明's veil, 1 s for the 家族灭迹人); a new block inside it lifts it again and its end restarts the window; our operators' 隐匿 / 迷彩 never lift by blocking (DESIGN §22.8). `targeting.js enemyStealthed` is the one test: the b.snap stealth bit is set only while its 隐匿 is on (drawn solid otherwise); an operator's radius area damage (`foesInRadius`) skips it too (PRTS 作战机制 §AOE伤害判定 "对攻击范围内的每个可以被选中的敌人进行判定"; until 0.1.1 the splash still hit it) | – |
| `camou` (迷彩) | an ally's camouflage (ba.camou "不阻挡时不成为敌方普通攻击的目标"): like `stealth` for enemy targeting (only the enemy it blocks attacks it) and on screen (b.snap stealth bit, `snapshot.js flagsOf`), but not 隐匿 for 隐匿-conditions (叙拉古, 家族徽章) and under its own buff keys. 忍冬 S3 (key `vulpis:camou`, until her next cast), 寒芒克洛丝 S1 | – |
| `invulnerable` | ignores damage | – |
| `levitate` (浮空) | stun + unblockable (unblocks enemies) + 失衡免疫 (`noDisplace`); an air unit meanwhile (`isFlying`: melee cannot hit it); **half duration on units with (current) massLevel > 3**; refused on data flyers (`motion` FLY) and units already levitated (PRTS 异常效果 "若单位数据上为飞行单位…或是持有浮空异常则Buff取消") — a 近地悬浮 enemy is WALK in its data, so it can be levitated; 浮空 is not one of the 近地悬浮 enemies' drop triggers | – |
@@ -624,8 +630,9 @@ draws nothing; research 11) → shields → HP loss
(boss units: routed to `sharedBoss.damage(playerId, amount)`; a pool left under 1 HP is emptied) → if HP ≤ 0: **`fatal`** (`ctx.prevented = true` keeps the
unit at ≥ 1 HP) → **`damaged`** → SP-on-hurt / TAKE_DAMAGE → `kill` + `death`.
`battle.heal(source, target, amount, { overheal=false, self, silent })`: no-op on `noHeal` targets (unless self — 禁疗 /
孤立 summons carry the flag, §3);
`battle.heal(source, target, amount, { overheal=false, self, silent, regen, ignoreHealFree })`: no-op on `noHeal` targets
(unless self — 禁疗 / 孤立 summons carry the flag, §3) and on `healFree` ones, self included (史尔特尔's 余烬), unless `regen`
(an HP-regen attribute tick) or `ignoreHealFree` (a heal that "无视禁疗");
× source `healingDealtMul` × target `healingTakenMul`; **`heal`** hook (mutable amount); capped at max HP; `overheal`
turns the excess into an `overheal` shield. `battle.loseHp(target, amount, { source, from, tags, silent, sourceless })` = HP
loss ignoring DEF/RES/shields/dodge (流失); `sourceless: true` makes it 无来源 ("受到等量的无来源生命流失": hooks see no source,
@@ -709,7 +716,7 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged
| `downOn(r, c, except?)`, `restTile(u)` | the knocked-out operator lying on a tile (no ally deploys / moves there: `_deploy`, `spawnDevice`, `relocate`); the tile a withdrawn ally comes back on — its `body` tile when knocked out, else its home (§1) |
| `addProjectile({ from, target | to:{x,y}, speed, onHit(ctx), visual, source, hitDead })` | homing; fizzles if the target dies unless `hitDead` |
| `allySelectable(ally, by)`, `alliesFor(by, ownerId?)` | may an ability of ally `by` select `ally` — never a 孤立 unit (炎佑, 从不混淆的方向) but `by` itself (PRTS 选择器: "若掩码中孤立为1,且选择器的阵营与目标为友好关系,则不可选中"); `allies()` without the 孤立 ones — content picks ally targets (buffs, auras, 全场 talents, heal picks) through these, `alliesInGrid` too |
| `unitsInGrid(unit, grid, {side, extend})`, `alliesInGrid(unit)`, `enemiesInRadius(x, y, r, centre?)`, `foesInRadius(x, y, r, centre?)`, `alliesInRadius(x, y, r, ownerId?)` | grid offsets are relative to facing RIGHT, rotated by `unit.dir`; enemies by their body (§2 hit areas: a huge enemy on every tile it occupies / within `r` of its rectangle; `centre` = splash around a target, a 中点判定 by position; `foesInRadius` = the enemies an operator's area effect can select — no untargetable, no unblocked unrevealed 隐匿 enemy, PRTS 作战机制 §AOE伤害判定 — while enemy-side auras / heals and collisions keep `enemiesInRadius`) |
| `unitsInGrid(unit, grid, {side, extend})`, `alliesInGrid(unit)`, `enemiesInRadius(x, y, r, centre?)`, `foesInRadius(x, y, r, centre?)`, `alliesInRadius(x, y, r, ownerId?)` | grid offsets are relative to facing RIGHT, rotated by `unit.dir`; enemies by their body (§2 hit areas: a huge enemy on every tile it occupies / within `r` of its rectangle; `centre` = splash around a target, a 中点判定 by position; `foesInRadius` = the enemies an operator's area effect can select — no untargetable enemy, none whose 隐匿 is on (`targeting.js enemyStealthed`: not blocked, not revealed, not within 3 s of its last block), PRTS 作战机制 §AOE伤害判定 — while enemy-side auras / heals and collisions keep `enemiesInRadius`) |
| `enemiesInKeys(keys, attacker, profile)`, `blockedTargets(unit, profile)` | targetable enemies whose body is on the tiles (a huge one listed once); the enemies a unit blocks — always selectable by it, a ranged operator on a melee tile included (§1.2 Blocking) |
| `allies(ownerId?)`, `aliveEnemies()`, `unitAt(r, c)`, `unitById(id)`, `tileInfo(r, c)`, `lowestHpAllyInRange(unit)` | |
| `addLayers(playerId, bondId, n, reason, {source})`, `addCoins(playerId, n)` | layers are a no-op when `flags.layerGainsEnabled` is false (unite/boss); a gain adds at most the room left under `BOND_LAYER_CAP` (999, shared/constants.js `layerGainRoom`: the client's `AddBondCount` min(L + n, 999)) on the live copy — or, without one, on the battle's own gains — and returns what it added (0 at the cap: no hook, no event) |
@@ -1121,7 +1128,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te
- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex?, moduleId?, items? }` (`skillIndex`: an ally's equipped skill, DESIGN §16; `form`: an enemy's current model form — `content/enemies.js setForm`: 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop, 转译基底·α's forms … — so a view built mid-battle from `fieldMeta()` starts on that clip set; `items`: an ally operator's equipped item ids — a 变形同构体 wearer is a member of the bond it grants on the client too, the bond popup and the detail card's chips)
(`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping)
(`spine`/`avatar` are asset ids from data).
- flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8 — 隐匿 (an enemy's only while not blocked / revealed) or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512);
- flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8 — 隐匿 (an enemy's only while not blocked / revealed and not within 3 s of a block's end) or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512);
anim: ANIM codes (idle 0, move 1, attack 2, skill 3, die 4, stun 5, deploy 6).
---
+4 -2
View File
@@ -31,7 +31,9 @@ export const MUL_KEYS = Object.freeze([
/**
* Boolean flag keys (OR). `taunt` is also accepted as a numeric mod. `liftoff` = 起飞 of an ally (蒂比's skills): blocks
* no ground enemy (Battle._blockerFor), 对地规避 against ground enemies (targeting.js evadesGround); it stays a ground
* unit on its tile (`unit.ground` unchanged).
* unit on its tile (`unit.ground` unchanged). `noHeal` = no heal from another unit (and no heal pick of one); `healFree`
* = 禁疗 that also stops the unit's own heals (damage.js heal: only an HP-regen attribute and a heal that "无视禁疗" pass —
* 史尔特尔's 余烬, which sets both). `stealthOff` = an enemy 隐匿 source switched off after a block (Battle._stealthSwitch).
*/
export const FLAG_KEYS = Object.freeze([
'stun', 'freeze', 'sleep', 'silence', 'disarm', 'stealth', 'invulnerable', 'unblockable', 'levitate', 'fear',
@@ -39,7 +41,7 @@ export const FLAG_KEYS = Object.freeze([
'noBlock', 'tremble', 'hitCount', 'hitCountArts', 'attract', 'float', 'noDisplace', 'isolated', 'camou', 'liftoff',
// 自缚 (the unit's own immobility: 守墓石像's 转换模式, the 自缚 leaders) beside its `noMove` — 束缚 sets noMove too, and
// only 自缚 makes a unit "不视为可达目标" for 余 S2's teleport (PRTS 余 S2 备注)
'selfBound',
'selfBound', 'healFree', 'stealthOff',
]);
/**
+360
View File
@@ -0,0 +1,360 @@
// test/sim/feedback2-surtr-stealth.test.js — GitHub issues after 0.1.1 (DESIGN §22.7, §22.8):
// #52 史尔特尔 余烬 (PRTS 天赋备注 "触发本天赋后,获得禁疗与不死"; 异常效果 禁疗 HEAL_FREE "无法成为治疗类能力的目标,且受到
// 的治疗量变为0", an HP-regen attribute excepted; 技能3 备注 "(无视禁疗)"): v0.1.1 kept healing her during the 8 s —
// medics picked her as the lowest HP ratio and wasted their heals on a unit that leaves anyway.
// #43 follow-up — PRTS 作战机制 §隐匿 "对于绝大部分可隐匿的敌人而言,在被我方单位阻挡后会解除隐匿,不被阻挡的3秒后重新进入隐匿"
// (and the enemy pages' "(解除阻挡N秒后恢复)"): v0.1.1 hid an enemy again 1 tick after its block ended. Our operators'
// 隐匿 is never lifted by blocking. The 深池逐火 embers: the 重生's 无法阻挡 ends the warrior's block at the knock-out,
// so the ember stays revealed until 3 s after it (players after 0.1.1: "the stealth monster revives forever").
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import * as enemiesMod from '../../server/sim/content/enemies.js';
import { canTargetEnemy, canTargetAlly, enemyStealthed, stealthOffKey } from '../../server/sim/targeting.js';
import { flagsOf } from '../../server/sim/snapshot.js';
import { STEALTH_RESTORE } from '../../server/sim/constants.js';
import { UF } from '../../shared/constants.js';
import { statusIconKey } from '../../public/js/render/style.js';
import { STATUS_KEYS } from '../../public/js/render/textures.js';
const { HUSK_REBIRTH } = enemiesMod;
const clean = (h) => { assert.deepEqual(h.b.errors.map((e) => `${e.label}: ${e.message}`), []); checkInvariants(h.b); };
// ---------------------------------------------------------------------------------------------------------------
// #52 史尔特尔 余烬: 禁疗 + 不死 until the forced exit
const SURTR = 'chess_char_5_07_a', MEDIC = 'chess_char_2_02_a' /* 赫默 */, TANK = 'chess_char_1_04_a' /* 深巡 */;
const DUMMY = enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, atk: 0 });
const lethal = (h, u) => h.b.dealDamage(null, u, { amount: 1e9, type: 'true', canDodge: false });
const waitOf = (u) => (u.def.raw.talents || []).find((t) => t.index === 1).bb['surtr_t_2[withdraw].interval'];
describe('#52 史尔特尔 余烬: 禁疗 from the lethal blow until she leaves', () => {
test('a medic beside her heals the other injured operator instead — 0 heals reach her, she is never its target, she still leaves at 8 s; the 禁疗 status is shown', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: DUMMY } }, autoFinish: false, timeLimit: 200, seed: 2,
units: [
{ chessId: SURTR, row: 9, col: 5, dir: 'RIGHT', skillIndex: 0, carryState: { sp: 0 } },
{ chessId: MEDIC, row: 9, col: 4, dir: 'RIGHT' }, // her range covers (9,5) and (10,5)
{ chessId: TANK, row: 10, col: 5, dir: 'RIGHT' },
],
enemies: [{ key: 'enemy_dummy', pos: [9, 9] }],
});
const s = h.unit(SURTR), medic = h.unit(MEDIC), tank = h.unit(TANK);
const wait = waitOf(s);
assert.equal(wait, 8, 'data: surtr_t_2[withdraw].interval');
h.run(1);
const healed = new Map();
const picked = new Set();
h.b.on('heal', (c) => { if (c.amount > 0) healed.set(c.target, (healed.get(c.target) ?? 0) + c.amount); }, { priority: -9999 });
h.b.on('attack', (c) => { if (c.attacker === medic) for (const t of c.targets) picked.add(t); }, { priority: -9999 });
tank.hp = tank.s.maxHp * 0.5;
lethal(h, s);
const t0 = h.b.time;
assert.ok(s.alive && s.deployed && s.hp >= 1 && s.hp < 2, '余烬: HP held at 1');
assert.ok(s.s.flags.noHeal && s.s.flags.healFree, '禁疗: no heal pick, no heal at all');
const on = h.eventsOf('status').filter((ev) => ev[1] === s.id && ev[2] === 'healFree');
assert.deepEqual(on.map((ev) => ev[3]), [1], 'the status 禁疗 is reported to the clients');
assert.equal(statusIconKey('healFree'), 'healFree');
assert.ok(STATUS_KEYS.includes('healFree'), 'and drawn in the status atlas');
// 不死: a second lethal blow inside the window changes nothing (and does not restart the 8 s)
h.run(3);
lethal(h, s);
assert.ok(s.alive && s.hp >= 1);
h.run(wait - 3 - 0.2);
assert.ok(s.alive && s.deployed, 'still on the field just before 8 s');
assert.ok(s.hp < 2, 'nobody healed her');
assert.equal(healed.get(s) ?? 0, 0, '0 heals to her');
assert.ok(!picked.has(s), 'the medic never picks her');
assert.ok((healed.get(tank) ?? 0) > 0 && picked.has(tank), 'the medic heals the other injured operator instead');
h.run(0.4);
assert.equal(s.deployed, false, 'she leaves at 8 s');
assert.equal(s.removeReason, 'retreat', '"强制退出战场视为撤回干员"');
assert.ok(h.b.time - t0 <= wait + 0.25);
const ev = h.eventsOf('status').filter((e) => e[1] === s.id && e[2] === 'healFree').map((e) => e[3]);
assert.deepEqual(ev, [1, 0], 'the status ends with her stay');
clean(h);
});
test('her own heals stop too (休眠子裔), an HP-regen attribute does not, and S3 黄昏\'s start heal ignores 禁疗 (PRTS 技能3 备注) — she still leaves at 8 s', () => {
const mk = (o = {}) => makeBattle({
defs: { enemies: { enemy_dummy: DUMMY } }, autoFinish: false, timeLimit: 200, seed: 2,
units: [{ chessId: SURTR, row: 9, col: 5, dir: 'RIGHT', carryState: { sp: 0 }, ...o }],
enemies: [{ key: 'enemy_dummy', pos: [9, 6] }],
});
// control: 休眠子裔 ("每攻击1个目标,回复自身2%最大生命值") heals her while she is not under 禁疗
const h0 = mk({ skillIndex: 0, items: ['chess_item_4_06_e_a'] });
const c = h0.unit(SURTR);
h0.run(1);
c.hp = c.s.maxHp * 0.5;
h0.run(3);
assert.ok(c.hp > c.s.maxHp * 0.5 + 1, 'control: the item heals her on attack');
// under 禁疗 it gives nothing; an HP-regen attribute still works
const h = mk({ skillIndex: 0, items: ['chess_item_4_06_e_a'] });
const s = h.unit(SURTR);
h.run(1);
lethal(h, s);
const atk0 = s.stats.attacks;
h.run(3);
assert.ok(s.stats.attacks > atk0, 'she keeps attacking');
assert.ok(s.hp < 2, '休眠子裔: no heal under 禁疗');
h.b.addBuff(s, { key: 'test:regen', mods: { hpRegen: 100 } });
h.run(2);
assert.ok(s.hp > 150, `生命回复速度 is no 治疗类能力: regen still applies (${s.hp.toFixed(0)})`);
// S3 黄昏 starting inside the window: "立即恢复所有生命(无视禁疗)"
const h3 = mk();
const u = h3.unit(SURTR);
assert.equal(u.skill.id, 'skchr_surtr_3');
h3.run(1);
lethal(h3, u);
const t0 = h3.b.time;
h3.run(1);
assert.ok(u.hp < 2 && !u.skill.active);
u.skill.gainSp(1000);
assert.ok(h3.runUntil(() => u.skill.active, 3), 'S3 starts');
assert.ok(u.hp > u.s.maxHp - 50, 'healed to full in spite of 禁疗');
h3.runUntil(() => !u.deployed, waitOf(u) + 1);
assert.equal(u.removeReason, 'retreat');
assert.ok(Math.abs(h3.b.time - t0 - waitOf(u)) < 0.1, 'and she still leaves 8 s after the lethal blow');
clean(h); clean(h3);
});
});
// ---------------------------------------------------------------------------------------------------------------
// #43 follow-up: an enemy's 隐匿 returns 3 s after its block ends
const WALL = (id, block = 3) => chessRec({ id, profession: 'TANK', stats: { atk: 0, maxHp: 1e7, def: 0, res: 0, blockCnt: block }, rangeGrid: [[0, 0]], skill: null });
const BIG = [];
for (let dr = -4; dr <= 4; dr++) for (let dc = -12; dc <= 12; dc++) BIG.push([dr, dc]);
const CHESS = {
t_wall: WALL('t_wall'), t_wall1: WALL('t_wall1', 1),
t_gun: chessRec({ id: 't_gun', profession: 'SNIPER', projectile: 'none', stats: { atk: 50, maxHp: 1e7, bat: 1, blockCnt: 0 }, rangeGrid: BIG, skill: null }),
};
const NOATK = () => ({ trait: { noAttack: true } });
function arena(o = {}) {
const { kits = {}, ...rest } = o;
return makeBattle({
content: 'generic', extraContent: [enemiesMod], seed: 7, autoFinish: false, timeLimit: 600,
defs: { chess: CHESS }, kits: { t_wall: NOATK, t_wall1: NOATK, ...kits }, ...rest,
});
}
/** A pinned enemy (speed ×0) at a tile. */
const put = (h, key, pos, o = {}) => h.spawn(key, { pos, routeIndex: 0, mods: { speedMul: 0, ...(o.mods || {}) } });
const anyGun = { canHitFly: false };
/** Is `e` hidden by its 隐匿 for every consumer: ally targeting, operator splash and the b.snap stealth bit. */
function hidden(h, e) {
const st = enemyStealthed(e);
assert.equal(!canTargetEnemy(null, e, anyGun), st, 'targeting agrees');
assert.equal(!h.b.foesInRadius(e.x, e.y, 0.3).includes(e), st, 'operator splash agrees (Battle.foesInRadius)');
assert.equal(!!(flagsOf(e) & UF.STEALTH), st, 'the stealth bit agrees');
return st;
}
const stun = (h, u, d) => h.b.applyStatus(u, 'stun', { duration: d, force: true });
describe('#43: a blocked 隐匿 enemy hides again only 3 s after the block ends (PRTS 作战机制 §隐匿)', () => {
test(`潜伏者: blocked = revealed; the blocker lets go ⇒ revealed for STEALTH_RESTORE (${STEALTH_RESTORE}) s — targetable, splashed, drawn solid, shot by a ranged operator — then 隐匿 again`, () => {
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }, { chessId: 't_gun', row: 11, col: 3 }] });
h.step();
const wall = h.unit('t_wall');
const e = put(h, 'enemy_1009_lurker', [9, 5]);
h.step(2);
assert.ok(e.blockedBy === wall && !hidden(h, e), 'blocked: revealed');
stun(h, wall, 30); // a stunned blocker lets go and blocks nobody
assert.ok(!e.blockedBy);
const t0 = h.b.time;
assert.ok(e.findBuff(stealthOffKey('ab:stealth')), 'its 隐匿 source is switched off');
h.run(1);
assert.equal(hidden(h, e), false, '+1 s: still revealed');
const taken = e.stats.taken;
h.run(STEALTH_RESTORE - 1 - 0.1);
assert.equal(hidden(h, e), false, `+${STEALTH_RESTORE - 0.1} s: still revealed`);
assert.ok(e.stats.taken > taken, 'the ranged operator shoots it meanwhile');
h.run(0.2);
assert.equal(hidden(h, e), true, `+${STEALTH_RESTORE + 0.1} s: 隐匿 again`);
assert.ok(Math.abs(h.b.time - t0 - STEALTH_RESTORE - 0.1) < 0.05);
const after = e.stats.taken;
h.run(2);
assert.equal(e.stats.taken, after, 'no ranged hit once it hides');
clean(h);
});
test('a new block inside the window lifts it again, and the window restarts when that block ends (the first one does not cut it short)', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
h.step();
const wall = h.unit('t_wall');
const e = put(h, 'enemy_1009_lurker', [9, 5]);
h.step(2);
stun(h, wall, 1.5); // let go at t0 …
const t0 = h.b.time;
h.run(1.6);
assert.equal(e.blockedBy, wall, '… blocked again at +1.6 s (inside the 3 s)');
assert.equal(hidden(h, e), false);
h.run(2.5 - 1.6);
stun(h, wall, 30); // … and let go again at +2.5 s
const t1 = h.b.time;
h.run(t0 + 4 - h.b.time);
assert.equal(hidden(h, e), false, '+4 s: revealed (the first window would have ended at +3 s)');
h.run(t1 + STEALTH_RESTORE - 0.1 - h.b.time);
assert.equal(hidden(h, e), false, `${STEALTH_RESTORE - 0.1} s after the second block: revealed`);
h.run(0.2);
assert.equal(hidden(h, e), true, `${STEALTH_RESTORE + 0.1} s after it: 隐匿`);
clean(h);
});
test('a release through the block count (ai.js enforceBlockCapacity) and a 反隐 that ends: the same switch; a 反隐 ending inside the window keeps it revealed', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
h.step();
const wall = h.unit('t_wall');
const e = put(h, 'enemy_1009_lurker', [9, 5]);
h.step(2);
h.b.addBuff(e, { key: 'test:reveal', duration: 1, flags: { reveal: true } });
h.b.addBuff(wall, { key: 'test:noblock', mods: { blockCnt: -99 } });
h.step(1);
assert.ok(!e.blockedBy, 'over capacity: released');
h.run(1.5);
assert.ok(!e.s.flags.reveal && hidden(h, e) === false, 'the 反隐 is over, the block switch still holds it');
h.run(STEALTH_RESTORE - 1.5);
assert.equal(hidden(h, e), true);
// a plain 反隐 (no block): hidden again as soon as it ends
h.b.addBuff(e, { key: 'test:reveal', duration: 1, flags: { reveal: true } });
assert.equal(hidden(h, e), false);
h.run(1.05);
assert.equal(hidden(h, e), true, '反隐 alone: no 3 s');
clean(h);
});
test('"(解除阻挡N秒后恢复)" on the enemy pages: 0 s (业余竞演者, 清明\'s veil …), 1 s (家族灭迹人); a veil over its own 隐匿 hides it at once, its own switch takes over when the veil ends', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }, { chessId: 't_wall', row: 11, col: 5 }, { chessId: 't_wall', row: 12, col: 7 }] });
h.step();
const [w1, w2, w3] = h.allies();
// (流泪小子 has 0 s too, but its first hit stuns its blocker, which lets it go at once)
const thief = put(h, 'enemy_10031_cnvsld', [9, 5]);
const killer = put(h, 'enemy_1283_sgkill', [11, 5]);
const lurker = put(h, 'enemy_1009_lurker', [12, 7]);
h.step(2);
assert.ok(thief.blockedBy && killer.blockedBy && lurker.blockedBy);
assert.equal(thief.findBuff('ab:stealth').data.stealthRestore, 0);
assert.equal(killer.findBuff('ab:stealth').data.stealthRestore, 1);
assert.equal(lurker.findBuff('ab:stealth').data.stealthRestore, undefined, 'a plain 隐匿: the general 3 s');
// the 清明 veil the same way kitInvisShield gives it, over the lurker's own 隐匿
h.b.addBuff(lurker, { key: 'ab:veiled', duration: 0.5, flags: { stealth: true }, visible: true, data: { stealthRestore: 0 } });
for (const w of [w1, w2, w3]) stun(h, w, 30);
h.step();
assert.equal(hidden(h, thief), true, '业余竞演者: 0 s');
assert.equal(hidden(h, lurker), true, 'veiled: 0 s');
assert.equal(hidden(h, killer), false);
h.run(0.85);
assert.equal(hidden(h, killer), false, '家族灭迹人: still revealed at +0.9 s');
assert.equal(hidden(h, lurker), false, 'the veil is over and its own 隐匿 is still switched off');
h.run(0.25);
assert.equal(hidden(h, killer), true, '家族灭迹人: 隐匿 at +1.1 s');
h.run(STEALTH_RESTORE - 1.1 + 0.1);
assert.equal(hidden(h, lurker), true, 'its own switch: 3 s after the block');
clean(h);
});
test('清明 / 堂皇 give their veil a 0 s switch; every other stealthy enemy here keeps the general 3 s unless its PRTS page says otherwise', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 12, col: 3 }] });
h.step();
const lamp = put(h, 'enemy_1209_sfden', [10, 7]);
const o = put(h, 'enemy_1007_slime', [10, 8]);
assert.ok(h.runUntil(() => !!o.findBuff('ab:veiled'), 30), 'the veil comes');
assert.equal(o.findBuff('ab:veiled').data.stealthRestore, 0);
assert.ok(lamp.alive);
const ZERO = ['enemy_10031_cnvsld', 'enemy_10034_cnvsax', 'enemy_9008_acbunn', 'enemy_2034_sythef', 'enemy_2034_sythef_2', 'enemy_1389_winbab_2'];
const ONE = ['enemy_1283_sgkill', 'enemy_1283_sgkill_2'];
const PLAIN = ['enemy_1009_lurker', 'enemy_1019_jshoot', 'enemy_1019_jshoot_2', 'enemy_1023_jmage', 'enemy_1299_ymkilr', 'enemy_1299_ymkilr_2', 'enemy_1404_msnip', 'enemy_10042_prtrop', 'enemy_10042_prtrop_2', 'enemy_1175_dushdo_2'];
for (const [keys, n] of [[ZERO, 0], [ONE, 1], [PLAIN, undefined]]) {
for (const k of keys) assert.equal(put(h, k, [10, 9]).findBuff('ab:stealth').data.stealthRestore, n, k);
}
});
test('our operators: 隐匿 is never lifted by blocking, and its block ending switches nothing (PRTS "我方干员并不会因为阻挡而解除隐匿")', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
h.step();
const wall = h.unit('t_wall');
h.b.applyStatus(wall, 'stealth', { duration: 99 });
const e = put(h, 'enemy_1007_slime', [9, 5]);
h.step(2);
assert.ok(e.blockedBy === wall && flagsOf(wall) & UF.STEALTH, 'blocking and still 隐匿');
assert.ok(canTargetAlly(e, wall, true), 'the enemy it blocks attacks it');
h.b.releaseBlocked(wall);
assert.ok(!wall.buffs.some((b) => b.key.startsWith('stealthOff')) && !e.buffs.some((b) => b.key.startsWith('stealthOff')));
assert.ok(flagsOf(wall) & UF.STEALTH);
clean(h);
});
test('山海众头目: its 强击标记 comes with its 隐匿 ("该隐匿每次生效后自身获得强击标记") — a new block inside the 3 s gives no second strong hit, one after it hid again does', () => {
const key = 'enemy_1299_ymkilr';
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }], captureNoisy: true, hooks: ['damaged'] });
h.step();
const wall = h.unit('t_wall');
const e = put(h, key, [9, 5]);
const scale = e.mem.ab.sk.InvisibleCombat.bb.atk_scale;
const hits = () => h.hooksOf('damaged').filter((c) => c.source === e && c.dmg.isAttack).map((c) => c.amount);
assert.ok(h.runUntil(() => hits().length >= 1, 20));
assert.ok(Math.abs(hits()[0] - e.s.atk * scale) < 1e-6, 'the first blocked attack: ×atk_scale (the mark of its spawn)');
stun(h, wall, 1); // let go, blocked again at +1 s (inside the 3 s)
h.run(1.2);
assert.ok(e.blockedBy === wall);
const n1 = hits().length;
assert.ok(h.runUntil(() => hits().length > n1, 20));
assert.ok(Math.abs(hits()[n1] - e.s.atk) < 1e-6, 'never hidden in between: a plain hit');
stun(h, wall, STEALTH_RESTORE + 1); // let go long enough to hide again
h.run(STEALTH_RESTORE + 0.5);
assert.equal(enemyStealthed(e), true);
const n2 = hits().length;
assert.ok(h.runUntil(() => hits().length > n2, 20));
assert.ok(e.blockedBy === wall);
assert.ok(Math.abs(hits()[n2] - e.s.atk * scale) < 1e-6, 'it hid again: the next blocked attack is ×atk_scale');
clean(h);
});
});
// ---------------------------------------------------------------------------------------------------------------
// the 深池逐火 embers: the warrior's block ends at the knock-out (the 重生's 无法阻挡)
describe('深池逐火: an ember knocked out while blocked stays revealed until 3 s after the knock-out [ASSUMED order]', () => {
for (const key of ['enemy_1288_duskls', 'enemy_1288_duskls_2', 'enemy_1292_duskld']) {
test(`${key}: its blocker takes the next warrior during the 1 s 重生 (无敌, untargetable) — the ember is then targetable by ranged and operator splash until +${STEALTH_RESTORE} s, then 隐匿`, () => {
const h = arena({ units: [{ chessId: 't_wall1', row: 9, col: 5 }, { chessId: 't_gun', row: 11, col: 3 }], kits: { t_gun: NOATK } });
h.step();
const wall = h.unit('t_wall1'), gun = h.unit('t_gun');
const a = put(h, key, [9, 5]);
const b = put(h, key, [9, 5.3]); // queued right behind it, in contact
h.step(2);
assert.ok(a.blockedBy === wall && !b.blockedBy, 'block 1: the first warrior is blocked');
h.b.kill(a, gun);
const t0 = h.b.time;
assert.ok(a.alive && a.form === 'husk' && !a.blockedBy, 'the knock-out: 重生, its block ends at once');
assert.ok(a.findBuff(stealthOffKey('ab:ember')), 'the ember\'s 隐匿 starts switched off');
h.step();
assert.equal(b.blockedBy, wall, 'the blocker takes the next warrior');
h.run(HUSK_REBIRTH - 0.2);
assert.ok(a.s.flags.invulnerable && !canTargetEnemy(gun, a, gun.profile), '重生: 无敌 + untargetable');
h.run(0.4);
assert.ok(!a.s.flags.untargetable && !a.blockedBy && a.s.flags.stealth, 'the ember walks, unblocked (the blocker is full)');
assert.equal(hidden(h, a), false, 'revealed after the 重生');
assert.ok(canTargetEnemy(gun, a, gun.profile), 'a ranged operator can target it');
const n = a.hp;
h.b.dealDamage(gun, a, { amount: 1, type: 'phys', tags: ['test'] });
assert.equal(a.hp, n - 1, 'and hit it (one of its hits)');
h.run(t0 + STEALTH_RESTORE - 0.1 - h.b.time);
assert.equal(hidden(h, a), false, `+${STEALTH_RESTORE - 0.1} s: revealed`);
h.run(0.2);
assert.equal(hidden(h, a), true, `+${STEALTH_RESTORE + 0.1} s: 隐匿`);
clean(h);
});
}
test('knocked out while NOT blocked: 隐匿 at once when the 重生 ends (the switch only follows a block)', () => {
const h = arena({ units: [{ chessId: 't_gun', row: 11, col: 3 }], kits: { t_gun: NOATK } });
h.step();
const a = put(h, 'enemy_1288_duskls', [9, 6]);
h.b.kill(a, h.unit('t_gun'));
assert.ok(!a.buffs.some((x) => x.key.startsWith('stealthOff')));
h.run(HUSK_REBIRTH + 0.1);
assert.equal(hidden(h, a), true);
clean(h);
});
});