feedback2: merge WG — 史尔特尔's 余烬 brings 禁疗 (GitHub #52); enemies re-enter 隐匿 3 s after a block ends, a blocked 深池逐火's ember stays visible ~2 s (GitHub #43)
# Conflicts: # public/js/render/style.js # public/js/render/textures.js
This commit is contained in:
commit
e0b51e0310
15 files changed
+534
-47
No files matched your search
+5
-5
@@ -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
@@ -305,7 +305,8 @@ unblockable ember — nor one feared by the knocking-out hit itself (the 重生'
|
||||
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),
|
||||
@@ -480,8 +481,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
|
||||
@@ -492,7 +493,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
|
||||
@@ -536,7 +542,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 | – |
|
||||
@@ -630,8 +636,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,
|
||||
@@ -719,7 +726,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) |
|
||||
@@ -1131,7 +1138,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`: the unit's current model form — `content/enemies.js setForm`: 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop, 转译基底·α's forms …; a 傀儡师 fighting as its 替身 `'doll'` — 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).
|
||||
|
||||
---
|
||||
|
||||
@@ -127,6 +127,8 @@ export const STATUS_ICON = Object.freeze({
|
||||
burn: 'burn', burnBurst: 'burn', neural: 'neural', neuralBurst: 'neural', necrosis: 'necrosis', apoptosis: 'necrosis',
|
||||
// a 傀儡师 fighting as its <替身> (sim professions.js buff 'trait:substitute', the 20 s form)
|
||||
substitute: 'doll',
|
||||
// 禁疗 (sim status 'healFree': 史尔特尔's 余烬 — no heal reaches her until she leaves)
|
||||
healFree: 'healFree',
|
||||
});
|
||||
|
||||
/** Keyword fallbacks for namespaced / content status keys ('ab:frost', 'reed2:scorch', 'skill:shotst_shred' …). */
|
||||
|
||||
@@ -835,7 +835,7 @@ export function fxAtlas() {
|
||||
return _fx;
|
||||
}
|
||||
|
||||
export const STATUS_KEYS = ['stun', 'freeze', 'cold', 'stealth', 'shield', 'fragile', 'sleep', 'invuln', 'silence', 'slow', 'bind', 'fear', 'weaken', 'levitate', 'taunt', 'burn', 'neural', 'necrosis', 'blocked', 'skill', 'doll'];
|
||||
export const STATUS_KEYS = ['stun', 'freeze', 'cold', 'stealth', 'shield', 'fragile', 'sleep', 'invuln', 'silence', 'slow', 'bind', 'fear', 'weaken', 'levitate', 'taunt', 'burn', 'neural', 'necrosis', 'blocked', 'skill', 'doll', 'healFree'];
|
||||
|
||||
function drawStatusIcon(c, key, x, y, s) {
|
||||
const cx = x + s / 2, cy = y + s / 2;
|
||||
@@ -941,6 +941,14 @@ function drawStatusIcon(c, key, x, y, s) {
|
||||
c.lineTo(cx, cy + 6); c.lineTo(cx - 1.5, cy + 11); c.lineTo(cx - 5, cy + 11); c.lineTo(cx - 3, cy + 5); c.closePath(); c.fill();
|
||||
break;
|
||||
}
|
||||
case 'healFree': { // 禁疗: a green heal cross struck through in red
|
||||
disc('rgba(15,40,20,0.92)', '#7ee08a');
|
||||
c.fillStyle = '#9dffa8';
|
||||
c.fillRect(cx - 2.5, cy - 9, 5, 18); c.fillRect(cx - 9, cy - 2.5, 18, 5);
|
||||
c.strokeStyle = '#ff5a5a'; c.lineWidth = 3;
|
||||
c.beginPath(); c.moveTo(cx - 10, cy + 10); c.lineTo(cx + 10, cy - 10); c.stroke();
|
||||
break;
|
||||
}
|
||||
default: disc('rgba(30,30,30,0.9)', '#ccc');
|
||||
}
|
||||
}
|
||||
|
||||
+29
-8
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -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',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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 }); } });
|
||||
/**
|
||||
@@ -889,9 +916,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
|
||||
@@ -929,6 +957,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 });
|
||||
@@ -1069,7 +1104,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) {
|
||||
@@ -1318,7 +1354,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 } });
|
||||
})];
|
||||
}
|
||||
|
||||
@@ -1338,10 +1375,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; },
|
||||
}];
|
||||
@@ -1350,7 +1395,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); }, // 平时不攻击,失去隐匿时反击
|
||||
@@ -1503,7 +1548,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);
|
||||
},
|
||||
|
||||
@@ -1059,7 +1059,8 @@ const KITS = {
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 史尔特尔 — S3 黄昏 (toggle, 持续时间无限): full heal, ATK +, range +2, 3 targets, max HP +5000 (flat), HP loss ramping
|
||||
// to 20 % max HP/s over 60 s. T1 熔火: ignores 20 RES. T2 余烬: lethal damage keeps HP ≥ 1 for 8 s, then she withdraws.
|
||||
// to 20 % max HP/s over 60 s. T1 熔火: ignores 20 RES. T2 余烬: lethal damage keeps HP ≥ 1 for 8 s (不死 + 禁疗), then she
|
||||
// withdraws.
|
||||
// Module (elite): ASPD +8 while not blocking.
|
||||
// S1 烈焰魔剑 (instant, attack SP): next attack atk_scale × ATK; a kill refills all SP at once.
|
||||
// S2 熔核巨影 (duration): ATK +, range +1, 2 targets; an attack that hits a single enemy is ×critical atk_scale.
|
||||
@@ -1093,7 +1094,8 @@ const KITS = {
|
||||
onStart({ battle, unit }) {
|
||||
unit.mem.twilightT = 0;
|
||||
unit.mem.twilightAcc = 0;
|
||||
battle.heal(unit, unit, unit.s.maxHp, { self: true });
|
||||
// "立即恢复所有生命" — PRTS 技能3 备注 "(无视禁疗)": it reaches her during 余烬 too
|
||||
battle.heal(unit, unit, unit.s.maxHp, { self: true, ignoreHealFree: true });
|
||||
battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id, r: 1, skill: 'surtr' });
|
||||
},
|
||||
onTick({ battle, unit, dt }) {
|
||||
@@ -1110,6 +1112,12 @@ const KITS = {
|
||||
talents: [
|
||||
{ install(battle, unit) { permBuff(battle, unit, 'surtr:magma', { resIgnoreFlat: num(t0.magic_resist_penetrate_fixed) }); } },
|
||||
{ install(battle, unit) { // 余烬
|
||||
// PRTS 天赋备注: "持有不死的情况下不会触发此天赋" (a 不死 that prevented the blow first: `c.prevented` — 坚固维式重锤's
|
||||
// lock, PRIO_REVIVE −100, runs after this −60 on the same first lethal blow [ASSUMED order], so it never starts
|
||||
// while 余烬 is unused). "触发本天赋后,获得禁疗与不死": 不死 = every later lethal blow is prevented (`mem.ember`);
|
||||
// 禁疗 (异常效果 HEAL_FREE "无法成为治疗类能力的目标,且受到的治疗量变为0", an HP-regen attribute excepted) = flags
|
||||
// noHeal (no heal pick, no heal from others) + healFree (her own heals too; S3's start heal "无视禁疗"), shown as
|
||||
// the status 'healFree' until she leaves. "强制退出战场视为撤回干员": a retreat (Battle.retreat drops the buff).
|
||||
const wait = num(t1['surtr_t_2[withdraw].interval'], 8);
|
||||
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.ember = false; }, { owner: unit });
|
||||
battle.on('fatal', (c) => {
|
||||
@@ -1118,6 +1126,7 @@ const KITS = {
|
||||
if (unit.mem.ember) return;
|
||||
unit.mem.ember = true;
|
||||
const dep = unit.deploySeq;
|
||||
battle.addBuff(unit, { key: 'surtr:ember', status: 'healFree', flags: { noHeal: true, healFree: true } });
|
||||
battle.fx('ember', { x: unit.x, y: unit.y, id: unit.id });
|
||||
battle.after(wait, () => { if (unit.alive && unit.deploySeq === dep) battle.retreat(unit, { reason: 'retreat' }); }, { owner: unit });
|
||||
}, { owner: unit, priority: -60 });
|
||||
|
||||
@@ -500,11 +500,17 @@ export function reduceElement(target, amount, el = null) {
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Heal pipeline. Returns the HP actually restored. */
|
||||
/**
|
||||
* Heal pipeline. Returns the HP actually restored. `noHeal` stops heals from others (`self` heals pass); `healFree` (禁疗,
|
||||
* PRTS 异常效果 HEAL_FREE "受到的治疗量变为0") stops the unit's own too — except an HP-regeneration attribute (`regen`:
|
||||
* "增减生命回复速度或生命回复速度(百分比)属性的效果不会被识别为治疗类能力") and a heal that ignores it (`ignoreHealFree`:
|
||||
* 史尔特尔 S3's start heal, PRTS "无视禁疗").
|
||||
*/
|
||||
export function heal(battle, source, target, amount, opts = {}) {
|
||||
if (!target || !target.alive || target.removed || !target.deployed || target.bossPool) return 0;
|
||||
const self = source === target || !!opts.self;
|
||||
if (!self && (target.s.flags.noHeal || (target.profile && target.profile.noHeal))) return 0;
|
||||
if (target.s.flags.healFree && !opts.regen && !opts.ignoreHealFree) return 0;
|
||||
let amt = amount * (source && source.s ? source.s.healingDealtMul : 1) * target.s.healingTakenMul;
|
||||
if (!(amt > 0) || !Number.isFinite(amt)) return 0;
|
||||
if (battle._hooks.heal) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+18
-1
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user