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'); +});