diff --git a/docs/SIM.md b/docs/SIM.md index 6dc2251e..797c1757 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -460,8 +460,9 @@ extraRangeKeys (content extra targets, `battle.setExtraRange`), blocking[] (alli stats {dmg,kills,heal,taken,attacks}, mem {} and trait {} (free scratch space), persist {redeployMul, …}.` Getters: `s` (aggregated stats), `maxHp`, `atk`, `hpRatio`, `sp`, `spMax`, `canAct`, `isFlying`, `statusFlags` (UF bits), `dmgType`, `weight`. `isFlying` = an **air unit** for every targeting / ground-only rule: `motion` FLY, or an enemy with -flag `float` (近地悬浮, PRTS 术语释义 "算作空中单位") or `levitate` (浮空 "变为空中单位"); movement and pathing read `motion` -(a hovering enemy keeps walking the ground path). `deploySeq` counts deployments (and identifies one: `seq === u.deploySeq`); +flag `float` (近地悬浮, PRTS 术语释义 "算作空中单位") or `levitate` (浮空 "变为空中单位") — never an enemy under 缚地 (flag +`groundbind`, "目标变为地面单位") unless a 浮空 lifts it again; movement and pathing read `motion` (a hovering enemy keeps +walking the ground path). `deploySeq` counts deployments (and identifies one: `seq === u.deploySeq`); `aggroSeq` is the aggro order (= deploySeq, except the summons of the initial deployment, §1). **Hit areas (`body.js`, user playtest #5 item 10).** A regular enemy is a point: in a grid range when the tile of its @@ -531,8 +532,8 @@ A `rangeExtend` on a `persist` never-expiring buff is **permanent**: it also wid a running skill's range too, unless that skill's range ignores 攻击距离 (`targeting.noRangeExtend`; PRTS 数值范围 "根据配置 不同,任何范围都可以受/不受该属性影响" — 信仰搅拌机 S3 "此技能的攻击范围不受“攻击距离”属性影响"). **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 "不阻挡地面敌人且不会被地面敌人攻击, +healFree untargetable blockFly noMove noSp burstLock hidden attract float noDisplace isolated groundbind camou liftoff` (`float` = 近地悬浮 (an air +unit, `Unit.isFlying`), `groundbind` = 缚地 (the status below: an enemy air unit counts as a ground unit), `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 still does (`ignoreSelect` / 无来源: 无视无法选择 abilities, direct picks, flying units' blasts, a debuff's ticks); flyers, @@ -596,9 +597,10 @@ of coverage per 3 s), kept because the current wording no longer says so (feedba | `stealth` / `reveal` | 隐匿: untargetable unless blocked (an ally: only the enemy it blocks attacks it, and an enemy's area effects and buff auras skip it even when it blocks that enemy — `targeting.js areaSelectable` / `auraSelectable`, GitHub #97, owner 2026-10-04; 0.1.2 made an exception for that blocker) / 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) — but an enemy's splash and other area effects still hit it (`areaSelectable` does not check 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 | – | +| `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) that hold no 缚地 and on 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 | – | +| `groundbind` (缚地) | gamedata_const ba.groundbind "目标变为地面单位,无法移动;使部分近地悬浮敌人掉落;对重量大于3的单位持续时间减半": an enemy air unit (data flyer, 近地悬浮) counts as a ground unit (`isFlying` false: melee operators hit it, ground blockers block it) and cannot move (`noMove`); **half duration on units with (current) massLevel > 3**; one of 抵抗's statuses [ASSUMED, as 束缚]; the drop trigger of 掠海漂移体 / 吉兆飞鳞 (content/enemies: "受…缚地影响后"). 予愿安洁莉娜 S2 (kits/ops/op-aglna2.js) | – | | `attract` (诱导) | 无法被阻挡并向目标位置移动: unblockable (released); the engine walks it (own speed, grid path re-planned on obstacle changes, after a push and after an outranking 恐惧; flyers straight) to `opts.point` (`[r, c]` or `{x, y}`, default the source's tile, clamped to the rect) and keeps it there; stun/bind/sleep stop it; its route re-plans from where it stands when the status ends. A new application moves the point | point | -| `resist` (抵抗) | the control statuses of `RESIST_STATUSES` (晕眩 冻结 寒冷 沉睡 恐惧 战栗 诱导 浮空 束缚 沉默 缴械 停顿 减速) applied to the unit last ×(1 − value); a resisting unit loses one 麻痹 stack every 5 s. **同名效果不叠加** — `battle.resistOf(u)` = the strongest `status: 'resist'` buff (never a product): several sources (灵知, 流明, 寒檀, enemy talents) never compound. *strongest*; a permanent one that survives death is a `persist` buff with `status: 'resist', data: { value }` | 0.5 (≤ 0.95) | +| `resist` (抵抗) | the control statuses of `RESIST_STATUSES` (晕眩 冻结 寒冷 沉睡 恐惧 战栗 诱导 浮空 束缚 沉默 缴械 停顿 减速 缚地) applied to the unit last ×(1 − value); a resisting unit loses one 麻痹 stack every 5 s. **同名效果不叠加** — `battle.resistOf(u)` = the strongest `status: 'resist'` buff (never a product): several sources (灵知, 流明, 寒檀, enemy talents) never compound. *strongest*; a permanent one that survives death is a `persist` buff with `status: 'resist', data: { value }` | 0.5 (≤ 0.95) | | `taunt` | taunt level +value | 1 | | `weaken` | atkMul 1 − value (*strongest*) | 0.3 | | `aspdDown` | aspd + value (*strongest*) | −30 | diff --git a/public/js/render/style.js b/public/js/render/style.js index 4f53e9ad..1895610b 100644 --- a/public/js/render/style.js +++ b/public/js/render/style.js @@ -122,7 +122,7 @@ export const PROJ = Object.freeze({ export const STATUS_ICON = Object.freeze({ stun: 'stun', freeze: 'freeze', cold: 'cold', stealth: 'stealth', shield: 'shield', fragile: 'fragile', artsFragile: 'fragile', physFragile: 'fragile', elemFragile: 'fragile', sleep: 'sleep', invulnerable: 'invuln', - silence: 'silence', slow: 'slow', sluggish: 'slow', bind: 'bind', fear: 'fear', tremble: 'fear', weaken: 'weaken', + silence: 'silence', slow: 'slow', sluggish: 'slow', bind: 'bind', groundbind: 'bind', fear: 'fear', tremble: 'fear', weaken: 'weaken', levitate: 'levitate', taunt: 'taunt', defDown: 'weaken', resDown: 'weaken', aspdDown: 'slow', disarm: 'silence', 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) diff --git a/server/sim/battle/status.js b/server/sim/battle/status.js index ef6a2e1c..c726edcd 100644 --- a/server/sim/battle/status.js +++ b/server/sim/battle/status.js @@ -135,8 +135,8 @@ export class BattleStatus { * handlers may cancel it or change `duration` / `value`. Official rules (buffs.js STATUS): 抵抗 (the `resist` status) * shortens the RESIST_STATUSES by its value (default half; applied after `beforeStatus`; `resistApplied` skips that * pass — the cold-on-cold 冻结 below already used post-抵抗 lengths). A second 寒冷 while 寒冷 remains applies 冻结 for - * max(remaining, this cold after 抵抗) (PRTS 术语释义 寒冷 「持续时间取双方之中最高」). 浮空 lasts half as long on units heavier - * than LEVITATE_HALF_WEIGHT (current massLevel); 冻结's RES cut hits enemies only; 麻痹 adds stacks; "同名效果取最高" + * max(remaining, this cold after 抵抗) (PRTS 术语释义 寒冷 「持续时间取双方之中最高」). 浮空 and 缚地 last half as long on units + * heavier than LEVITATE_HALF_WEIGHT (current massLevel); 冻结's RES cut hits enemies only; 麻痹 adds stacks; "同名效果取最高" * statuses (`valued`) keep the strongest value — a weaker application only extends past the stronger one's end (it * then resumes); `stackAs` = the value such an application competes with instead of its own (its effect stays * `value`): an effect the game stacks as another strength — Raidian S3's 虚弱, PRTS 备注 "在叠加时视为90%(1级~6级)/80% @@ -162,9 +162,9 @@ export class BattleStatus { const immune = target.def && target.def.immune; if (!opts.force && tpl.immune && immune && immune.has(tpl.immune)) return false; // 浮空 Buff (PRTS 异常效果: "若单位数据上为飞行单位且不持有缚地异常或是持有浮空异常则Buff取消"; 行动方式 "行动类型(数据)为 - // 飞行的单位、以及已持有浮空异常的单位无法被施加浮空Buff"): refused on data flyers (`motion` FLY) and units already - // levitated — a hovering 近地悬浮 enemy is WALK in its data, so it can be levitated (no 缚地 / 浮空强化 in this mode) - if (key === 'levitate' && (target.motion === 'FLY' || target.s.flags.levitate)) return false; + // 飞行的单位、以及已持有浮空异常的单位无法被施加浮空Buff"): refused on data flyers (`motion` FLY) that hold no 缚地 and on + // units already levitated — a hovering 近地悬浮 enemy is WALK in its data, so it can be levitated (no 浮空强化 in this mode) + if (key === 'levitate' && ((target.motion === 'FLY' && !target.s.flags.groundbind) || target.s.flags.levitate)) return false; if (this._hooks.beforeStatus) { const c = this.emit('beforeStatus', { source: opts.source ?? null, target, status: key, duration, value, cancel: false }); if (c.cancel || !target.alive) return false; @@ -177,7 +177,8 @@ export class BattleStatus { const rv = this.resistOf(target); if (rv > 0) duration *= 1 - rv; } - if (key === 'levitate' && target.s.massLevel > LEVITATE_HALF_WEIGHT) duration /= 2; + // 浮空 / 缚地 (ba.levitate, ba.groundbind): "对重量大于3的单位持续时间减半" + if ((key === 'levitate' || key === 'groundbind') && target.s.massLevel > LEVITATE_HALF_WEIGHT) duration /= 2; if (!(duration > 0)) return false; if (key === 'cold' && target.findBuff('cold') && !(immune && immune.has('frozen'))) { // PRTS 术语释义 寒冷: 友方寒冷 pairs into 冻结, 「持续时间取双方之中最高」. `duration` is this cold after 抵抗; diff --git a/server/sim/buffs.js b/server/sim/buffs.js index d65c7d2a..ff77315d 100644 --- a/server/sim/buffs.js +++ b/server/sim/buffs.js @@ -42,6 +42,8 @@ export const FLAG_KEYS = Object.freeze([ // 自缚 (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', 'healFree', 'stealthOff', + // 缚地 (status `groundbind`): an enemy air unit counts as a ground unit meanwhile (Unit.isFlying) + 'groundbind', ]); /** @@ -90,6 +92,11 @@ export const STATUS = Object.freeze({ // 浮空: 变为空中单位 (Unit.isFlying),无法移动、攻击及使用技能; 对重量大于3的单位持续时间减半; the state holds 不可阻挡 + // 失衡免疫 (noDisplace: "不会被位移影响", PRTS 异常效果) levitate: { flags: { levitate: true, stun: true, unblockable: true, noDisplace: true }, immune: 'levitate' }, + // 缚地 (gamedata_const ba.groundbind "目标变为地面单位,无法移动;使部分近地悬浮敌人掉落;对重量大于3的单位持续时间减半"): + // an air unit (data flyer, 近地悬浮) counts as a ground unit (Unit.isFlying: melee operators hit it, ground blockers + // block it) and cannot move; half duration on units with massLevel > 3 (Battle.applyStatus, as 浮空); a 浮空 still + // lands on it (PRTS 异常效果 "若单位数据上为飞行单位且不持有缚地异常…则Buff取消"). 予愿安洁莉娜 S2 (kits/ops/op-aglna2.js) + groundbind: { flags: { groundbind: true, noMove: true }, mods: { moveMul: 0 } }, // 麻痹: value = stacks (default 1, max 3); each stack cancels one enemy normal attack; lasts until consumed; honours // 麻痹免疫 (enemy_database palsyImmune, e.g. 假想敌:铳 — PRTS 元素: a 神经 burst gives 麻痹 only "若单位不具有麻痹免疫") palsy: { palsy: true, immune: 'palsy' }, @@ -120,12 +127,12 @@ export const STATUS = Object.freeze({ }); /** - * Control statuses (异常状态) shortened by 抵抗: 晕眩 冻结 寒冷 沉睡 恐惧 战栗 诱导 浮空 束缚 沉默 缴械 停顿 减速. The + * Control statuses (异常状态) shortened by 抵抗: 晕眩 冻结 寒冷 沉睡 恐惧 战栗 诱导 浮空 束缚 沉默 缴械 停顿 减速 缚地. The * official term lists 晕眩/寒冷/冻结/恐惧/诱导…; the rest follow the operator kits that grant 抵抗 [ASSUMED]. 麻痹 is * stack-based: it decays instead (RESIST_PALSY_DECAY). */ export const RESIST_STATUSES = Object.freeze(new Set(['stun', 'freeze', 'cold', 'sleep', 'fear', 'tremble', 'attract', 'levitate', - 'bind', 'silence', 'disarm', 'sluggish', 'slow'])); + 'bind', 'silence', 'disarm', 'sluggish', 'slow', 'groundbind'])); /** Statuses that should be reported to clients as `['status', id, key, on]`. */ export const VISIBLE_STATUS = new Set(Object.keys(STATUS)); diff --git a/server/sim/content/enemies/element.js b/server/sim/content/enemies/element.js index 1fe5e4f7..11ea7df9 100644 --- a/server/sim/content/enemies/element.js +++ b/server/sim/content/enemies/element.js @@ -58,13 +58,13 @@ const SYUFO_CRAWL_STUN = 0.5; /** * 掠海漂移体 (PRTS): 初始模式 近地悬浮 + 失衡免疫, 不会攻击飞行单位; 受晕眩/无法行动/沉睡/冻结/缚地影响后进入爬行模式 for * good and is stunned 0.5 s — a ground unit (blockable, melee operators hit it) that "仅进行阻挡攻击" (only its blocker). - * The engine has 晕眩 / 冻结 / 沉睡 (no operator here applies 无法行动 or 缚地). Erosion on every attack. + * The engine has 晕眩 / 冻结 / 沉睡 / 缚地 (予愿安洁莉娜 S2; no operator here applies 无法行动). Erosion on every attack. */ function kitSyufo(ab) { return [float(), ep('erosion', T(ab, 'EpDamage.attack@ep_damage_ratio') ?? 0), { spawn(b, e) { e.profile.canTarget = (u) => !u.isFlying; }, status(c, b, e, a) { - if (a.crawl || !(c.status === 'stun' || c.status === 'freeze' || c.status === 'sleep')) return; + if (a.crawl || !(c.status === 'stun' || c.status === 'freeze' || c.status === 'sleep' || c.status === 'groundbind')) return; a.crawl = true; setFloat(b, e, false); e.profile.melee = true; diff --git a/server/sim/content/enemies/special.js b/server/sim/content/enemies/special.js index 858f5ae7..962e6515 100644 --- a/server/sim/content/enemies/special.js +++ b/server/sim/content/enemies/special.js @@ -103,7 +103,7 @@ function kitParrot(ab) { }, status(c, b, e, a) { if (c.status === 'freeze') { a.chill = true; return; } - if (a.down || !(c.status === 'stun' || c.status === 'sleep')) return; + if (a.down || !(c.status === 'stun' || c.status === 'sleep' || c.status === 'groundbind')) return; a.down = true; setFloat(b, e, false); b.removeBuff(e, 'ab:parrotRun'); @@ -114,8 +114,8 @@ function kitParrot(ab) { tick(b, e, a) { if (a.chill && !e.s.flags.freeze) { a.chill = false; b.applyStatus(e, 'stun', { duration: PARROT_CHILL_STUN, source: null }); } // "离开上述异常效果影响后进入初始模式": s.flags.stun also holds while asleep / frozen (units.js), so a sleep that - // outlasts the mode stun keeps it down - if (a.down && !e.s.flags.stun) { + // outlasts the mode stun keeps it down; so does a 缚地 (flag `groundbind`) + if (a.down && !e.s.flags.stun && !e.s.flags.groundbind) { a.down = false; b.removeBuff(e, 'ab:parrotDown'); b.removeBuff(e, 'ab:parrotRun'); diff --git a/server/sim/units.js b/server/sim/units.js index 25e07a06..85343143 100644 --- a/server/sim/units.js +++ b/server/sim/units.js @@ -179,13 +179,18 @@ export class Unit { /** * Air unit (空中单位) for every targeting / ground-only rule: FLY movers, and enemies that hover (近地悬浮, buff flag * `float` — PRTS 术语释义 ba.float "算作空中单位"; they keep walking the ground path) or are levitated (浮空, gamedata_const - * ba.levitate "变为空中单位"). Movement and pathing read `motion`, never this. + * ba.levitate "变为空中单位"). A 缚地 enemy (status `groundbind`, ba.groundbind "目标变为地面单位") is a ground unit + * meanwhile — unless a 浮空 lifts it again (浮空 lands on a 缚地 flyer: Battle.applyStatus). Movement and pathing read + * `motion`, never this. */ get isFlying() { - if (this.motion === 'FLY') return true; - if (this.side !== 'enemy') return false; - const f = this.s.flags; - return !!(f.float || f.levitate); + if (this.side === 'enemy') { + const f = this.s.flags; + if (f.levitate) return true; + if (f.groundbind) return false; + return this.motion === 'FLY' || !!f.float; + } + return this.motion === 'FLY'; } get hpRatio() { const mh = this.s.maxHp; return mh > 0 ? this.hp / mh : 0; } diff --git a/test/sim/feedback5-groundbind.test.js b/test/sim/feedback5-groundbind.test.js new file mode 100644 index 00000000..d7144f07 --- /dev/null +++ b/test/sim/feedback5-groundbind.test.js @@ -0,0 +1,86 @@ +// test/sim/feedback5-groundbind.test.js — the 缚地 status (0.2.0, O20: 予愿安洁莉娜 S2, kits/ops/op-aglna2.js): gamedata_const +// ba.groundbind "目标变为地面单位,无法移动;使部分近地悬浮敌人掉落;对重量大于3的单位持续时间减半" — server/sim/buffs.js STATUS +// `groundbind` (flag `groundbind` + `noMove`), units.js Unit.isFlying (a 缚地 enemy is a ground unit unless a 浮空 lifts it), +// battle/status.js applyStatus (浮空 lands on a 缚地 data flyer — PRTS 异常效果 "若单位数据上为飞行单位且不持有缚地异常…则Buff +// 取消"; the weight halving), the 抵抗 list, and the two hovering enemies whose PRTS text names it (content/enemies: +// 掠海漂移体 爬行模式, 吉兆飞鳞 晕眩模式). +// Run: node --test test/sim/feedback5-groundbind.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; +import { STATUS, RESIST_STATUSES, FLAG_KEYS } from '../../server/sim/buffs.js'; + +const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`); +const ENEMIES = { + enemy_fly: enemyRec({ key: 'enemy_fly', hp: 1e9, speed: 0, mass: 0, motion: 'FLY' }), + enemy_flywalk: enemyRec({ key: 'enemy_flywalk', hp: 1e9, speed: 0.6, mass: 0, motion: 'FLY' }), + enemy_flyheavy: enemyRec({ key: 'enemy_flyheavy', hp: 1e9, speed: 0, mass: 4, motion: 'FLY' }), +}; + +function arena(units = []) { + const h = makeBattle({ defs: { enemies: ENEMIES }, timeLimit: 600, autoFinish: false, seed: 3, flags: { dpPerSec: 0 }, units }); + h.step(); + return h; +} + +test('缚地 is a catalogue status: flag groundbind + noMove, move ×0, one of 抵抗\'s statuses', () => { + assert.deepEqual(STATUS.groundbind, { flags: { groundbind: true, noMove: true }, mods: { moveMul: 0 } }); + assert.ok(FLAG_KEYS.includes('groundbind')); + assert.ok(RESIST_STATUSES.has('groundbind')); +}); + +test('缚地: an air unit is a ground unit (melee operators hit it) and cannot move; half as long on weight > 3; 抵抗 shortens it; 浮空 lands on a 缚地 flyer; back in the air when it ends', () => { + const h = arena([{ uid: 1, chessId: 'chess_char_1_02_a', row: 9, col: 4 }]); + const yak = h.unit(1); + const fl = h.spawn('enemy_flywalk', { routeIndex: 2 }); + h.run(0.5); + assert.equal(fl.isFlying, true); + assert.equal(h.b.applyStatus(fl, 'levitate', { duration: 3 }), false, 'a data flyer takes no 浮空'); + assert.ok(h.b.applyStatus(fl, 'groundbind', { duration: 5 })); + assert.deepEqual([fl.isFlying, !!fl.s.flags.noMove, fl.s.moveSpeed], [false, true, 0], 'ground unit, rooted'); + const x0 = fl.x; + h.run(1); + approx(fl.x, x0, 'it does not move'); + assert.ok(h.b.applyStatus(fl, 'levitate', { duration: 2 }), '浮空 lands on a 缚地 flyer'); + assert.equal(fl.isFlying, true, 'levitated: an air unit again'); + // a melee operator (ground-only, range: his own tile) hits a 缚地 flyer on his tile — and blocks it, a ground unit now + const near = h.spawn('enemy_fly', { pos: [9, 4] }); + h.run(1); + assert.equal(yak.stats.dmg, 0, '角峰 cannot hit the flyer'); + h.b.applyStatus(near, 'groundbind', { duration: 10 }); + assert.ok(h.runUntil(() => yak.stats.dmg > 0, 5), '缚地: a ground unit he can hit'); + assert.equal(near.blockedBy, yak, 'and block'); + // halving and 抵抗 + const heavy = h.spawn('enemy_flyheavy', { pos: [10, 9] }); + h.b.applyStatus(heavy, 'groundbind', { duration: 10 }); + approx(heavy.findBuff('groundbind').timeLeft, 5, 'weight 4: half'); + const res = h.spawn('enemy_fly', { pos: [11, 9] }); + h.b.applyStatus(res, 'resist', { duration: 99, value: 0.5 }); + h.b.applyStatus(res, 'groundbind', { duration: 10 }); + approx(res.findBuff('groundbind').timeLeft, 5, '抵抗 halves it'); + h.run(6); + assert.equal(res.isFlying, true, 'back in the air when it ends'); + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0); +}); + +test('the hovering enemies (PRTS "受…缚地影响后"): 掠海漂移体 drops to its 爬行模式 for good; 吉兆飞鳞 is down while the 缚地 holds', () => { + const h = arena(); + const sy = h.spawn('enemy_2025_syufo', { pos: [12, 8], routeIndex: 0, mods: { speedMul: 0 } }); + const pa = h.spawn('enemy_10045_parrot', { pos: [12, 6], routeIndex: 0, mods: { speedMul: 0 } }); + h.step(); + assert.ok(sy.isFlying && pa.isFlying, 'both hover'); + h.b.applyStatus(sy, 'groundbind', { duration: 4 }); + h.b.applyStatus(pa, 'groundbind', { duration: 20 }); + h.step(); + assert.equal(sy.form, 'crawl', '掠海漂移体: 爬行模式'); + assert.equal(pa.isFlying, false, '吉兆飞鳞: down'); + h.run(10); + assert.equal(sy.isFlying, false, '掠海漂移体 stays down for good'); + assert.equal(pa.isFlying, false, '吉兆飞鳞 down while the 缚地 holds (its 8 s stun over)'); + h.run(12); + assert.equal(pa.isFlying, true, '吉兆飞鳞 floats again once nothing holds it'); + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0); +});