diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 30b6f0e1..e52a12cd 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -76,7 +76,7 @@ - 作战中:海嗣、狼群、流形、支援装置开战时就在摆放的位置部署;医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次(持续 10 / 15 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、海嗣(以及自选干员的工程蓄水炮、沙地兽、打字机、本能的召唤)这类不会受到攻击、到时间就离场的召唤物不受任何伤害(活性源石也一样)、也不会被治疗,它们的血条就是倒计时:随时间减少,空了就离场。琳琅诗怀雅二技能的香槟炸弹也不会掉血(活性源石和各种伤害都不会)、不会被治疗,不会因为生命值而消失,只在「见面礼」炸到碰到它的第一个地面敌人后消失(维护者 2026-10-08 的决定);隐匿(没有被反隐、也没有被阻挡)或沉睡的敌人选不中,不会触发它。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。狼群最后一只狼影被击倒后进入战术点形态:暂时离开战斗(不阻挡、不攻击、不会被攻击,格子仍然占着),经过狼影的恢复时间(25 秒)后在原地满血、带着 1 只狼影回来,之后重新开始每 25 秒加 1 只狼影;伺夜退场时狼群随之撤退,伺夜再部署回场时狼群重新带着 2 只狼影出现。 - **阻挡**看接触:干员阻挡中心距离约 0.7 格以内、与它接触的敌人(地面干员挡不住飞行和近地悬浮的敌人)。敌人卡在两名干员之间时,阻挡它的干员倒下、撤退或被晕眩后,另一名仍与它接触、还有空余阻挡数的干员会**立刻接替阻挡**;都没有空位时敌人才会走掉。被冻结或晕眩的敌人同样会被接触到的干员挡住:干员再部署到它身边就会阻挡它,隐匿的敌人也随之破隐(沉睡、浮空、恐惧的敌人仍然挡不住)。迎面走来的敌人会停在干员格子前面。**阻挡了就一定能打到**:任何干员(包括站在近战位的远程干员和召唤物)总能攻击自己阻挡的敌人,不管它在不在攻击范围内、在干员哪一侧,并且优先攻击它(医疗干员除外:咒愈师以外的医疗挡住敌人时照常治疗队友,不会攻击它;「停止攻击」类技能期间也除外;技能原文另有限制的也按原文:普罗旺斯二技能开启时不以生命值高于 80% 的敌人为目标,寒檀二技能的冰凌随机落在攻击范围内的地块上)。 - 开战时干员按**逐列从左到右、每列从上到下**的顺序部署(最左一列最先,与阿戈尔吞噬「更靠左和靠上」的顺序相同),召唤物在所有干员之后。被阻挡的敌人先打阻挡它的干员;没被阻挡的远程敌人先打嘲讽等级高的,同级时打最后部署的(召唤物、最右一列靠下的干员和刚再部署的干员更容易挨打)。隐匿的干员只会被它阻挡的敌人攻击,没被它挡住的敌人的溅射、爆炸、范围技能、留下的伤害区域(包括假想敌:蚀裂被击倒后喷出的毒雾)以及深池伙友卫队、扎罗的减攻速光环也落不到它身上(和官方一样;写明「无视无法选择」的效果如污染秽蚀照常能打到,寒霜的减攻速光环和“墓碑”这类地图效果照常生效);**迷彩**(如忍冬三技能期间击倒敌人后、寒芒克洛丝一技能)同样只会被它阻挡的敌人攻击,也显示为半透明,但躲不开溅射、爆炸和范围伤害(同时打范围内所有单位的普通攻击——如“斩胄之剑”“破胄之锤”“灵幛”——打不到它),也不算隐匿(不触发叙拉古等「隐匿时」效果)。叙拉古的隐匿按时(32 秒 + 每层 0.4 秒)结束;忍冬的迷彩按官方持续到她下一次开技能。**蒂比起飞**期间只挡飞行敌人,地面敌人不会选她为目标(普通攻击、溅射、技能范围、燃烧区域和要选中目标的减益都落不到她身上);飞行和悬浮敌人、写明「无视无法选择」的效果(如污染秽蚀)照常能打到她;地面敌人的光环和全场效果(如“墓碑”让全场攻击力、防御力减半)、她攻击敌人时招来的反制(如重装侦察兵的暴露)照常生效,起飞前已挂上的持续伤害也照常跳。 -- **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**开启后攻击范围比平时大**的手动技能(如莫斯提马「序时之匙」、银灰「真银斩」、史尔特尔「黄昏」、白面鸮「脑啡肽」)在**开启后的攻击范围内出现敌人**(治疗技能:有受伤的队友)时就释放,不必等敌人走进平时的攻击范围,这是本作按玩家反馈有意与官方不同的地方;**重装干员**的手动技能在受到伤害时释放(例外:深巡、雷蛇的二技能,号角的二、三技能,灰毫的一、二技能按玩家反馈改为攻击范围内有敌人时就释放(深巡二技能按开启后更远的攻击范围判断),余的二技能「厚礼上宾」改为周围两格(技能范围)内有敌人时就释放,这是本作有意与官方不同的地方;深巡一技能仍要受到伤害);执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放(开启后攻击范围扩大的技能——薄绿「风语」、蜜蜡「沙暴扩散」、卡涅利安「食噬之印」、玛恩纳「未宽解的悲哀」,以及安洁莉娜「秘杖·反重力模式」——同样按开启后的攻击范围判断)。**自动触发**的技能按其本身规则:只作用于队友的(如引星棘刺「度算浪波」、浊心斯卡蒂「同葬无光之愿」、纯烬艾雅法拉「无声润物」)技力满就释放,不用等敌人或伤员;伺夜「领袖的呼唤」「领袖的馈赠」只在场上有狼群时释放:狼群处于战术点形态时,「领袖的呼唤」也会释放并让它立刻带着 1 只狼影回到场上(狼影未满时改为加 1 只狼影,已满时把狼群的生命值回满),「领袖的馈赠」也会释放(立即获得费用,强化留给回来的狼群的下一次攻击);又如古米「备用军粮」在身边(技能范围内)有受伤的队友时就会发动,下一次攻击改为治疗该队友(不用先接敌;她的二技能「食粮烹制」是手动技能,按重装规则在受到伤害时释放);塞雷娅「急救」在她即将攻击时、身边有生命不高于一半的队友才发动,这次攻击直接改为治疗;新约能天使二技能的护盾按官方给攻击范围内**仇恨最高**的队友(嘲讽等级高的优先,同级时给最后部署的),不一定是站在最前面挨打的那个。手动技能的自动释放有 3 秒冷却:开战部署后先等 3 秒,之后同一干员两次自动释放至少间隔 3 秒(可充能的技能不会同时放完)。白铁的「铁钳号·原型机」能被我方干员攻击(不受伤害):它在攻击范围 / 技能范围内时,非治疗技能会像遇到敌人一样自动开启并消耗弹药(项目维护者 2026-10-08 按官方体验确认;白铁™多功能平台不会被攻击)。 +- **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**开启后攻击范围比平时大**的手动技能(如莫斯提马「序时之匙」、银灰「真银斩」、史尔特尔「黄昏」、白面鸮「脑啡肽」)在**开启后的攻击范围内出现敌人**(治疗技能:有受伤的队友)时就释放,不必等敌人走进平时的攻击范围,这是本作按玩家反馈有意与官方不同的地方;**重装干员**的手动技能在受到伤害时释放(例外:深巡、雷蛇的二技能,号角的二、三技能,灰毫的一、二技能按玩家反馈改为攻击范围内有敌人时就释放(深巡二技能按开启后更远的攻击范围判断),余的二技能「厚礼上宾」改为周围两格(技能范围)内有敌人时就释放,这是本作有意与官方不同的地方;深巡一技能仍要受到伤害);执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放(开启后攻击范围扩大的技能——薄绿「风语」、蜜蜡「沙暴扩散」、卡涅利安「食噬之印」、玛恩纳「未宽解的悲哀」,以及安洁莉娜「秘杖·反重力模式」——同样按开启后的攻击范围判断)。**自动触发**的技能按其本身规则:只作用于队友的(如引星棘刺「度算浪波」、浊心斯卡蒂「同葬无光之愿」、纯烬艾雅法拉「无声润物」)技力满就释放,不用等敌人或伤员;伺夜「领袖的呼唤」「领袖的馈赠」只在场上有狼群时释放:狼群处于战术点形态时,「领袖的呼唤」也会释放并让它立刻带着 1 只狼影回到场上(狼影未满时改为加 1 只狼影,已满时把狼群的生命值回满),「领袖的馈赠」也会释放(立即获得费用,强化留给回来的狼群的下一次攻击);又如古米「备用军粮」在身边(技能范围内)有受伤的队友时就会发动,下一次攻击改为治疗该队友(不用先接敌;她的二技能「食粮烹制」是手动技能,按重装规则在受到伤害时释放);塞雷娅「急救」在她即将攻击时、身边有生命不高于一半的队友才发动,这次攻击直接改为治疗;新约能天使二技能的护盾按官方给攻击范围内**仇恨最高**的队友(嘲讽等级高的优先,同级时给最后部署的),不一定是站在最前面挨打的那个。手动技能的自动释放有 3 秒冷却:开战部署后先等 3 秒,之后同一干员两次自动释放至少间隔 3 秒(可充能的技能不会同时放完)。白铁的「铁钳号·原型机」能被我方干员攻击(不受伤害):它在攻击范围 / 技能范围内时,能作用到它的技能会像遇到敌人一样自动开启并消耗弹药(项目维护者 2026-10-08 按官方体验确认;白铁™多功能平台不会被攻击);技能效果只会挑选敌人、打不到它的技能(如德克萨斯「剑雨」)不会为它开启,留给真正的敌人。 - **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 0 / 1 / 2 / 3 的敌人推开约 2.1 / 1.7 / 0.4 / 0.1 格,重量 4 以上推不动——轻型敌人反而推得更远。野鬃二技能沿她的朝向推开:正前方 45° 以内按上面的距离,身后、侧面或贴身的敌人只被轻推一下(约 0.4 格以内);琳琅诗怀雅三技能的金币则从她身边向外推开,落在她前方 2–4 格的范围(官方「范围2-4」)内的地面敌人和她阻挡的敌人身上。攻击速度最低为 20(沼泽、寒霜等减速叠满时也不会更慢)。 - **生命回复不是治疗**(与官方一致):吟游者的特性(魔王、浊心斯卡蒂及其海嗣)、调香师的天赋、瑕光二技能、铃兰三技能、锡人和引星棘刺(一、二技能)的炼金单元给友军的「每秒恢复生命」是提高对方的**生命回复速度**,不是治疗:收割者 / 不屈者 / 武者(如隐德来希、圣约送葬人、折桠)这类「无法被友方治疗」的干员和处于禁疗的干员也能吃到,治疗量加成(如「受到的治疗效果提升」)也不会放大它;铃兰三技能开启后的第一秒没有回复。直接治疗(医疗干员的治疗、技能的「恢复…生命」)仍然对这些干员无效。 - **元素损伤条**在单位自己的血条正下方:左边是元素图标,白条是剩余的元素值,只有真正爆发时才会耗尽;爆发后条变成元素的颜色、图标闪烁,在冷却期间慢慢回满(这段时间不会再积累元素损伤)。 diff --git a/docs/SIM.md b/docs/SIM.md index 84d44db9..a4331607 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -846,7 +846,7 @@ instance) and skip `'counter'` / `'reflect'` damage. When the guard trips, the l | `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 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) | -| `setAllyTarget(unit, on)`, `allyTargetsInKeys(keys, attacker)`, `isAllyTarget(unit)` | an **ally target**: an ally unit our attacks select like an enemy — 白铁's 铁钳号·原型机, a summon of the enemy camp ("可被我方干员攻击但不受伤害", PRTS; kits/ops/op-ironmn.js). The attack loop (ai.js `acquireTargets`) appends the registered ones standing on the attacker's range after every enemy (their 嘲讽等级 −2 puts them last: one target ⇒ an enemy first; all in range / more targets ⇒ them too) and ranged attacks fly to them; heals, skills that pick their own victims and enemies never select them (the kit gives it `untargetable` against enemies and cancels the damage it takes in its `hit` hook). A non-heal skill's automatic start counts the registered ones like enemies (skills.js `_allyTargetIn`: every enemy condition of the trigger rules — the owner's rule of 2026-10-08). None registered ⇒ the loop is unchanged | +| `setAllyTarget(unit, on)`, `allyTargetsInKeys(keys, attacker)`, `isAllyTarget(unit)` | an **ally target**: an ally unit our attacks select like an enemy — 白铁's 铁钳号·原型机, a summon of the enemy camp ("可被我方干员攻击但不受伤害", PRTS; kits/ops/op-ironmn.js). The attack loop (ai.js `acquireTargets`) appends the registered ones standing on the attacker's range after every enemy (their 嘲讽等级 −2 puts them last: one target ⇒ an enemy first; all in range / more targets ⇒ them too) and ranged attacks fly to them; heals, skills that pick their own victims and enemies never select them (the kit gives it `untargetable` against enemies and cancels the damage it takes in its `hit` hook). A skill that acts on them counts the registered ones like enemies for its automatic start (skills.js `_allyTargetIn` / `allyTargetsOk`, every enemy condition of the trigger rules — the owner's rule of 2026-10-08): one that acts through the unit's own attacks, or whose kit flags `allyTargets: true` because its selectors take them too (`allyTargetsInKeys` / `allyTargetsInRadius`: 蕾缪安 S3's locks, 艾丽妮 S3, 陈 S2, 引星棘刺 S2); [ASSUMED] a skill that picks its victims among enemies only (or whose kit says `allyTargets: false`) is not opened by them and waits for an enemy. None registered ⇒ the loop is unchanged | | `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) | | `getPlayer(playerId)` | `{ playerId, seat, side, colOffset, mirror, dir (default unit direction: RIGHT, mirrored side LEFT), facing (its sign), bonds (live copy, layers updated by addLayers), bandId, playerEffects, lpForBoss, dp, units }` | @@ -919,8 +919,10 @@ instance) and skip `'counter'` / `'reflect'` damage. When the guard trips, the l every tick; `NEVER` (alias `MANUAL`) — never auto-casts: the kit calls `unit.skill.activate(reason)` itself; `MLYSS_WTRMAN` and any unknown rule behave like DEFAULT. Units that never attack (bard, phalanx, librator) check DEFAULT every tick. In every enemy condition above a registered ally target (`setAllyTarget`: 白铁's 铁钳号·原型机, an - enemy-camp summon) counts like an enemy for a non-heal skill (`_allyTargetIn` — the owner's rule of 2026-10-08: with the - device alone in range a skill opens and spends its bullets on it). Silence blocks activation. The automatic operations cool down (`constants.js AUTO_OP_COOLDOWN` 3 s, + enemy-camp summon) counts like an enemy for a skill that acts on it (`_allyTargetIn`, `allyTargetsOk` — the owner's rule + of 2026-10-08: with the device alone in range such a skill opens and spends itself, its bullets included, on it): one + acting through the unit's own attacks, or flagged `allyTargets` by its kit; [ASSUMED] one that picks enemies only waits. + Silence blocks activation. The automatic operations cool down (`constants.js AUTO_OP_COOLDOWN` 3 s, "自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却"): a MANUAL skill is auto-cast no sooner than 3 s after its previous cast (a charged skill spends its charges 3 s apart) or after its battle-start deployment (`Battle._deploy` initial; `battle.flags.startOpCooldown`, default 3 — the test harness sets 0 unless told otherwise); diff --git a/docs/design/engine.md b/docs/design/engine.md index af5ada70..e4aed12c 100644 --- a/docs/design/engine.md +++ b/docs/design/engine.md @@ -139,7 +139,7 @@ Duration end cleans the window and emits `skillEnd`; it leaves zero charges and Redeployment opens a new full window. Permanent passives retain `passive`; first-kill recasts of 缄默德克萨斯 explicitly restart a full window. Special timed kits and their effect rules are described in SIM §7.2. -Auto-trigger rules (`skillTrigger.rule` in data — the official 技能策略, PRTS 卫戍协议/帮助 §作战阶段 技能操作; §20.2): `DEFAULT` (SP ready AND about to attack/heal AND an enemy/injured ally inside the **initial** range, or an enemy the unit blocks; with `trigger.allies` + `hpAtMost` an ally of the skill grid at or below that HP ratio — 塞雷娅 S1 急救, whose cast replaces that attack and is withdrawn with its charge if the ally recovers first), `SKILL_RANGE` (a MANUAL skill with a 技能范围 of its own, not an attack-range change: any living enemy, stealthed ones included, on the skill range, checked every tick — "仅在技能范围内存在敌人(无视其不可选中)时释放技能"; 13 skills / 26 records by the official rule, 14 / 28 in the data with 余 S2's deviation, §22.10), `ACTIVE_RANGE` (the owner's deliberate deviation of 2026-10-05, 0.2.0: a MANUAL skill on the basic strategy — or, by the owner's further decisions of the same day, on the `SEARCH` row, and 深巡 S2 on top of its §21.29 DEFAULT — whose attack range while it runs strictly contains the operator's own range — its rangeId grid, or the own range grown by `ability_range_forward_extend` — checks the DEFAULT condition on that running range every tick, no attack needed; `customRangeGrid` = the running range, `rawRule` the official row (DEFAULT, SEARCH, 深巡's TAKE_DAMAGE); 39 skills / 78 records, of them 薄绿 S1, 蜜蜡 S1, 卡涅利安 S3, 玛恩纳 S2 and 安洁莉娜 S3 from SEARCH; 烛煌 S2 / S3, whose ranges do not contain her 3-1, keep DEFAULT), `TAKE_DAMAGE` (on taking damage — any damage, 无来源 included, never a 流失; every MANUAL 重装 skill, §21.12 — but the six of the deliberate deviation, §21.29: 深巡 / 雷蛇 S2, 号角 S2 / S3 and 灰毫 S1 / S2 are `DEFAULT`, `rawRule` keeping the official TAKE_DAMAGE — 深巡 S2 then `ACTIVE_RANGE` on its 3-2; and 余 S2, `SKILL_RANGE` on its x-1 (§22.10); the 重装 of data/backups.json — the 补位 stand-ins 预备干员-重装 / Mechanist and every 自选 重装 — cast with an enemy in range, `DEFAULT` (or `ACTIVE_RANGE` when the skill's running range is larger), by the owner's decisions of 2026-10-05), `SP_FULL` (immediately — 执旗手 / 战术家 / 吟游者 MANUAL skills, AUTO DP skills such as 焰尾 S1, 凛御银灰 S1 (伺夜 S1 / S2 too, but only while his pack is on the field — PRTS 备注 「仅场上存在狼群时可触发技能」, the kit's own cast; the owner's decision of 2026-10-05 for S1; S1 also while the pack sits in its 战术点形态, which the cast ends — S2 too since 0.2.0: its DP at once, its gift kept for the returning pack [ASSUMED: S2's 备注 uses S1's words]; 黑键 S2 荒芜回响 likewise only while one placeable tile of his range is free, no enemy needed — PRTS 备注 「攻击范围内不存在可部署位时,技能不会被触发」, its kit's cast, 0.2.0), and, by their kits, AUTO skills whose effects act on allies only: 引星棘刺 S1, 浊心斯卡蒂 S2, 纯烬艾雅法拉 S1 — the owner's decision of 2026-10-05 for the last), `CUSTOM_RANGE` (enemy inside the custom grid), `SEARCH` (an enemy inside the initial range, checked every tick — no attack needed; 解放者 / 阵法术师 MANUAL skills and 安洁莉娜 S2, those without a larger running range), `GDGLOW_SKILL_2` (a valid target anywhere on the field: 荒芜拉普兰德, 纯烬艾雅法拉 S3), special ids. In every enemy condition a registered ally target (白铁's 铁钳号·原型机, an enemy-camp summon our attacks select) counts like an enemy for a non-heal skill (the owner's rule of 2026-10-08). The class rows cover **every MANUAL skill** of the class and never an AUTO skill (AUTO skills keep their own rule — an inference: PRTS names whole classes; the data's `skillIndex 0` / `-1` sentinel argues the other way, §20.2). **Operation cooldown**: the engine auto-casts a MANUAL skill no sooner than `AUTO_OP_COOLDOWN` (3 s) after its previous cast and after the unit's battle-start deployment ("自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却"; `Battle._deploy` initial, `battle.flags.startOpCooldown`); kits with an automatic cast of their own check `skill.opCooling`; a pending "next attack" never takes a second charge. Permanent passive skills are always on; finite deployment effects use the duration lifecycle above. +Auto-trigger rules (`skillTrigger.rule` in data — the official 技能策略, PRTS 卫戍协议/帮助 §作战阶段 技能操作; §20.2): `DEFAULT` (SP ready AND about to attack/heal AND an enemy/injured ally inside the **initial** range, or an enemy the unit blocks; with `trigger.allies` + `hpAtMost` an ally of the skill grid at or below that HP ratio — 塞雷娅 S1 急救, whose cast replaces that attack and is withdrawn with its charge if the ally recovers first), `SKILL_RANGE` (a MANUAL skill with a 技能范围 of its own, not an attack-range change: any living enemy, stealthed ones included, on the skill range, checked every tick — "仅在技能范围内存在敌人(无视其不可选中)时释放技能"; 13 skills / 26 records by the official rule, 14 / 28 in the data with 余 S2's deviation, §22.10), `ACTIVE_RANGE` (the owner's deliberate deviation of 2026-10-05, 0.2.0: a MANUAL skill on the basic strategy — or, by the owner's further decisions of the same day, on the `SEARCH` row, and 深巡 S2 on top of its §21.29 DEFAULT — whose attack range while it runs strictly contains the operator's own range — its rangeId grid, or the own range grown by `ability_range_forward_extend` — checks the DEFAULT condition on that running range every tick, no attack needed; `customRangeGrid` = the running range, `rawRule` the official row (DEFAULT, SEARCH, 深巡's TAKE_DAMAGE); 39 skills / 78 records, of them 薄绿 S1, 蜜蜡 S1, 卡涅利安 S3, 玛恩纳 S2 and 安洁莉娜 S3 from SEARCH; 烛煌 S2 / S3, whose ranges do not contain her 3-1, keep DEFAULT), `TAKE_DAMAGE` (on taking damage — any damage, 无来源 included, never a 流失; every MANUAL 重装 skill, §21.12 — but the six of the deliberate deviation, §21.29: 深巡 / 雷蛇 S2, 号角 S2 / S3 and 灰毫 S1 / S2 are `DEFAULT`, `rawRule` keeping the official TAKE_DAMAGE — 深巡 S2 then `ACTIVE_RANGE` on its 3-2; and 余 S2, `SKILL_RANGE` on its x-1 (§22.10); the 重装 of data/backups.json — the 补位 stand-ins 预备干员-重装 / Mechanist and every 自选 重装 — cast with an enemy in range, `DEFAULT` (or `ACTIVE_RANGE` when the skill's running range is larger), by the owner's decisions of 2026-10-05), `SP_FULL` (immediately — 执旗手 / 战术家 / 吟游者 MANUAL skills, AUTO DP skills such as 焰尾 S1, 凛御银灰 S1 (伺夜 S1 / S2 too, but only while his pack is on the field — PRTS 备注 「仅场上存在狼群时可触发技能」, the kit's own cast; the owner's decision of 2026-10-05 for S1; S1 also while the pack sits in its 战术点形态, which the cast ends — S2 too since 0.2.0: its DP at once, its gift kept for the returning pack [ASSUMED: S2's 备注 uses S1's words]; 黑键 S2 荒芜回响 likewise only while one placeable tile of his range is free, no enemy needed — PRTS 备注 「攻击范围内不存在可部署位时,技能不会被触发」, its kit's cast, 0.2.0), and, by their kits, AUTO skills whose effects act on allies only: 引星棘刺 S1, 浊心斯卡蒂 S2, 纯烬艾雅法拉 S1 — the owner's decision of 2026-10-05 for the last), `CUSTOM_RANGE` (enemy inside the custom grid), `SEARCH` (an enemy inside the initial range, checked every tick — no attack needed; 解放者 / 阵法术师 MANUAL skills and 安洁莉娜 S2, those without a larger running range), `GDGLOW_SKILL_2` (a valid target anywhere on the field: 荒芜拉普兰德, 纯烬艾雅法拉 S3), special ids. In every enemy condition a registered ally target (白铁's 铁钳号·原型机, an enemy-camp summon our attacks select) counts like an enemy for a skill that acts on it (the owner's rule of 2026-10-08): one acting through the unit's own attacks, or whose kit flags `allyTargets` (its selectors take the device too — 蕾缪安 S3, 艾丽妮 S3, 陈 S2, 引星棘刺 S2); [ASSUMED] a skill that picks enemies only is not opened by it. The class rows cover **every MANUAL skill** of the class and never an AUTO skill (AUTO skills keep their own rule — an inference: PRTS names whole classes; the data's `skillIndex 0` / `-1` sentinel argues the other way, §20.2). **Operation cooldown**: the engine auto-casts a MANUAL skill no sooner than `AUTO_OP_COOLDOWN` (3 s) after its previous cast and after the unit's battle-start deployment ("自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却"; `Battle._deploy` initial, `battle.flags.startOpCooldown`); kits with an automatic cast of their own check `skill.opCooling`; a pending "next attack" never takes a second charge. Permanent passive skills are always on; finite deployment effects use the duration lifecycle above. **SkillSpec** (authored per chess in `sim/content/kits/tierN.js`; values come from the chess's skill blackboard at its level — normal Lv4, elite Lv7 — so a spec is a *function of the blackboard*): ```js diff --git a/server/sim/battle/queries.js b/server/sim/battle/queries.js index 5a6e74f8..2d08fc6f 100644 --- a/server/sim/battle/queries.js +++ b/server/sim/battle/queries.js @@ -47,7 +47,8 @@ export class BattleQueries { * 铁钳号·原型机, a summon of the enemy camp that "可被我方干员攻击但不受伤害" (PRTS 铁钳号·原型机 备注 "该召唤物阵营为敌方"; * kits/ops/op-ironmn.js). Those standing on one of `keys` (tile keys, or a Set of them), alive and deployed — never * `attacker` itself, and an ally target selects none. ai.js acquireTargets appends them after the enemies (their 嘲讽等级 - * −2 puts them last); a non-heal skill's automatic start counts them like an enemy (skills.js `_allyTargetIn`). [] at + * −2 puts them last); a skill that acts on them counts them like an enemy for its automatic start (skills.js + * `_allyTargetIn` / `allyTargetsOk`). [] at * once while none is registered, so every other battle runs exactly as before. */ allyTargetsInKeys(keys, attacker) { @@ -59,6 +60,19 @@ export class BattleQueries { return out; } + /** + * The registered ally targets within `r` of (x, y) (centre distance) — the radius form of allyTargetsInKeys, for a kit + * whose area selection takes them too (a skill flagged `allyTargets`, skills.js allyTargetsOk). [] while none is registered. + */ + allyTargetsInRadius(x, y, r, attacker) { + const set = this._allyTargets; + if (!set || !set.size || !attacker || set.has(attacker)) return []; + const r2 = r * r + 1e-9; + const out = []; + for (const a of set) if (a.alive && a.deployed && !a.hidden && (a.x - x) * (a.x - x) + (a.y - y) * (a.y - y) <= r2) out.push(a); + return out; + } + /** Register (`on`) or drop an ally target (allyTargetsInKeys); one off the field is skipped while registered. */ setAllyTarget(unit, on = true) { if (!unit || unit.side !== 'ally') return false; diff --git a/server/sim/content/kits/ops/chess_char_1_07-prove.js b/server/sim/content/kits/ops/chess_char_1_07-prove.js index 34e588d0..c065b10d 100644 --- a/server/sim/content/kits/ops/chess_char_1_07-prove.js +++ b/server/sim/content/kits/ops/chess_char_1_07-prove.js @@ -28,7 +28,9 @@ export default { const s2On = (u) => !!(u.skill && u.skill.active && u.skill.id === S2); return { skill: { kind: 'passive' }, - skills: { [S2]: { kind: 'duration', mods: { atkPct: num(skillBbOf(chess, S2).atk) } } }, + // [ASSUMED] S2 never shoots 白铁's 铁钳号 (its damage is cancelled, so it never drops below 80 %): the device alone does + // not open it (skills.js allyTargetsOk) + skills: { [S2]: { kind: 'duration', mods: { atkPct: num(skillBbOf(chess, S2).atk) }, allyTargets: false } }, trait: { canAttack: (battle, u) => !s2On(u) || battle.enemiesInKeys(u.rangeKeys, u, u.profile).some(huntable), }, diff --git a/server/sim/content/kits/ops/chess_char_3_11-snhunt.js b/server/sim/content/kits/ops/chess_char_3_11-snhunt.js index 002c2cfb..1b14cf2c 100644 --- a/server/sim/content/kits/ops/chess_char_3_11-snhunt.js +++ b/server/sim/content/kits/ops/chess_char_3_11-snhunt.js @@ -58,9 +58,7 @@ export default { battle.on('tick', () => { const sk = unit.skill; if (!alive(unit) || !unit.canAct || !sk || !sk.ready || sk.opCooling || unit.s.flags.silence || (unit.trait.ammo ?? 1) > 0) return; - // (白铁's 铁钳号·原型机, an ally target, counts like an enemy — skills.js `_allyTargetIn`) - const keys = unit.baseRangeKeys || unit.rangeKeys; - if (battle.enemiesInKeys(keys, unit, unit.profile).length || battle.allyTargetsInKeys(keys, unit).length) sk.activate('DEFAULT'); + if (battle.enemiesInKeys(unit.baseRangeKeys || unit.rangeKeys, unit, unit.profile).length) sk.activate('DEFAULT'); }, { owner: unit }); } if (extra > 0) { diff --git a/server/sim/content/kits/ops/chess_char_4_24-billro.js b/server/sim/content/kits/ops/chess_char_4_24-billro.js index 56b42be2..a35e107b 100644 --- a/server/sim/content/kits/ops/chess_char_4_24-billro.js +++ b/server/sim/content/kits/ops/chess_char_4_24-billro.js @@ -37,6 +37,8 @@ export default withDefaults({ skills: alt(def, { skchr_billro_1: () => ({ kind: 'duration', + // [ASSUMED] a charged cast makes no attack at all: 白铁's 铁钳号 alone does not open it (skills.js allyTargetsOk) + allyTargets: false, mods: { atkPct: num(bb.atk), defPct: num(bb.def) }, onStart({ battle, unit, skill }) { unit.mem.billroCharged = isCharged(skill); diff --git a/server/sim/content/kits/ops/chess_char_5_15-thorn2.js b/server/sim/content/kits/ops/chess_char_5_15-thorn2.js index 73f3794d..77c0cd72 100644 --- a/server/sim/content/kits/ops/chess_char_5_15-thorn2.js +++ b/server/sim/content/kits/ops/chess_char_5_15-thorn2.js @@ -121,10 +121,13 @@ export default { }), skill: { kind: maxCharges(chess, def) > 1 ? 'charges' : 'instant', + // 白铁's 铁钳号·原型机 (a registered ally target) is a target like an enemy when no enemy is in her range — the owner's + // rule of 2026-10-08: the unit lands on it and its pulses hit it (cancelled by its kit) [ASSUMED] (skills.js allyTargetsOk) + allyTargets: true, onStart({ battle, unit }) { const list = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile); sortEnemyTargets(battle, unit, list, null); - let t = list.find((e) => !e.isFlying) ?? list[0]; + let t = list.find((e) => !e.isFlying) ?? list[0] ?? battle.allyTargetsInKeys(unit.rangeKeys, unit)[0]; if (!t) { // PRTS 备注: no enemy in range → thrown at the farthest tile straight ahead inside her range let best = null; @@ -191,6 +194,8 @@ export default { if (z.acc >= 1 - 1e-9) { z.acc -= 1; for (const e of foes) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'alchemy'] }); + // (白铁's 铁钳号 too, like a ground enemy — its kit cancels the hit) + for (const a of battle.allyTargetsInRadius(z.x, z.y, r, unit)) battle.dealDamage(unit, a, { amount: unit.s.atk * num(bb.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'alchemy'] }); const regen = unit.s.atk * num(bb.hp_recovery_per_sec_ratio_chr); // 生命回复速度 (see the header) if (regen > 0) { for (const a of battle.alliesInRadius(z.x, z.y, r, null)) { diff --git a/server/sim/content/kits/ops/chess_char_5_17-f12yin.js b/server/sim/content/kits/ops/chess_char_5_17-f12yin.js index a70f7d41..e2a91ea7 100644 --- a/server/sim/content/kits/ops/chess_char_5_17-f12yin.js +++ b/server/sim/content/kits/ops/chess_char_5_17-f12yin.js @@ -39,6 +39,9 @@ export default { }), skill: { kind: 'toggle', + // [ASSUMED] its attacks reach the enemies he blocks; the unblockable 铁钳号 of 白铁 never: it does not open it (skills.js + // allyTargetsOk) + allyTargets: false, mods: mods({ atkPct: num(bb.atk), defPct: num(bb.def), blockCnt: num(bb.block_cnt), hpRegenRatio: num(bb.hp_recovery_per_sec_by_max_hp_ratio) }), targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined, attack: { hitAllBlocked: true }, diff --git a/server/sim/content/kits/ops/chess_char_6_01-lemuen.js b/server/sim/content/kits/ops/chess_char_6_01-lemuen.js index 3e4f8df1..230f2938 100644 --- a/server/sim/content/kits/ops/chess_char_6_01-lemuen.js +++ b/server/sim/content/kits/ops/chess_char_6_01-lemuen.js @@ -58,7 +58,11 @@ function lemuen(bb, chess, def) { const d2 = num(bb['attack@dist_2'], num(bb['attack@projectile_range'], 1.5)); const s1 = num(bb['attack@proj_atk_scale_1'], 1), s2 = num(bb['attack@proj_atk_scale_2'], 1); const pickLock = (battle, unit, locks) => { - const cands = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile); + let cands = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile); + // 白铁's 铁钳号·原型机 (a registered ally target, an enemy-camp summon) is locked like an enemy when no enemy is in her + // range — the owner's rule of 2026-10-08 (it draws aggro like an enemy): her bullets are spent on it, the shells' hits + // are cancelled by it [ASSUMED: after every enemy, its 嘲讽等级 −2] + if (!cands.length) cands = battle.allyTargetsInKeys(unit.rangeKeys, unit); if (!cands.length) return null; sortEnemyTargets(battle, unit, cands, 'lowDef'); const cnt = (e) => locks.reduce((n, L) => n + (L.e === e ? 1 : 0), 0); @@ -85,6 +89,10 @@ function lemuen(bb, chess, def) { const d = bodyDist(e, x, y); battle.dealDamage(unit, e, { amount: atk * (d <= d1 + 1e-9 ? s1 : s2), type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'bombard'] }); } + // (the 铁钳号 too, like an enemy — its kit cancels the hit) + for (const a of battle.allyTargetsInRadius(x, y, d2, unit)) { + battle.dealDamage(unit, a, { amount: atk * s2, type: 'phys', isSkill: true, isSplash: true, tags: ['skill', 'bombard'] }); + } }; const bombard = (battle, unit, locks) => { const atk = unit.s.atk; @@ -164,6 +172,7 @@ function lemuen(bb, chess, def) { kind: 'ammo', ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 5))), attack: { noAttack: true }, + allyTargets: true, // its locks take 白铁's 铁钳号 too (pickLock) — skills.js allyTargetsOk onStart({ unit }) { unit.mem.lemLocks = []; unit.mem.lemAcc = aim; }, onTick({ battle, unit, skill, dt }) { const m = unit.mem; diff --git a/server/sim/content/kits/ops/chess_char_6_03-yu.js b/server/sim/content/kits/ops/chess_char_6_03-yu.js index d5bd4ea0..4273efbb 100644 --- a/server/sim/content/kits/ops/chess_char_6_03-yu.js +++ b/server/sim/content/kits/ops/chess_char_6_03-yu.js @@ -51,6 +51,8 @@ function yu(bb, chess, def) { // 'teleport' fx). PRTS's 0.13 s between the damage and the teleport is not modelled (same tick). skchr_yu_2: { kind: 'duration', + // [ASSUMED] its burst and teleport pick enemies; 白铁's 铁钳号 alone does not open it (skills.js allyTargetsOk) + allyTargets: false, mods: { hpPct: num(bb.max_hp), atkPct: num(bb.atk), blockCnt: num(bb.block_cnt) }, attack: { dmgType: 'arts' }, onStart({ battle, unit }) { @@ -79,6 +81,7 @@ function yu(bb, chess, def) { skill: { kind: 'duration', mods: { hpPct: num(bb.max_hp), atkPct: num(bb.atk), defPct: num(bb.def) }, + allyTargets: false, // [ASSUMED] its wall burns enemies crossing it; 白铁's 铁钳号 alone does not open it // the wall runs through his tile perpendicular to his direction (RIGHT / LEFT: his column; UP / DOWN: his row — // it turns with the deploy direction like a range) [ASSUMED orientation for UP / DOWN] onStart({ battle, unit }) { diff --git a/server/sim/content/kits/ops/chess_char_6_12-rosmon.js b/server/sim/content/kits/ops/chess_char_6_12-rosmon.js index b3d82b43..37db5980 100644 --- a/server/sim/content/kits/ops/chess_char_6_12-rosmon.js +++ b/server/sim/content/kits/ops/chess_char_6_12-rosmon.js @@ -31,6 +31,8 @@ function rosmon(bb, chess, def) { // two 战术装备 on melee tiles of her range (the token kit: appear stun, blocked enemies DEF −160, lifetime) skchr_rosmon_3: { kind: 'duration', + // [ASSUMED] blocked enemies only; 白铁's unblockable 铁钳号 never: it does not open it (skills.js allyTargetsOk) + allyTargets: false, mods: { batPct: batOf(bb.base_attack_time, def, true), atkPct: num(bb.atk) }, targeting: { maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 2))) }, onStart({ battle, unit }) { diff --git a/server/sim/content/kits/ops/chess_char_6_18-whitw2.js b/server/sim/content/kits/ops/chess_char_6_18-whitw2.js index 08efbb74..5332a725 100644 --- a/server/sim/content/kits/ops/chess_char_6_18-whitw2.js +++ b/server/sim/content/kits/ops/chess_char_6_18-whitw2.js @@ -128,6 +128,8 @@ function whitw2(bb, chess, def) { // grid only selects those targets — no rangeId, no 攻击范围 in the text — so the card keeps her 3-1 (showOwnRange) skchr_whitw2_1: { kind: 'toggle', + // [ASSUMED] her drones lock enemies only (install): 白铁's 铁钳号 alone does not open S1 / S2 (skills.js allyTargetsOk) + allyTargets: false, mods: { atkPct: num(bb.atk) }, targeting: { rangeGrid: WHOLE_FIELD, showOwnRange: true }, onStart({ unit }) { unit.mem.lazyLock = null; }, @@ -136,6 +138,7 @@ function whitw2(bb, chess, def) { // (install); each drone hit ramps on its own target (trait init → max) and fears it attack@fear s with attack@prob skchr_whitw2_2: { kind: 'duration', + allyTargets: false, mods: { atkPct: num(bb.atk) }, ...(skillGridW ? { targeting: { rangeGrid: skillGridW } } : {}), attack: { diff --git a/server/sim/content/kits/ops/op-chen.js b/server/sim/content/kits/ops/op-chen.js index 878acfaa..408a8d5b 100644 --- a/server/sim/content/kits/ops/op-chen.js +++ b/server/sim/content/kits/ops/op-chen.js @@ -114,9 +114,13 @@ export default { }, [S2]: { kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant', + // 白铁's 铁钳号·原型机 (a registered ally target) is drawn on like an enemy, after every enemy (嘲讽等级 −2) — the + // owner's rule of 2026-10-08; its kit cancels the hits [ASSUMED] (skills.js allyTargetsOk) + allyTargets: true, onStart({ battle, unit }) { const list = battle.enemiesInKeys(keysOf(unit, grid2), unit, AIR); sortEnemyTargets(battle, unit, list, null); + list.push(...battle.allyTargetsInKeys(keysOf(unit, grid2), unit)); const v = list.slice(0, Math.max(1, Math.floor(num(b2.max_target, 1)))); battle.fx('slash', { x: unit.x, y: unit.y, id: unit.id, n: v.length, skill: 'chen:draw' }); const amount = () => unit.s.atk * num(b2.atk_scale, 1); @@ -132,6 +136,9 @@ export default { duration: S3_CLIP, flags: { invulnerable: true, noBlock: true }, attack: { noAttack: true }, + // [ASSUMED] its slashes and its guard (guardActivation) take ground enemies only: 白铁's 铁钳号 alone does not open + // it (skills.js allyTargetsOk) — the skill ends at once without a target + allyTargets: false, onStart({ battle, unit }) { battle.releaseBlocked(unit); // 无法阻挡 unit.mem.chenS3 = { t: 0, n: 0, target: null }; diff --git a/server/sim/content/kits/ops/op-haruka.js b/server/sim/content/kits/ops/op-haruka.js index eec091ea..fe0a1ada 100644 --- a/server/sim/content/kits/ops/op-haruka.js +++ b/server/sim/content/kits/ops/op-haruka.js @@ -168,9 +168,10 @@ export default { return { trait: atkTargets > 1 ? { maxTargets: atkTargets } : null, skills: { - [S1]: { kind: 'duration', mods: { aspd: num(b1.attack_speed) }, attack: healAttack(healTargets) }, + // [ASSUMED] her skills' attacks heal: 白铁's 铁钳号 (禁疗, no patient) alone does not open them (skills.js allyTargetsOk) + [S1]: { kind: 'duration', mods: { aspd: num(b1.attack_speed) }, attack: healAttack(healTargets), allyTargets: false }, [S2]: { - kind: 'duration', attack: healAttack(healTargets + Math.max(0, Math.floor(num(b2['attack@max_target_heal_add'])))), + kind: 'duration', attack: healAttack(healTargets + Math.max(0, Math.floor(num(b2['attack@max_target_heal_add'])))), allyTargets: false, onStart({ battle, unit }) { unit.mem.harukaS2Uses = (unit.mem.harukaS2Uses ?? 0) + 1; unit.mem.harukaS2Endless = unit.mem.harukaS2Uses >= 2; @@ -185,6 +186,7 @@ export default { kind: 'duration', mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) }, targeting: { rangeGrid: s3?.rangeGrid ?? null }, + allyTargets: false, attack: healAttack(healTargets), onStart({ battle, unit }) { rescale(battle, unit); }, onEnd({ battle, unit }) { rescale(battle, unit); }, // (`active` is already false: ×1 again) diff --git a/server/sim/content/kits/ops/op-irene.js b/server/sim/content/kits/ops/op-irene.js index 68f69034..70581aa0 100644 --- a/server/sim/content/kits/ops/op-irene.js +++ b/server/sim/content/kits/ops/op-irene.js @@ -100,6 +100,9 @@ export default { }, [S2]: { kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant', + // [ASSUMED] its strike and its guard (guardActivation) take ground enemies only: 白铁's 铁钳号 alone does not open + // it (skills.js allyTargetsOk) + allyTargets: false, onStart({ battle, unit }) { const list = battle.enemiesInKeys(keysOf(unit, grid2), unit, GROUND); sortEnemyTargets(battle, unit, list, null); @@ -116,9 +119,12 @@ export default { kind: 'duration', duration: S3_DURATION, attack: { noAttack: true }, + // 白铁's 铁钳号·原型机 (a registered ally target) is struck like an enemy — the owner's rule of 2026-10-08; its kit + // cancels the hits [ASSUMED: its strikes take it like a ground enemy of the skill range] (skills.js allyTargetsOk) + allyTargets: true, onStart({ battle, unit }) { unit.mem.ireneS3 = { t: 0, n: 0 }; - const v = battle.enemiesInKeys(keysOf(unit, grid3), unit, GROUND); + const v = [...battle.enemiesInKeys(keysOf(unit, grid3), unit, GROUND), ...battle.allyTargetsInKeys(keysOf(unit, grid3), unit)]; battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1.5, id: unit.id, skill: 'irene:judgment' }); for (const e of v) { battle.dealDamage(unit, e, { amount: unit.s.atk * num(b3.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] }); @@ -132,11 +138,12 @@ export default { const iv = Math.max(0.05, num(b3.multi_hit_interval, 0.3)), times = Math.max(0, Math.floor(num(b3.multi_times, 10))); while (m.n < times && m.t + 1e-9 >= (m.n + 1) * iv) { m.n++; - const cands = battle.enemiesInKeys(keysOf(unit, grid3), unit, AIR); + const foes = battle.enemiesInKeys(keysOf(unit, grid3), unit, AIR); + const cands = foes.length ? foes : battle.allyTargetsInKeys(keysOf(unit, grid3), unit); // (the 铁钳号 when no enemy) if (!cands.length) continue; const c = battle.rng.pick(cands); battle.fx('aoe', { x: c.x, y: c.y, radius: S3_SPLASH, id: unit.id, skill: 'irene:judgment' }); - for (const e of battle.foesInRadius(c.x, c.y, S3_SPLASH, true)) { + for (const e of [...battle.foesInRadius(c.x, c.y, S3_SPLASH, true), ...battle.allyTargetsInRadius(c.x, c.y, S3_SPLASH, unit)]) { battle.dealDamage(unit, e, { amount: unit.s.atk * num(b3.multi_atk_scale, 1), type: 'phys', isSkill: true, isSplash: e !== c, tags: ['skill'] }); } if (!unit.alive || !unit.skill?.active) return; diff --git a/server/sim/content/kits/ops/op-mcnist.js b/server/sim/content/kits/ops/op-mcnist.js index 31cd8bc2..ad8d43bf 100644 --- a/server/sim/content/kits/ops/op-mcnist.js +++ b/server/sim/content/kits/ops/op-mcnist.js @@ -269,6 +269,8 @@ export default { [S3]: { kind: 'duration', mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) }, + // [ASSUMED] the cross lands on an enemy target only: 白铁's 铁钳号 alone does not open it (skills.js allyTargetsOk) + allyTargets: false, // the attack is the cross: no damage on the target itself (its 0.8 s later landing does it) attack: { dmgType: 'arts', hitsFn: () => 0 }, onAttack({ battle, unit, targets }) { diff --git a/server/sim/content/kits/ops/op-slent2.js b/server/sim/content/kits/ops/op-slent2.js index 686e1d71..8ede1593 100644 --- a/server/sim/content/kits/ops/op-slent2.js +++ b/server/sim/content/kits/ops/op-slent2.js @@ -158,9 +158,10 @@ export default { return { trait: targets > 1 ? { maxTargets: targets } : null, skills: { - [S1]: { kind: 'duration', mods: { atkPct: num(b1.atk) }, attack: healAttack }, + // [ASSUMED] her skills' attacks heal: 白铁's 铁钳号 (禁疗, no patient) alone does not open them (skills.js allyTargetsOk) + [S1]: { kind: 'duration', mods: { atkPct: num(b1.atk) }, attack: healAttack, allyTargets: false }, [S2]: { - kind: 'duration', mods: { aspd: num(b2.attack_speed) }, attack: healAttack, + kind: 'duration', mods: { aspd: num(b2.attack_speed) }, attack: healAttack, allyTargets: false, onStart({ battle, unit }) { // a 夜灯 already up (the battle-start deploy) is this skill's drone; else her placed piece takes the field if (!battle.allyUnits.some((t) => t.kind === 'token' && t.defId === LAMP && t.ownerUnit === unit && t.alive)) releaseSkillSummon(battle, unit, LAMP); @@ -168,7 +169,7 @@ export default { onEnd({ battle, unit }) { withdrawLamps(battle, unit); }, }, [S3]: { - kind: 'duration', mods: { atkPct: num(b3.atk) }, attack: healAttack, + kind: 'duration', mods: { atkPct: num(b3.atk) }, attack: healAttack, allyTargets: false, onStart({ unit }) { unit.mem.slent2Grave = true; }, // the one 不死 of this cast (slent2_shallow_grave[self]) onEnd({ unit }) { unit.mem.slent2Grave = false; }, }, diff --git a/server/sim/content/kits/ops/op-thumpy.js b/server/sim/content/kits/ops/op-thumpy.js index b6dcd328..073045a6 100644 --- a/server/sim/content/kits/ops/op-thumpy.js +++ b/server/sim/content/kits/ops/op-thumpy.js @@ -177,6 +177,8 @@ export default { }, [S2]: { kind: 'duration', + // [ASSUMED] its glue / belt act on ground enemies only: 白铁's 铁钳号 alone does not open it (skills.js allyTargetsOk) + allyTargets: false, // the owner's rule for a skill whose area is larger than her range (her tile): cast as soon as a ground enemy she // can affect is on the glue's first five tiles — her tile, the front one, its two sides and the one beyond // (glueTiles' PRTS order), as 余 S2's x-1 (§22.10); the data's DEFAULT (重装 exception) only saw her own tile @@ -200,6 +202,7 @@ export default { }, [S3]: { kind: 'duration', + allyTargets: false, // likewise on the conveyor: her tile and the four ahead (the owner's larger-area rule; 余 S2 precedent) trigger: { rule: 'ACTIVE_RANGE', grid: [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4]] }, mods: nz({ atkPct: num(b3.atk), defPct: num(b3.def), blockCnt: num(b3.block_cnt) }), diff --git a/server/sim/skills.js b/server/sim/skills.js index ba126e9f..6c5a866a 100644 --- a/server/sim/skills.js +++ b/server/sim/skills.js @@ -30,7 +30,9 @@ // rule AND such an ally on the trigger grid — the cast replaces the attack about to be made (塞雷娅 S1 "触发时会替换当 // 次攻击"); should that ally condition fail before the attack, the cast is withdrawn and its charge returned. // A registered ally target (Battle.setAllyTarget: 白铁's 铁钳号·原型机, an enemy-camp summon our attacks select) counts -// like an enemy in each of these enemy conditions for a non-heal skill (`_allyTargetIn` — the owner's rule of 2026-10-08). +// like an enemy in each of these enemy conditions for a skill that acts on it (`_allyTargetIn`, `allyTargetsOk` — the +// owner's rule of 2026-10-08): one that acts through the unit's own attacks, or whose kit flags `allyTargets: true` (its +// selectors take ally targets too); a skill that picks its own victims among enemies only is not opened by it. // Automatic operations cool down (constants.js AUTO_OP_COOLDOWN, "自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位 // 将进入冷却"): the engine auto-casts a MANUAL skill (def.skillType) no sooner than 3 s after its previous cast (so a // charged skill spends its charges 3 s apart) or after the unit's deployment at the battle start (Battle._deploy @@ -44,7 +46,8 @@ // mods, flags, targeting {maxTargets, rangeGrid, priority, allInRange, rangeExtend, noRangeExtend (the range ignores // the unit's 攻击距离), showOwnRange (rangeGrid only selects targets: the detail card keeps the unit's own range)}, // attack {dmgType, atkScale, splashRadius, splashScale, hits, projectile, maxTargets, dmgMul, onHit, heal…}, -// heal (bool: heal-type skill for the trigger rule), onStart(ctx), onEnd(ctx), onHit(ctx), onAttack(ctx), onTick(ctx). +// heal (bool: heal-type skill for the trigger rule), allyTargets (bool: the skill acts on a registered ally target — see +// `allyTargetsOk`), onStart(ctx), onEnd(ctx), onHit(ctx), onAttack(ctx), onTick(ctx). // ctx passed to spec callbacks: { battle, unit, skill, bb, target?, dealt?, targets?, dt?, reason? }; onAttack's ctx // also carries `noAmmo` (set it to true: this attack spends no ammo). onEnd runs while the skill's mods / range are // still applied (`active` is already false); they are removed right after it (unless onEnd re-activated the skill). @@ -177,11 +180,31 @@ export class SkillRuntime { * canTargetEnemy (every other selector keeps to enemies); a heal skill never counts it (禁疗: not a patient). */ _allyTargetIn(keys) { - if (this.healSkill || !keys) return false; + if (!keys || !this.allyTargetsOk) return false; const b = this.battle; return !!(b._allyTargets && b._allyTargets.size) && b.allyTargetsInKeys(keys, this.unit).length > 0; } + /** + * Does this skill act on a registered ally target, so that one may start it (`_allyTargetIn`)? Never a heal skill. + * SkillSpec `allyTargets` (true / false) answers for a kit: true where its selectors take ally targets too + * (allyTargetsInKeys / allyTargetsInRadius — 蕾缪安 S3, 艾丽妮 S3, 陈 S2, 引星棘刺 S2), false where they never do although + * the skill keeps attacking. Without it: a skill that acts through the unit's own attacks — which select the device + * (ai.js acquireTargets) —, a timed one that keeps attacking (no `attack.noAttack`) or a cast that is the next attack + * (`spec.attack`), on a unit that attacks. [ASSUMED] Any other skill picks its victims itself among the enemies + * (enemiesInKeys, foesInRadius …) and would be spent on nothing: the device does not open it — it waits for an enemy. + * (Grok's review of round 22: 蕾缪安 S3 / 艾丽妮 S3 / 陈 S2 / 引星棘刺 S2 opened on the device alone and did nothing.) + */ + get allyTargetsOk() { + if (this.healSkill || this.noSkill || this.kind === 'passive') return false; + const s = this.spec; + if (s.allyTargets != null) return !!s.allyTargets; + const p = this.unit.profile; + if (p && p.noAttack) return false; + if (s.attack && s.attack.noAttack) return false; + return this.isTimed || !!s.attack; + } + get spCost() { const c = Math.floor(this.baseSpCost * this.spCostMul + (this.unit.s ? this.unit.s.spCostFlat : 0)); // content writes spCostMul / spCostFlat: a NaN/Infinity cost would freeze SP forever — use the base cost instead diff --git a/test/content/feedback7-allytarget-kits.test.js b/test/content/feedback7-allytarget-kits.test.js new file mode 100644 index 00000000..8ff8d2d5 --- /dev/null +++ b/test/content/feedback7-allytarget-kits.test.js @@ -0,0 +1,106 @@ +// test/content/feedback7-allytarget-kits.test.js — a skill that 白铁's 铁钳号·原型机 (a registered ally target) opens must act on +// it (Grok's review of round 22: 蕾缪安 S3, 艾丽妮 S3, 陈 S2 and 引星棘刺 S2 opened on the device alone and were spent on +// nothing — their kits picked enemies only). The owner's rule (2026-10-08): the device draws aggro like an enemy. So: +// - these four kits now take ally targets in their selectors (the hits are cancelled by the device, its 受击回复 counts; +// 蕾缪安's locks spend her bullets); +// - a skill whose effect does not reach it — one that picks its own victims and has no kit flag `allyTargets: true` — is +// not opened by it (skills.js `allyTargetsOk`), e.g. 德克萨斯 S2 剑雨: it waits for an enemy. +// Run: node --test test/content/feedback7-allytarget-kits.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; + +const CRAB = 'token_10027_ironmn_pile3'; +const IRON = { uid: 2, diy: { slot: 6, charId: 'char_4072_ironmn', skillIndex: 2 }, elite: true, row: 12, col: 9, dir: 'RIGHT' }; +const crabAt = (col) => ({ uid: 10, kind: 'token', tokenId: CRAB, ownerUid: 2, row: 10, col, dir: 'RIGHT' }); +const diy = (charId, skillIndex) => ({ uid: 1, diy: { slot: 6, charId, skillIndex }, elite: true, row: 10, col: 4, dir: 'RIGHT' }); + +function field(unit, extra = {}) { + const h = makeBattle({ + timeLimit: 60, autoFinish: false, seed: 3, flags: { dpPerSec: 0, dpMax: 999, dpInit: 99 }, + hooks: ['hit', 'skillStart', 'skillEnd', 'ammoUsed'], captureNoisy: true, + defs: { enemies: { e_d: enemyRec({ key: 'e_d', hp: 1e7, speed: 0, def: 0, atk: 0 }) } }, + units: [unit, IRON, crabAt(5)], ...extra, + }); + h.step(); + const u = h.unit(1); + const crab = h.b.allyUnits.find((a) => a.defId === CRAB); + return { h, u, crab }; +} +/** Hits of `u`'s skill on the device (cancelled by it). */ +const skillHits = (h, u, crab) => h.hooksOf('hit').filter((c) => c.target === crab && (c.source === u || c.credit === u) && c.dmg?.isSkill); + +test('蕾缪安 S3: on the device alone she locks it, spends her bullets on it and ends with the last one', () => { + const { h, u, crab } = field({ uid: 1, chessId: 'chess_char_6_01_a', skillIndex: 2, row: 10, col: 4, dir: 'RIGHT' }); + assert.equal(u.skill.id, 'skchr_lemuen_3'); + u.skill.gainSp(999, 'test'); + const ammo = u.skill.ammo; + assert.ok(h.runUntil(() => u.skill.activations > 0, 5), 'opened'); + assert.ok(h.runUntil(() => !u.skill.active, 15), 'ended'); + assert.ok(h.hooksOf('skillEnd').some((c) => c.unit === u && c.reason === 'ammo'), 'by its bullets'); + assert.ok(h.hooksOf('ammoUsed').filter((c) => c.unit === u).length >= ammo, `${ammo} bullets spent`); + assert.equal(crab.hp, crab.s.maxHp); + checkInvariants(h.b); +}); + +test('艾丽妮 S3 / 陈 S2: on the device alone their strikes land on it (cancelled)', () => { + for (const [charId, si, id] of [['char_4009_irene', 2, 'skchr_irene_3'], ['char_010_chen', 1, 'skchr_chen_2']]) { + const { h, u, crab } = field(diy(charId, si)); + assert.equal(u.skill.id, id); + u.skill.gainSp(999, 'test'); + assert.ok(h.runUntil(() => u.skill.activations > 0, 5), `${id} opened`); + h.run(4); + assert.ok(skillHits(h, u, crab).length >= 1, `${id} hit the device`); + assert.equal(crab.hp, crab.s.maxHp); + checkInvariants(h.b); + } +}); + +test('引星棘刺 S2: on the device alone the alchemy unit lands on it, not on the far tile ahead', () => { + const { h, u, crab } = field({ uid: 1, chessId: 'chess_char_5_15_a', skillIndex: 1, row: 10, col: 4, dir: 'RIGHT' }); + assert.equal(u.skill.id, 'skchr_thorn2_2'); + u.skill.gainSp(999, 'test'); + assert.ok(h.runUntil(() => u.skill.activations > 0, 5), 'opened'); + const z = u.mem.zones?.[0]; + assert.ok(z, 'a zone'); + assert.ok(Math.abs(z.x - crab.x) < 0.3 && Math.abs(z.y - crab.y) < 0.3, `thrown at the device (${z.x}, ${z.y})`); + checkInvariants(h.b); +}); + +test('a skill whose effect picks enemies only (德克萨斯 S2 剑雨) is not opened by the device; an enemy opens it', () => { + const { h, u } = field({ uid: 1, chessId: 'chess_char_1_08_a', skillIndex: 1, row: 10, col: 4, dir: 'RIGHT' }); + assert.equal(u.skill.id, 'skchr_texas_2'); + u.skill.gainSp(999, 'test'); + const ch = u.skill.charges; + h.run(5); + assert.equal(u.skill.activations, 0, 'kept for an enemy'); + assert.equal(u.skill.charges, ch); + h.spawn('e_d', { pos: [10, 6] }); + assert.ok(h.runUntil(() => u.skill.activations > 0, 5), 'an enemy opens it'); + checkInvariants(h.b); +}); + +test('the engine rule: the device opens a skill acting through the attacks or flagged allyTargets — not an onStart-only or a no-attack one', () => { + const id = 'test_cast_a'; + const opens = (skill) => { + const h = makeBattle({ + defs: { chess: { [id]: chessRec({ id, profession: 'SNIPER', rangeGrid: [[0, 0], [0, 1]], skill: { skillType: 'MANUAL', spCost: 1, initSp: 1 } }) } }, + kits: { [id]: () => ({ skill: { spCost: 1, initSp: 1, trigger: 'DEFAULT', ...skill } }) }, + units: [{ uid: 1, chessId: id, row: 10, col: 4, dir: 'RIGHT' }, IRON, crabAt(5)], + timeLimit: 20, autoFinish: false, seed: 2, flags: { dpPerSec: 0, dpMax: 999 }, + }); + h.step(); + const u = h.unit(1); + u.skill.gainSp(999, 'test'); + h.run(3); + checkInvariants(h.b); + return u.skill.activations > 0; + }; + assert.equal(opens({ kind: 'duration', duration: 3 }), true, 'a timed skill that keeps attacking'); + assert.equal(opens({ kind: 'instant', attack: { atkScale: 2 } }), true, 'a cast that is the next attack'); + assert.equal(opens({ kind: 'instant', onStart() {} }), false, 'an onStart-only cast'); + assert.equal(opens({ kind: 'duration', duration: 3, attack: { noAttack: true } }), false, 'a timed skill that stops attacking'); + assert.equal(opens({ kind: 'instant', onStart() {}, allyTargets: true }), true, 'flagged by its kit'); + assert.equal(opens({ kind: 'duration', duration: 3, allyTargets: false }), false, 'opted out by its kit'); +});