From 37e45e362e18771de70c4d6b2587e21374b16647 Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 18:54:36 +0800 Subject: [PATCH] =?UTF-8?q?feedback7=20R22-OSIM:=20=E7=99=BD=E9=93=81's=20?= =?UTF-8?q?=E9=93=81=E9=92=B3=E5=8F=B7=C2=B7=E5=8E=9F=E5=9E=8B=E6=9C=BA=20?= =?UTF-8?q?counts=20like=20an=20enemy=20for=20a=20non-heal=20skill's=20aut?= =?UTF-8?q?omatic=20start;=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Community report relayed by the owner: 圣约送葬人 hitting 白铁's device spent no bullet and gained no layer. Our attacks already selected the device (Battle.setAllyTarget, damage cancelled), but every skill start condition counted enemies only, so her MANUAL ammo skill (DEFAULT) never opened. The owner's rule of 2026-10-08: a registered ally target in range counts like an enemy for a non-heal skill's auto start. - skills.js `_allyTargetIn`: DEFAULT (about to attack, the initial range; SEARCH / ACTIVE_RANGE through it), CUSTOM_RANGE, SKILL_RANGE, GDGLOW_SKILL_2 (anywhere) and the content trigger ranges. Heal skills never count it (禁疗). It stays out of enemiesInKeys / canTargetEnemy. - 雪猎's empty-magazine S2 cast counts it too; allyTargetsInKeys takes a Set of tile keys. - 白铁™多功能平台 stays no target. Kits whose own selectors pick enemies (陈 S3, 艾丽妮 S2 guards, 蕾缪安 S3 locks) are unchanged. Tests: test/sim/feedback7-allytarget-cast.test.js (4; 3 fail on the parent). --- docs/PLAYING.md | 2 +- docs/SIM.md | 6 +- docs/design/engine.md | 2 +- server/sim/battle/queries.js | 9 +- .../kits/ops/chess_char_3_11-snhunt.js | 4 +- server/sim/content/kits/ops/op-ironmn.js | 3 +- server/sim/skills.js | 31 ++++- test/sim/feedback7-allytarget-cast.test.js | 117 ++++++++++++++++++ 8 files changed, 158 insertions(+), 16 deletions(-) create mode 100644 test/sim/feedback7-allytarget-cast.test.js diff --git a/docs/PLAYING.md b/docs/PLAYING.md index f357981a..2234dc3f 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -76,7 +76,7 @@ - 作战中:海嗣、狼群、流形、支援装置开战时就在摆放的位置部署;医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次(持续 10 / 15 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、海嗣(以及自选干员的工程蓄水炮、沙地兽、打字机、本能的召唤)这类不会受到攻击、到时间就离场的召唤物不受任何伤害(活性源石也一样)、也不会被治疗,它们的血条就是倒计时:随时间减少,空了就离场。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。狼群最后一只狼影被击倒后进入战术点形态:暂时离开战斗(不阻挡、不攻击、不会被攻击,格子仍然占着),经过狼影的恢复时间(25 秒)后在原地满血、带着 1 只狼影回来,之后重新开始每 25 秒加 1 只狼影;伺夜退场时狼群随之撤退,伺夜再部署回场时狼群重新带着 2 只狼影出现。 - **阻挡**看接触:干员阻挡中心距离约 0.7 格以内、与它接触的敌人(地面干员挡不住飞行和近地悬浮的敌人)。敌人卡在两名干员之间时,阻挡它的干员倒下、撤退或被晕眩后,另一名仍与它接触、还有空余阻挡数的干员会**立刻接替阻挡**;都没有空位时敌人才会走掉。被冻结或晕眩的敌人同样会被接触到的干员挡住:干员再部署到它身边就会阻挡它,隐匿的敌人也随之破隐(沉睡、浮空、恐惧的敌人仍然挡不住)。迎面走来的敌人会停在干员格子前面。**阻挡了就一定能打到**:任何干员(包括站在近战位的远程干员和召唤物)总能攻击自己阻挡的敌人,不管它在不在攻击范围内、在干员哪一侧,并且优先攻击它(医疗干员除外:咒愈师以外的医疗挡住敌人时照常治疗队友,不会攻击它;「停止攻击」类技能期间也除外;技能原文另有限制的也按原文:普罗旺斯二技能开启时不以生命值高于 80% 的敌人为目标,寒檀二技能的冰凌随机落在攻击范围内的地块上)。 - 开战时干员按**逐列从左到右、每列从上到下**的顺序部署(最左一列最先,与阿戈尔吞噬「更靠左和靠上」的顺序相同),召唤物在所有干员之后。被阻挡的敌人先打阻挡它的干员;没被阻挡的远程敌人先打嘲讽等级高的,同级时打最后部署的(召唤物、最右一列靠下的干员和刚再部署的干员更容易挨打)。隐匿的干员只会被它阻挡的敌人攻击,没被它挡住的敌人的溅射、爆炸、范围技能、留下的伤害区域(包括假想敌:蚀裂被击倒后喷出的毒雾)以及深池伙友卫队、扎罗的减攻速光环也落不到它身上(和官方一样;写明「无视无法选择」的效果如污染秽蚀照常能打到,寒霜的减攻速光环和“墓碑”这类地图效果照常生效);**迷彩**(如忍冬三技能期间击倒敌人后、寒芒克洛丝一技能)同样只会被它阻挡的敌人攻击,也显示为半透明,但躲不开溅射、爆炸和范围伤害(同时打范围内所有单位的普通攻击——如“斩胄之剑”“破胄之锤”“灵幛”——打不到它),也不算隐匿(不触发叙拉古等「隐匿时」效果)。叙拉古的隐匿按时(32 秒 + 每层 0.4 秒)结束;忍冬的迷彩按官方持续到她下一次开技能。**蒂比起飞**期间只挡飞行敌人,地面敌人不会选她为目标(普通攻击、溅射、技能范围、燃烧区域和要选中目标的减益都落不到她身上);飞行和悬浮敌人、写明「无视无法选择」的效果(如污染秽蚀)照常能打到她;地面敌人的光环和全场效果(如“墓碑”让全场攻击力、防御力减半)、她攻击敌人时招来的反制(如重装侦察兵的暴露)照常生效,起飞前已挂上的持续伤害也照常跳。 -- **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**开启后攻击范围比平时大**的手动技能(如莫斯提马「序时之匙」、银灰「真银斩」、史尔特尔「黄昏」、白面鸮「脑啡肽」)在**开启后的攻击范围内出现敌人**(治疗技能:有受伤的队友)时就释放,不必等敌人走进平时的攻击范围,这是本作按玩家反馈有意与官方不同的地方;**重装干员**的手动技能在受到伤害时释放(例外:深巡、雷蛇的二技能,号角的二、三技能,灰毫的一、二技能按玩家反馈改为攻击范围内有敌人时就释放(深巡二技能按开启后更远的攻击范围判断),余的二技能「厚礼上宾」改为周围两格(技能范围)内有敌人时就释放,这是本作有意与官方不同的地方;深巡一技能仍要受到伤害);执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放(开启后攻击范围扩大的技能——薄绿「风语」、蜜蜡「沙暴扩散」、卡涅利安「食噬之印」、玛恩纳「未宽解的悲哀」,以及安洁莉娜「秘杖·反重力模式」——同样按开启后的攻击范围判断)。**自动触发**的技能按其本身规则:只作用于队友的(如引星棘刺「度算浪波」、浊心斯卡蒂「同葬无光之愿」、纯烬艾雅法拉「无声润物」)技力满就释放,不用等敌人或伤员;伺夜「领袖的呼唤」「领袖的馈赠」只在场上有狼群时释放:狼群处于战术点形态时,「领袖的呼唤」也会释放并让它立刻带着 1 只狼影回到场上(狼影未满时改为加 1 只狼影,已满时把狼群的生命值回满),「领袖的馈赠」也会释放(立即获得费用,强化留给回来的狼群的下一次攻击);又如古米「备用军粮」在身边(技能范围内)有受伤的队友时就会发动,下一次攻击改为治疗该队友(不用先接敌;她的二技能「食粮烹制」是手动技能,按重装规则在受到伤害时释放);塞雷娅「急救」在她即将攻击时、身边有生命不高于一半的队友才发动,这次攻击直接改为治疗;新约能天使二技能的护盾按官方给攻击范围内**仇恨最高**的队友(嘲讽等级高的优先,同级时给最后部署的),不一定是站在最前面挨打的那个。手动技能的自动释放有 3 秒冷却:开战部署后先等 3 秒,之后同一干员两次自动释放至少间隔 3 秒(可充能的技能不会同时放完)。 +- **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**开启后攻击范围比平时大**的手动技能(如莫斯提马「序时之匙」、银灰「真银斩」、史尔特尔「黄昏」、白面鸮「脑啡肽」)在**开启后的攻击范围内出现敌人**(治疗技能:有受伤的队友)时就释放,不必等敌人走进平时的攻击范围,这是本作按玩家反馈有意与官方不同的地方;**重装干员**的手动技能在受到伤害时释放(例外:深巡、雷蛇的二技能,号角的二、三技能,灰毫的一、二技能按玩家反馈改为攻击范围内有敌人时就释放(深巡二技能按开启后更远的攻击范围判断),余的二技能「厚礼上宾」改为周围两格(技能范围)内有敌人时就释放,这是本作有意与官方不同的地方;深巡一技能仍要受到伤害);执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放(开启后攻击范围扩大的技能——薄绿「风语」、蜜蜡「沙暴扩散」、卡涅利安「食噬之印」、玛恩纳「未宽解的悲哀」,以及安洁莉娜「秘杖·反重力模式」——同样按开启后的攻击范围判断)。**自动触发**的技能按其本身规则:只作用于队友的(如引星棘刺「度算浪波」、浊心斯卡蒂「同葬无光之愿」、纯烬艾雅法拉「无声润物」)技力满就释放,不用等敌人或伤员;伺夜「领袖的呼唤」「领袖的馈赠」只在场上有狼群时释放:狼群处于战术点形态时,「领袖的呼唤」也会释放并让它立刻带着 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 08a53a3f..53caf011 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -841,7 +841,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). 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 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 | | `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 }` | @@ -913,7 +913,9 @@ instance) and skip `'counter'` / `'reflect'` damage. When the guard trips, the l `GDGLOW_SKILL_2` — "全场存在可选目标时释放技能": a targetable enemy anywhere (a heal skill: an ally that needs healing), 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. Silence blocks activation. The automatic operations cool down (`constants.js AUTO_OP_COOLDOWN` 3 s, + 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, "自动操作具有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 9b90dee1..af5ada70 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. 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 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. **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 b4a526ae..5a6e74f8 100644 --- a/server/sim/battle/queries.js +++ b/server/sim/battle/queries.js @@ -45,14 +45,15 @@ export class BattleQueries { /** * Ally units an ally's attack selects like an enemy — the "ally targets" content registers (setAllyTarget): 白铁's * 铁钳号·原型机, a summon of the enemy camp that "可被我方干员攻击但不受伤害" (PRTS 铁钳号·原型机 备注 "该召唤物阵营为敌方"; - * kits/ops/op-ironmn.js). Those standing on one of `keys`, 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). [] at once - * while none is registered, so every other battle runs exactly as before. + * 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 + * once while none is registered, so every other battle runs exactly as before. */ allyTargetsInKeys(keys, attacker) { const set = this._allyTargets; if (!set || !set.size || !keys || !attacker || set.has(attacker)) return []; - const ks = keys === attacker.rangeKeys && attacker.rangeKeySet ? attacker.rangeKeySet : new Set(keys); + const ks = keys instanceof Set ? keys : (keys === attacker.rangeKeys && attacker.rangeKeySet ? attacker.rangeKeySet : new Set(keys)); const out = []; for (const a of set) if (a.alive && a.deployed && !a.hidden && ks.has(a.tileR * COLS + a.tileC)) out.push(a); return out; 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 1b14cf2c..002c2cfb 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,7 +58,9 @@ 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; - if (battle.enemiesInKeys(unit.baseRangeKeys || unit.rangeKeys, unit, unit.profile).length) sk.activate('DEFAULT'); + // (白铁'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'); }, { owner: unit }); } if (extra > 0) { diff --git a/server/sim/content/kits/ops/op-ironmn.js b/server/sim/content/kits/ops/op-ironmn.js index c52f76a9..71cd85d2 100644 --- a/server/sim/content/kits/ops/op-ironmn.js +++ b/server/sim/content/kits/ops/op-ironmn.js @@ -56,7 +56,8 @@ // 白铁's S2 runs: s2.interval s and s2.hp_ratio (both timers restart when the mode changes, as the client re-creates the // buffs on its mode switch). // - 铁钳号·原型机 (S3), an enemy-camp summon: our attacks select it like an enemy, after every real one (嘲讽等级 −2; -// Battle.setAllyTarget — the engine extension of this kit), enemies never (untargetable); every damage it takes is cancelled +// Battle.setAllyTarget — the engine extension of this kit), and a non-heal skill's automatic start counts it like an enemy +// (skills.js `_allyTargetIn`, the owner's rule of 2026-10-08), enemies never (untargetable); every damage it takes is cancelled // and gives it 1 SP (受击回复), and +hp_ratio of its max HP when the source is a 工匠 operator (trait, ignores 禁疗). 团结的力 // 量: the operator on its b-1 (its tile and the one behind it) takes physical damage ×(1 − damage_resistance) — one effect per // device. Its skill (AUTO, 5 SP, data DEFAULT — it never attacks, so the engine checks it every tick: an enemy, flyers too, in diff --git a/server/sim/skills.js b/server/sim/skills.js index 18316a1c..ba126e9f 100644 --- a/server/sim/skills.js +++ b/server/sim/skills.js @@ -29,6 +29,8 @@ // (noAttack profiles) evaluate DEFAULT every tick instead. DEFAULT with `trigger.allies` (+ `hpAtMost`): the basic // 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). // 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 @@ -161,12 +163,25 @@ export class SkillRuntime { if (Array.isArray(x)) keys = x; else if (x && typeof x === 'object' && x.side === 'ally' && x.alive && x.deployed && !x.hidden) keys = x.rangeKeys; else if (x && typeof x === 'object' && Array.isArray(x.keys)) { keys = x.keys; if (x.profile) prof = x.profile; } - if (keys && keys.length && b.enemiesInKeys(keys, u, prof).length) return true; + if (keys && keys.length && (b.enemiesInKeys(keys, u, prof).length || this._allyTargetIn(keys))) return true; } } return false; } + /** + * A registered ally target (Battle.setAllyTarget: 白铁's 铁钳号·原型机, a summon of the enemy camp our attacks select) on + * `keys` (tile keys or a Set): a non-heal skill's automatic start counts it like an enemy — the owner's rule of + * 2026-10-08 (a community report and their recall of the official mode: 干员攻击范围/技能范围内有白铁的装置时技能也自动开、自动 + * 消耗,装置对干员的仇恨类似敌方单位; PRTS 铁钳号·原型机 备注 "该召唤物阵营为敌方"). It stays out of enemiesInKeys / + * canTargetEnemy (every other selector keeps to enemies); a heal skill never counts it (禁疗: not a patient). + */ + _allyTargetIn(keys) { + if (this.healSkill || !keys) return false; + const b = this.battle; + return !!(b._allyTargets && b._allyTargets.size) && b.allyTargetsInKeys(keys, this.unit).length > 0; + } + 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 @@ -405,27 +420,31 @@ export class SkillRuntime { // SEARCH: an enemy inside the initial attack range, every tick (librators / phalanxes and 安洁莉娜 do not attack // while the skill is off, so DEFAULT's "about to attack" never comes) if (this.rule === 'SEARCH') return this._defaultCondition(); + // (CUSTOM_RANGE / SKILL_RANGE / GDGLOW_SKILL_2 / the DEFAULT condition: a registered ally target — 白铁's 铁钳号·原型机 — + // counts like an enemy for a non-heal skill, `_allyTargetIn`) if (this.rule === 'CUSTOM_RANGE') { if (!this.triggerGrid) return this._defaultCondition(); - return b.enemiesInKeys(this._triggerKeys(), u, { canHitFly: true }).length > 0; + const keys = this._triggerKeys(); + return b.enemiesInKeys(keys, u, { canHitFly: true }).length > 0 || this._allyTargetIn(this._trigSet); } if (this.rule === 'SKILL_RANGE') { if (this.triggerAllies) return this._allyTriggerSatisfied(); if (!this.triggerGrid) return this._defaultCondition(); - return b.anyEnemyInKeys(this._triggerKeys()); + return b.anyEnemyInKeys(this._triggerKeys()) || this._allyTargetIn(this._trigSet); } if (this.rule === 'ACTIVE_RANGE') return this._defaultCondition(this.triggerGrid ? this._triggerKeys() : null); // GDGLOW_SKILL_2 "全场存在可选目标时释放技能": a targetable enemy anywhere (heal skill: an ally that needs healing) if (this.rule === 'GDGLOW_SKILL_2') { if (this.healSkill) return b.injuredAlliesInKeys(ALL_TILES, u, !!u.profile?.heal?.elementHealRatio).length > 0; - return b.enemies.some((e) => canTargetEnemy(u, e, TRIGGER_PROFILE)); + return b.enemies.some((e) => canTargetEnemy(u, e, TRIGGER_PROFILE)) || this._allyTargetIn(ALL_TILES); } return false; } /** * DEFAULT rule condition: an enemy (or injured ally for heal skills) inside the initial range (baseRangeKeys: own - * grid + permanent rangeExtend), or an enemy inside a content trigger range (addTriggerRange; not for heal skills). + * grid + permanent rangeExtend) — a registered ally target there too (`_allyTargetIn`: 铁钳号·原型机; not for heal + * skills) — or an enemy inside a content trigger range (addTriggerRange; not for heal skills). * `range` (ACTIVE_RANGE): those absolute tile keys instead of the initial range. */ _defaultCondition(range = null) { @@ -435,7 +454,7 @@ export class SkillRuntime { const keys = range || u.baseRangeKeys || u.rangeKeys; if (keys) { if (this.healSkill) return b.injuredAlliesInKeys(keys, u).length > 0; - if (b.enemiesInKeys(keys, u, u.profile).length > 0) return true; + if (b.enemiesInKeys(keys, u, u.profile).length > 0 || this._allyTargetIn(keys)) return true; } // the enemies a unit blocks are always its targets (Battle.blockedTargets), in range or not — PRTS 卫戍协议/帮助 // "敌人被近战干员自身阻挡" satisfies the target condition of the basic strategy (a ranged blocker too: user playtest #6) diff --git a/test/sim/feedback7-allytarget-cast.test.js b/test/sim/feedback7-allytarget-cast.test.js new file mode 100644 index 00000000..2bc10d67 --- /dev/null +++ b/test/sim/feedback7-allytarget-cast.test.js @@ -0,0 +1,117 @@ +// test/sim/feedback7-allytarget-cast.test.js — a registered ally target (Battle.setAllyTarget: 白铁's 铁钳号·原型机, a +// summon of the enemy camp our attacks select — PRTS 铁钳号·原型机 备注 "该召唤物阵营为敌方") counts like an enemy for a +// non-heal skill's automatic start: the owner's rule of 2026-10-08 (community report: 圣约送葬人 hitting the device spent no +// bullet and gained no layer — her MANUAL ammo skill never opened, since every start condition counted enemies only). +// It stays out of enemiesInKeys / canTargetEnemy; a heal skill never counts it (禁疗); 白铁™多功能平台 is not one. +// Run: node --test test/sim/feedback7-allytarget-cast.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; +import { COLS } from '../../server/sim/constants.js'; + +const CRAB = 'token_10027_ironmn_pile3'; +const iron = (skillIndex) => ({ uid: 2, diy: { slot: 6, charId: 'char_4072_ironmn', skillIndex }, elite: true, row: 12, col: 9, dir: 'RIGHT' }); +const IRON = iron(2); // S3: his pieces are 铁钳号·原型机 (S1: 白铁™多功能平台 极致火力) +const piece = (tokenId, col) => ({ uid: 10, kind: 'token', tokenId, ownerUid: 2, row: 10, col, dir: 'RIGHT' }); + +function field(units, extra = {}) { + const h = makeBattle({ + timeLimit: 40, autoFinish: false, seed: 3, flags: { dpPerSec: 0, dpMax: 999 }, + hooks: ['ammoUsed', 'skillStart', 'layerGain', 'attack'], captureNoisy: true, units, ...extra, + }); + h.step(); + return h; +} + +test('圣约送葬人 S2 (MANUAL, DEFAULT, 12 bullets) opens with 铁钳号 alone in range, spends its bullets on it, and the 每消耗7发 garrisons gain layers', () => { + const h = field([{ uid: 1, chessId: 'chess_char_5_01_a', row: 10, col: 4, dir: 'RIGHT' }, IRON, piece(CRAB, 5)], { + bonds: { lateranoShip: { count: 3, active: true, tier: 1, layers: 0 }, visiShip: { count: 1, active: true, tier: 1, layers: 0 } }, + }); + const u = h.unit(1); + const crab = h.b.allyUnits.find((a) => a.defId === CRAB); + assert.equal(h.b.isAllyTarget(crab), true); + assert.equal(h.b.enemiesInKeys(u.rangeKeys, u, u.profile).length, 0, 'not an enemy of the selectors'); + assert.equal(u.skill.id, 'skchr_excu2_2'); + assert.equal(u.skill.rule, 'DEFAULT'); + assert.equal(u.skill.kind, 'ammo'); + u.skill.gainSp(999, 'test'); + h.run(14); + assert.equal(u.skill.activations, 1, 'the skill opened on the device'); + const atk = h.hooksOf('attack').filter((c) => c.attacker === u); + assert.ok(atk.length >= 10 && atk.every((c) => c.targets.every((t) => t === crab)), 'every attack hit the device'); + const spent = h.hooksOf('ammoUsed').filter((c) => c.unit === u).length; + assert.ok(spent >= 7, `bullets spent on it (${spent})`); + const gains = h.hooksOf('layerGain').filter((c) => c.reason === 'garrison'); + assert.ok(gains.length >= 2 && gains.every((c) => c.n > 0), '拉特兰 / 远见 garrison layers after 7 bullets'); + assert.equal(crab.hp, crab.s.maxHp, 'the hits are still cancelled'); + checkInvariants(h.b); +}); + +test('the other normal-attack ammo skills spend their bullets on 铁钳号 too (隐现 S2, 烛煌 S3, 新约能天使 S2)', () => { + for (const id of ['chess_char_1_01_a', 'chess_char_5_03_a', 'chess_char_6_13_a']) { + const h = field([{ uid: 1, chessId: id, row: 10, col: 4, dir: 'RIGHT' }, IRON, piece(CRAB, 5)]); + const u = h.unit(1); + assert.equal(u.skill.kind, 'ammo', id); + u.skill.gainSp(999, 'test'); + h.run(10); + assert.equal(u.skill.activations, 1, `${id} opened`); + assert.ok(h.hooksOf('ammoUsed').filter((c) => c.unit === u).length > 0, `${id} spent bullets`); + checkInvariants(h.b); + } +}); + +test('白铁™多功能平台 is no ally target: never attacked, the skill stays closed', () => { + const h = field([{ uid: 1, chessId: 'chess_char_5_01_a', row: 10, col: 4, dir: 'RIGHT' }, iron(0), piece('token_10027_ironmn_pile1', 5)]); + const u = h.unit(1); + const pile = h.b.allyUnits.find((a) => a.defId === 'token_10027_ironmn_pile1'); + assert.ok(pile && pile.deployed); + assert.equal(h.b.isAllyTarget(pile), false); + u.skill.gainSp(999, 'test'); + h.run(8); + assert.equal(u.skill.activations, 0); + assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u).length, 0); + assert.equal(h.hooksOf('ammoUsed').length, 0); + checkInvariants(h.b); +}); + +test('every start rule counts it (SEARCH, SKILL_RANGE, CUSTOM_RANGE, ACTIVE_RANGE, GDGLOW anywhere, a content trigger range) — never a heal skill', () => { + const id = 'test_cast_a'; + const open = (rule, { grid = null, rangeGrid = [[0, 0], [0, 1]], col = 5, heal = false, area = null, enemy = false } = {}) => { + const h = makeBattle({ + defs: { + chess: { [id]: chessRec({ id, profession: heal ? 'MEDIC' : 'SNIPER', rangeGrid, skill: { skillType: 'MANUAL', spCost: 1, initSp: 1 } }) }, + enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, mass: 0 }) }, + }, + kits: { + [id]: () => ({ + skill: { kind: 'duration', duration: 5, spCost: 1, initSp: 1, heal, trigger: grid ? { rule, grid } : rule }, + install(battle, unit) { if (area) unit.skill.addTriggerRange(() => [area]); }, + }), + }, + units: [{ uid: 1, chessId: id, row: 10, col: 4, dir: 'RIGHT' }, IRON, piece(CRAB, col)], + enemies: enemy ? [{ key: 'enemy_dummy', time: 0, pos: [11, 4] }] : [], + timeLimit: 20, autoFinish: false, seed: 2, flags: { dpPerSec: 0, dpMax: 999 }, + }); + h.step(); + const u = h.unit(1); + const crab = h.b.allyUnits.find((a) => a.defId === CRAB); + crab.hp = crab.s.maxHp * 0.2; // an injured unit: still no heal patient (禁疗) + u.skill.gainSp(999, 'test'); + h.run(3); + checkInvariants(h.b); + return u.skill.activations; + }; + + assert.equal(open('DEFAULT', { heal: true }), 0, 'a heal skill: the 禁疗 device is no patient'); + assert.equal(open('SEARCH', { heal: true }), 0); + assert.equal(open('DEFAULT'), 1, 'DEFAULT: about to attack it'); + assert.equal(open('SEARCH'), 1); + assert.equal(open('SKILL_RANGE', { grid: [[0, 0], [0, 1], [0, 2]], rangeGrid: [[0, 0]] }), 1, 'on the skill range only'); + assert.equal(open('CUSTOM_RANGE', { grid: [[0, 0], [0, 1], [0, 2]], rangeGrid: [[0, 0]] }), 1); + assert.equal(open('ACTIVE_RANGE', { grid: [[0, 0], [0, 1], [0, 2]], rangeGrid: [[0, 0]] }), 1); + assert.equal(open('SKILL_RANGE', { grid: [[0, 0], [0, 1], [0, 2]], rangeGrid: [[0, 0]], col: 8 }), 0, 'outside the grid: no'); + assert.equal(open('GDGLOW_SKILL_2', { rangeGrid: [[0, 0]], col: 9 }), 1, '全场: outside its own range too'); + assert.equal(open('DEFAULT', { rangeGrid: [[0, 0]], col: 8, area: [10 * COLS + 8] }), 1, 'a content trigger range (addTriggerRange)'); + assert.equal(open('DEFAULT', { rangeGrid: [[0, 0]], col: 8 }), 0, 'neither in range nor in a trigger range: no'); +});