From 37e45e362e18771de70c4d6b2587e21374b16647 Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 18:54:36 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feedback7=20R22-OSIM:=20=E7=99=BD=E9=93=81'?= =?UTF-8?q?s=20=E9=93=81=E9=92=B3=E5=8F=B7=C2=B7=E5=8E=9F=E5=9E=8B?= =?UTF-8?q?=E6=9C=BA=20counts=20like=20an=20enemy=20for=20a=20non-heal=20s?= =?UTF-8?q?kill's=20automatic=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'); +}); From 6d6fbe927738be34b6f58c3a9b1c3cff2060d0fb Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 18:59:04 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feedback7=20R22-OSIM:=20=E4=BC=8A=E5=86=85?= =?UTF-8?q?=E4=B8=9D=20S3=20starts=20again=20on=20a=20=E7=AA=81=E8=A2=AD?= =?UTF-8?q?=20landing=20(keepSp=20no=20longer=20overwrites=20the=20charge?= =?UTF-8?q?=20a=20free=20skill=20gets=20at=20every=20deployment);=20golden?= =?UTF-8?q?:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Community report relayed by the owner: 伊内丝激活突袭瞬移以后3技能被吞. The 突袭 jump retreats the operator, which ends her running S3 独影归途 (PASSIVE, spCost 0, "部署后…", cast by her kit's deploy hook), and redeploys her with keepSp (activity_table bondeffect_raid "保留技力立即再部署"). The snapshot taken after the retreat held 0 charges and was written over the charge reset() had just given the free skill, so the landing's activate('deploy') failed and S3 never ran again. keepSp now applies to skills that cost SP only; 伊内丝 S2 (27 SP) keeps a full charge and a partial bar as before. The other free skills set activateOnDeploy and were already running at that point (not affected). Tests: test/content/feedback7-ines-raid.test.js (2; the S3 one fails on the parent). --- docs/SIM.md | 2 +- server/sim/battle/deploy.js | 6 ++- test/content/feedback7-ines-raid.test.js | 69 ++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 test/content/feedback7-ines-raid.test.js diff --git a/docs/SIM.md b/docs/SIM.md index 53caf011..8b610d65 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -850,7 +850,7 @@ instance) and skip `'counter'` / `'reflect'` damage. When the guard trips, the l | `onFieldBoard(r, c)` | whether a field tile lies on the board of a player of this field — `onOwnBoard` of any of `players` (the players whose units the battle holds; an eliminated or absent teammate is not one): both halves of the two-helper 联防 field and of a Final Assault / Hidden Core pair field, the own half only for a lone 联防 helper and on a solo boss field, never a boss field's hand / 临时整备区 rows; the 突袭 landing takes only such tiles (DESIGN §25.18, §26.1) | | `addDp(playerId, n)`, `retreat(unit, {reason, permanent, dying})`, `relocate(unit, r, c)` | `relocate` only changes the tile (state kept, no event) | | `moveRedeploy(unit, r, c, { clearSp })` | a 【移动】 (PRTS 术语释义: "不退场,以当前血量在目标位置部署", a special retreat + redeploy): `relocate`'s checks (false = refused, nothing changed), then a new deployment (`deploySeq` / `aggroSeq` / `deployedAt`) and `deploy {initial:false, move:true}` (deploy effects fire again); no `die` / `death`, timer or cost (不屈 / 阿戈尔's revive never see it); HP, buffs and a running skill are kept (owner's deviation, DESIGN §22.3); `clearSp` empties the SP before the deploy handlers run — 乌尔比安 S3's move (kits/ops/chess_char_5_05-ulpia.js) and 【返回】 (`clearSp`; also the 从不混淆的方向 fallback, content/tokens.js) | -| `redeploy(unit, { free=true, tile, keepSp })` | immediate (re)deployment of a dead/retreated ally (full HP, `deploy {initial:false}`); `free: false` pays `base.cost` DP (refused without it); without `tile` it lands on the unit's rest tile (`restTile`: where a knocked-out operator lies, else home); `tile: [r, c]` lands on that tile once (home unchanged; refused when off-rect, occupied or a knocked-out operator's tile, no fallback); `keepSp` keeps SP/charges (保留技力), restored before `deploy` fires — 突袭 raids, 阿戈尔 / 不屈 revives where the unit lies | +| `redeploy(unit, { free=true, tile, keepSp })` | immediate (re)deployment of a dead/retreated ally (full HP, `deploy {initial:false}`); `free: false` pays `base.cost` DP (refused without it); without `tile` it lands on the unit's rest tile (`restTile`: where a knocked-out operator lies, else home); `tile: [r, c]` lands on that tile once (home unchanged; refused when off-rect, occupied or a knocked-out operator's tile, no fallback); `keepSp` keeps SP/charges (保留技力), restored before `deploy` fires — but not for a skill that costs no SP, which the reset re-arms as at every deployment (伊内丝 S3 casts again on a 突袭 landing) — 突袭 raids, 阿戈尔 / 不屈 revives where the unit lies | | `refreshRange(unit)`, `setExtraRange(unit, keys)`, `rangeChanged(unit)` | rebuild the ranges after changing `unit.rangeGrid` (流形 copies); extra targetable tiles (absolute keys; merged into every later rebuild until set again; `null` clears; never in `baseRangeKeys`): 蕾缪安 wanted, 维娜 S3 | | `push(enemy, force, {from, dir, fixed, fixedAngle, inward, effect})`, `pull(enemy, force, {to, center, stop})`, `pullToFront(enemy, unit, force)`, `forceLevel(enemy, force)`, `pushDistance(enemy, force, {effect})` | the official 位移 (PRTS 游戏数据基础 §重量公式 / 推与拉; user playtest #6 item 14): 受力等级 = 力度 (微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3 …) − current 重量等级 (massLevel, 失重 counts). Push distance per level (`constants.js PUSH_TILES`, PRTS 推与拉's 弹道 column): ≤ −3 → 0, −2 → 0.12, −1 → 0.44, 0 → 1.7, 1 → 2.14, 2 → 2.96, ≥ 3 → 3.53 tiles; `effect` = a 特效 push (`PUSH_TILES_EFFECT`: −2 → 0.085, −1 → 0.374, 0 → 1.562, 1 → 1.987, 2 → 2.773, ≥ 3 → 3.331 — 见行者 S1 / S2, `PUSH_EFFECT_SKILLS`; every other pusher uses the 弹道 column [ASSUMED]); radial (away from `from`; the client's buff template `knockback[relative]`: 莫斯提马 S3, 山 S3, 琳琅诗怀雅 S3 — also PRTS 备注 "推开效果为径向推动") unless `dir` (directional; template `knockback[dir]` = Knockback {`_useSourceDirection` true, `_decreaseForceLevelWhenNotInDirection` 2}: 推击手, 野鬃 S2 "往攻击方向" (charpack char_496_wildmn: buff `wildmn_s_2[force]`) — > 45° off or < 0.25 tile ⇒ radial and level −2; `fixed` waives both (圣聆初雪 S1 朝部署方向, `KnockBackWithCharacterDirection`), `fixedAngle` only the angle (见行者 S2)); `inward` = a push towards `from` (薄绿 S2), stopping at the 急停 radius. Pull: level ≥ 0 → to `to` / the 急停 radius around `center`, −1 → 35 % of the way, −2 → 0.03, ≤ −3 → 0; `pullToFront` aims at the 拉力起点 0.5 tile ahead of the unit with the 急停 radius 0.6708 around it (never moves an enemy the unit itself blocks — nor does a pull towards an ally's centre, e.g. the 流形 S3 pulse) A 静态刚体 (data `staticBody`: every air unit of the mode except “炎佑”, plus 昆图斯 — PRTS 特殊机制 "可以进入失衡状态…但物理层面上无法产生任何速度或移动", "与单位的行动方式无关") moves 0 from every source while the skills that reach it still hit it (薄绿 S2, 锏 S3, the 钩索师 …; player report after 0.1.0) | | `displace(enemy, {x, y}, tiles)` | the raw mover behind push / pull: along passable tiles (by `motion`: FLY in the rect, else ground-passable), no weight rule; nothing for 失衡免疫 (`noDisplace`: 近地悬浮 incl. 喷气人's 飞行模式, 浮空, the 胄 parts, 守墓石像's statue and flight), 静态刚体 (`def.staticBody`) and leaders (`_displaceable`); unblocks + re-paths | diff --git a/server/sim/battle/deploy.js b/server/sim/battle/deploy.js index 3f991d9d..604b9471 100644 --- a/server/sim/battle/deploy.js +++ b/server/sim/battle/deploy.js @@ -56,7 +56,11 @@ export class BattleDeploy { const sk = u.skill; // "自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却" (PRTS 卫戍协议/帮助; skills.js) if (initial) sk.opReadyAt = this.time + this.flags.startOpCooldown; - if (keepSp && !sk.noSkill && sk.kind !== 'passive' && !sk.active) { + // keepSp (突袭 "保留技力"): the SP / charges before the jump — not for a skill that costs no SP (spCost 0, the + // deploy-timed skills), which reset() just re-armed as at every deployment: the retreat ended it, so its snapshot + // holds 0 charges, and writing that back took the charge 伊内丝 S3's deploy hook casts with (community report: + // 伊内丝激活突袭瞬移以后3技能被吞; those with activateOnDeploy run already, skipped by `!sk.active`) + if (keepSp && !sk.noSkill && sk.kind !== 'passive' && !sk.active && sk.spCost > 0) { sk.charges = Math.max(0, Math.min(sk.maxCharges, Math.floor(fin(keepSp.charges, 0)))); sk.sp = Math.max(0, Math.min(sk.spCost, fin(keepSp.sp, 0))); if (sk.charges >= sk.maxCharges) sk.sp = sk.spCost; diff --git a/test/content/feedback7-ines-raid.test.js b/test/content/feedback7-ines-raid.test.js new file mode 100644 index 00000000..3b48df55 --- /dev/null +++ b/test/content/feedback7-ines-raid.test.js @@ -0,0 +1,69 @@ +// test/content/feedback7-ines-raid.test.js — 伊内丝 S3 独影归途 under 突袭 (community report: 伊内丝激活突袭瞬移以后3技能被吞). +// S3 costs no SP and runs from every deployment but the first (skill_table skchr_ines_3: PASSIVE, spCost 0, "部署后…"; +// her kit casts it in its deploy hook). The 突袭 jump (activity_table bondeffect_raid "保留技力立即再部署") retreats her — +// ending the running S3 — and redeploys her with the SP kept (Battle.redeploy keepSp): that snapshot held 0 charges and +// overwrote the charge reset() gives a free skill at every deployment, so the landing could not recast S3. A skill that +// costs SP still keeps its SP / charges. +// Run: node --test test/content/feedback7-ines-raid.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; + +function field(skillIndex) { + const landings = []; + const h = makeBattle({ + defs: { enemies: { e_walk: enemyRec({ key: 'e_walk', hp: 1e7, speed: 0.5, atk: 0 }) } }, + timeLimit: 20, autoFinish: false, seed: 1, flags: { dpInit: 99, dpPerSec: 0, dpMax: 999 }, + bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 0 } }, + units: [{ uid: 1, chessId: 'chess_char_4_04_a', row: 12, col: 3, dir: 'RIGHT', skillIndex }], + enemies: [{ key: 'e_walk', time: 1.2, routeIndex: 0, count: 1 }], + hooks: ['skillStart', 'skillEnd', 'death'], + setup: (b) => { + // priority 1000: after the keepSp restore, before her kit's deploy hook (which casts S3) + b.on('deploy', ({ unit }) => { + if (unit.uid !== 1 || b.time < 0.5) return; + const sk = unit.skill; + landings.push({ t: b.time, tile: [unit.tileR, unit.tileC], charges: sk.charges, sp: sk.sp, active: sk.active }); + }, { priority: 1000 }); + }, + }); + h.step(); + return { h, landings, u: h.b.allyUnits.find((x) => x.uid === 1) }; +} + +test('伊内丝 S3 starts again on the 突袭 landing: the free charge reset() gave is not overwritten by the kept 0', () => { + const { h, landings, u } = field(2); + assert.equal(u.skill.id, 'skchr_ines_3'); + h.run(4); + assert.ok(h.hooksOf('death').some((c) => c.unit === u && c.reason === 'raid'), 'she jumped'); + assert.equal(landings.length, 1); + assert.notDeepEqual(landings[0].tile, [12, 3], 'landed beside the enemy'); + assert.equal(landings[0].charges, 1, 'armed at the landing'); + const starts = h.hooksOf('skillStart').filter((c) => c.unit === u); + assert.equal(starts.length, 2, 'the second deployment and the landing'); + assert.ok(Math.abs(starts[1].t - landings[0].t) < 1e-9); + assert.equal(u.skill.active, true); + assert.ok(u.skill.timeLeft > 5, `a fresh S3 (${u.skill.timeLeft.toFixed(2)} s left)`); + assert.ok(u.findBuff('ines:s3'), 'its ATK buff'); + checkInvariants(h.b); +}); + +test('突袭 still keeps the SP of a skill that costs SP (伊内丝 S2: a full charge, and a partial bar)', () => { + const full = field(1); + assert.equal(full.u.skill.id, 'skchr_ines_2'); + full.u.skill.gainSp(999, 'test'); + full.h.run(4); + assert.equal(full.landings.length, 1); + assert.equal(full.landings[0].charges, 1); + assert.equal(full.landings[0].sp, full.u.skill.spCost); + checkInvariants(full.h.b); + + const part = field(1); + const initSp = part.u.skill.sp; + part.h.run(12); + assert.equal(part.landings.length, 1, 'the idle trigger'); + assert.equal(part.landings[0].charges, 0); + assert.ok(part.landings[0].sp > initSp + 5, `kept ${part.landings[0].sp} (a bar that started at ${initSp})`); + checkInvariants(part.h.b); +}); From 42caca3be82c72c566d3eef1df9fc48fa92c81c8 Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 19:11:30 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feedback7=20R22-OSIM:=20=E5=BC=95=E6=98=9F?= =?UTF-8?q?=E6=A3=98=E5=88=BA's=20S1=20/=20S2=20alchemy=20units=20restore?= =?UTF-8?q?=20HP=20as=20=E7=94=9F=E5=91=BD=E5=9B=9E=E5=A4=8D=E9=80=9F?= =?UTF-8?q?=E5=BA=A6,=20so=20=E4=B8=8D=E5=B1=88=E8=80=85=20and=20=E7=A6=81?= =?UTF-8?q?=E7=96=97=20operators=20recover=20too;=20golden:=2012=20scenari?= =?UTF-8?q?os=20move=20(each=20fields=20=E5=BC=95=E6=98=9F=E6=A3=98?= =?UTF-8?q?=E5=88=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Community report relayed by the owner: 炼金师分支干员的炼金单元无法给不屈者等禁疗的干员提供生命恢复. The alchemist branch is 锡人 and 引星棘刺. 锡人 S2's recovery was already an hpRegen buff (PRTS 锡人 备注: "生命恢复的提供方式为增加目标的“生命回复速度”属性,不受治疗加成和禁疗影响"). 引星棘刺 S1 度算浪波 ("每秒回复相当于 攻击力…的生命值") and S2 解构涌潮 ("友方单位每秒恢复相当于攻击力…的生命") carry the same wording and blackboard key (hp_recovery_per_sec_ratio[_chr]) but called battle.heal, which 不屈者 (no heal from others) and 禁疗 refuse. Both are now an hpRegen buff per alchemy unit, set at each 1 s pulse until the next (锡人's pattern): the same amount per second, not scaled by heal bonuses, credited to the target's own regen; S2 skips 孤立 units like every ally effect. Her PRTS page has no 备注 on this; the classification rests on 锡人 and the report. Golden: roster-014, roster-015, roster-033, roster-034, bond-maniShip-high, bond-soloShip-high, boss-boss_2-pair, boss-boss_4-pair, boss-boss_6-pair, hidden-boss_8-pair, hidden-boss_10-pair (引星棘刺 on the board, S2 by default) and coop2-NORMAL-14-standins (h_0 fields her from round 11): her allies' HP now rises over each second instead of in one lump, and the heal stats move from her to them. Tests: test/content/feedback7-thorn2-regen.test.js (2; both fail on the parent); the S1 case of kits_alt_t5 asserts the regen buff instead of a heal of hers. --- docs/PLAYING.md | 2 +- docs/SIM.md | 3 +- .../kits/ops/chess_char_5_15-thorn2.js | 25 +- test/content/feedback7-thorn2-regen.test.js | 55 +++++ test/content/kits_alt_t5.test.js | 7 +- test/content/kits_t5.test.js | 4 +- test/golden/bonds.json | 24 +- test/golden/fields.json | 194 ++++++++-------- test/golden/matches.json | 8 +- test/golden/roster.json | 214 +++++++++--------- 10 files changed, 301 insertions(+), 235 deletions(-) create mode 100644 test/content/feedback7-thorn2-regen.test.js diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 2234dc3f..2717ca37 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -78,7 +78,7 @@ - 开战时干员按**逐列从左到右、每列从上到下**的顺序部署(最左一列最先,与阿戈尔吞噬「更靠左和靠上」的顺序相同),召唤物在所有干员之后。被阻挡的敌人先打阻挡它的干员;没被阻挡的远程敌人先打嘲讽等级高的,同级时打最后部署的(召唤物、最右一列靠下的干员和刚再部署的干员更容易挨打)。隐匿的干员只会被它阻挡的敌人攻击,没被它挡住的敌人的溅射、爆炸、范围技能、留下的伤害区域(包括假想敌:蚀裂被击倒后喷出的毒雾)以及深池伙友卫队、扎罗的减攻速光环也落不到它身上(和官方一样;写明「无视无法选择」的效果如污染秽蚀照常能打到,寒霜的减攻速光环和“墓碑”这类地图效果照常生效);**迷彩**(如忍冬三技能期间击倒敌人后、寒芒克洛丝一技能)同样只会被它阻挡的敌人攻击,也显示为半透明,但躲不开溅射、爆炸和范围伤害(同时打范围内所有单位的普通攻击——如“斩胄之剑”“破胄之锤”“灵幛”——打不到它),也不算隐匿(不触发叙拉古等「隐匿时」效果)。叙拉古的隐匿按时(32 秒 + 每层 0.4 秒)结束;忍冬的迷彩按官方持续到她下一次开技能。**蒂比起飞**期间只挡飞行敌人,地面敌人不会选她为目标(普通攻击、溅射、技能范围、燃烧区域和要选中目标的减益都落不到她身上);飞行和悬浮敌人、写明「无视无法选择」的效果(如污染秽蚀)照常能打到她;地面敌人的光环和全场效果(如“墓碑”让全场攻击力、防御力减半)、她攻击敌人时招来的反制(如重装侦察兵的暴露)照常生效,起飞前已挂上的持续伤害也照常跳。 - **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**开启后攻击范围比平时大**的手动技能(如莫斯提马「序时之匙」、银灰「真银斩」、史尔特尔「黄昏」、白面鸮「脑啡肽」)在**开启后的攻击范围内出现敌人**(治疗技能:有受伤的队友)时就释放,不必等敌人走进平时的攻击范围,这是本作按玩家反馈有意与官方不同的地方;**重装干员**的手动技能在受到伤害时释放(例外:深巡、雷蛇的二技能,号角的二、三技能,灰毫的一、二技能按玩家反馈改为攻击范围内有敌人时就释放(深巡二技能按开启后更远的攻击范围判断),余的二技能「厚礼上宾」改为周围两格(技能范围)内有敌人时就释放,这是本作有意与官方不同的地方;深巡一技能仍要受到伤害);执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放(开启后攻击范围扩大的技能——薄绿「风语」、蜜蜡「沙暴扩散」、卡涅利安「食噬之印」、玛恩纳「未宽解的悲哀」,以及安洁莉娜「秘杖·反重力模式」——同样按开启后的攻击范围判断)。**自动触发**的技能按其本身规则:只作用于队友的(如引星棘刺「度算浪波」、浊心斯卡蒂「同葬无光之愿」、纯烬艾雅法拉「无声润物」)技力满就释放,不用等敌人或伤员;伺夜「领袖的呼唤」「领袖的馈赠」只在场上有狼群时释放:狼群处于战术点形态时,「领袖的呼唤」也会释放并让它立刻带着 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(沼泽、寒霜等减速叠满时也不会更慢)。 -- **生命回复不是治疗**(与官方一致):吟游者的特性(魔王、浊心斯卡蒂及其海嗣)、调香师的天赋、瑕光二技能、铃兰三技能、锡人炼金单元给友军的「每秒恢复生命」是提高对方的**生命回复速度**,不是治疗:收割者 / 不屈者 / 武者(如隐德来希、圣约送葬人、折桠)这类「无法被友方治疗」的干员和处于禁疗的干员也能吃到,治疗量加成(如「受到的治疗效果提升」)也不会放大它;铃兰三技能开启后的第一秒没有回复。直接治疗(医疗干员的治疗、技能的「恢复…生命」)仍然对这些干员无效。 +- **生命回复不是治疗**(与官方一致):吟游者的特性(魔王、浊心斯卡蒂及其海嗣)、调香师的天赋、瑕光二技能、铃兰三技能、锡人和引星棘刺(一、二技能)的炼金单元给友军的「每秒恢复生命」是提高对方的**生命回复速度**,不是治疗:收割者 / 不屈者 / 武者(如隐德来希、圣约送葬人、折桠)这类「无法被友方治疗」的干员和处于禁疗的干员也能吃到,治疗量加成(如「受到的治疗效果提升」)也不会放大它;铃兰三技能开启后的第一秒没有回复。直接治疗(医疗干员的治疗、技能的「恢复…生命」)仍然对这些干员无效。 - **元素损伤条**在单位自己的血条正下方:左边是元素图标,白条是剩余的元素值,只有真正爆发时才会耗尽;爆发后条变成元素的颜色、图标闪烁,在冷却期间慢慢回满(这段时间不会再积累元素损伤)。 - **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。没被阻挡的远程敌人每次攻击都会站定、播完攻击动作,两次攻击之间才继续前进。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。隐匿的敌人只有被阻挡(或被揭示)时才能被选为攻击目标,溅射、技能的圆形范围伤害也打不到没被挡住的隐匿敌人(和官方一样);阻挡解除后 3 秒内它仍然可以被选中(官方页面注明 0 秒或 1 秒的敌人按页面),之后才重新隐匿:深池逐火战士每次被击倒后先重生 1 秒,然后变成继续往前走的隐匿「余烬」(需要 5 次伤害击倒,护卫的「火灰」10 次),一般要用干员挡住它才能选中(挡住时它不再半透明;被阻挡时被击倒的,重生结束后约 2 秒内也能用远程或范围伤害补刀),10 秒内没打掉就满血站起来;敌人重生时身上的减速、恐惧等状态会被清空、技能冷却从头开始,被恐惧的逐火战士变成余烬后也能被挡住;转译基底·α 原始形态受到的伤害全部无效,受到第 4 次物理伤害、第 4 次法术伤害或被阻挡时花 2 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。 - **复活**(策略「埃芒加德」的前 3 次、M3茧甲)与官方一致:算作一次被击倒,紧接着在倒下的位置免费立即再部署——干员「被击倒时」和「部署时」的效果都会触发(例如砾的不屈 / 卡西米尔层数、瑕光的层数、卡西米尔盟约的部署攻击力),技力和身上的增益像普通再部署一样重置。 diff --git a/docs/SIM.md b/docs/SIM.md index 8b610d65..ea6a9d7b 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -743,7 +743,8 @@ selection (`injuredAlliesInKeys`) takes it — 凯尔希 on her Mon3tr (PRTS " describes as raising the target's 生命回复速度 are hpRegen buffs, never `heal` calls, so 禁疗 and 无法被友方治疗 (`noHeal`: 收割者 / 不屈者 / 武者) do not stop them: the 吟游者 trait (professions.js `bardRegen`, 分支特性信息 吟游者; 魔王's 微尘 ×1.5 through its `bardRegen` hook; 浊心斯卡蒂 and her 海嗣), 调香师's 熏衣草, 瑕光 S2, 铃兰 S3 (none in its first second, refreshed -every second), 锡人's 炼金单元 (GitHub #96 / #137); `overheal` +every second), 锡人's 炼金单元 (GitHub #96 / #137) and 引星棘刺's S1 / S2 炼金单元 (the same wording and blackboard key as +锡人 S2; a community report, 0.2.2); `overheal` turns the excess into an `overheal` shield. `battle.loseHp(target, amount, { source, from, tags, silent, sourceless })` = HP loss ignoring DEF/RES/shields/dodge (流失); `sourceless: true` makes it 无来源 ("受到等量的无来源生命流失": hooks see no source, `source` keeps the credit — stats and the per-player shared-pool tally), as does a 无来源 `from`. A 流失 skips the damage 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 5de800cd..73f3794d 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 @@ -37,12 +37,16 @@ function straightRun(battle, r, c) { export default { // --------------------------------------------------------------------------------------------------------------- // 引星棘刺 — S2 解构涌潮 (instant / 2 charges elite): throws an alchemy unit at the target: for 12/15 s ground enemies - // around it get healing ×0.5, take 120/140 % ATK arts per second, allies heal 12/15 % ATK per second; the unit drifts + // around it get healing ×0.5, take 120/140 % ATK arts per second, allies recover 12/15 % ATK per second; the unit drifts // along the throw direction and its radius grows. T1 心相: ATK +10 %, +3 s when another op is in range. // T2 视界: allies ASPD +5, enemies −5 (doubled on straight roads ≥ 6 tiles). Module (elite): +0.1 SP/s with a unit out. // S1 度算浪波 (instant): an alchemy unit thrown at the ally in her range with the lowest HP ratio: for - // projectile_delay_time s (+3 s 心相) allies on the landing tile and the 8 around it get DEF +def and heal - // hp_recovery_per_sec_ratio × ATK per second. S3 “我的海疆”: passive — her range is the skill range; active + // projectile_delay_time s (+3 s 心相) allies on the landing tile and the 8 around it get DEF +def and recover + // hp_recovery_per_sec_ratio × ATK per second. The recovery of S1 / S2 ("每秒回复/恢复相当于攻击力…的生命") is 生命回复速度, + // an hpRegen buff per alchemy unit set at each 1 s pulse until the next — the wording and blackboard key of 锡人 S2, whose + // PRTS 备注 says "生命恢复的提供方式为增加目标的“生命回复速度”属性,不受治疗加成和禁疗影响" (the only other 炼金师): 不屈者 and + // 禁疗 units recover too (community report: 炼金单元无法给不屈者等禁疗的干员提供生命恢复; it was a heal up to 0.2.1). + // S3 “我的海疆”: passive — her range is the skill range; active // (instant) — alchemy units on the max_target_token operators with the lowest block count: for projectile_delay_time s // the enemies around each of them (RING1, following it) get ATK/DEF/RES −, one strongest instance (不叠加), and take // atk_scale × ATK arts per second, everything ramping +per_interval each `interval` s up to max_stack_cnt steps. @@ -66,8 +70,8 @@ export default { for (const a of allies) battle.addBuff(a, { key: 'thorn2:bastion', duration: AURA_DUR, mods: mods({ defFlat: num(bb.def) }) }); if (z.acc >= 1 - 1e-9) { z.acc -= 1; - const heal = unit.s.atk * num(bb.hp_recovery_per_sec_ratio); - if (heal > 0) for (const a of allies) if (a.hp < a.s.maxHp) battle.heal(unit, a, heal, { aura: true }); + const regen = unit.s.atk * num(bb.hp_recovery_per_sec_ratio); // 生命回复速度 (see the header) + if (regen > 0) for (const a of allies) battle.addBuff(a, { key: z.key, duration: 1 + 2 * battle.dt, source: unit, mods: { hpRegen: regen } }); } return; } @@ -95,7 +99,8 @@ export default { onStart({ battle, unit }) { const t = battle.alliesInGrid(unit).filter((a) => a.hp > 0).sort((a, b) => a.hpRatio - b.hpRatio || b.blocking.length - a.blocking.length || dist(a, unit) - dist(b, unit) || a.id - b.id)[0]; if (!t) return; - const z = { type: 'guard', r: t.tileR, c: t.tileC, x: t.x, y: t.y, t: 0, acc: 0, dur: num(bb.projectile_delay_time, 6) + extend(battle, unit) }; + const key = `thorn2:guard:${unit.id}:${unit.mem.zoneSeq = (unit.mem.zoneSeq ?? 0) + 1}`; // its regen buff ("效果均可叠加") + const z = { type: 'guard', r: t.tileR, c: t.tileC, x: t.x, y: t.y, t: 0, acc: 0, dur: num(bb.projectile_delay_time, 6) + extend(battle, unit), key }; (unit.mem.zones ??= []).push(z); battle.fx('zone', { x: z.x, y: z.y, id: unit.id, r: RING1, duration: z.dur }); }, @@ -186,8 +191,12 @@ 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'] }); - const heal = unit.s.atk * num(bb.hp_recovery_per_sec_ratio_chr); - if (heal > 0) for (const a of battle.alliesInRadius(z.x, z.y, r, null)) if (a.hp < a.s.maxHp) battle.heal(unit, a, heal, { aura: true }); + 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)) { + if (battle.allySelectable(a, unit)) battle.addBuff(a, { key: `${z.key}:regen`, duration: 1 + 2 * battle.dt, source: unit, mods: { hpRegen: regen } }); + } + } battle.fx('zone', { x: z.x, y: z.y, id: unit.id, r, duration: Math.max(0, z.dur - z.t), key: z.key }); } } diff --git a/test/content/feedback7-thorn2-regen.test.js b/test/content/feedback7-thorn2-regen.test.js new file mode 100644 index 00000000..b438cca6 --- /dev/null +++ b/test/content/feedback7-thorn2-regen.test.js @@ -0,0 +1,55 @@ +// test/content/feedback7-thorn2-regen.test.js — 引星棘刺's alchemy units restore HP as 生命回复速度 (community report: +// 炼金师分支干员的炼金单元无法给不屈者等禁疗的干员提供生命恢复). S1 度算浪波 "每秒回复相当于攻击力…的生命值" and S2 解构涌潮 +// "友方单位每秒恢复相当于攻击力…的生命" carry 锡人 S2's wording and blackboard key (hp_recovery_per_sec_ratio), and PRTS 锡人 +// 备注 says that recovery "为增加目标的“生命回复速度”属性,不受治疗加成和禁疗影响" — an hpRegen buff, which 不屈者 (no heal +// from others) and 禁疗 units take too. Up to 0.2.1 both were heals (battle.heal), which those units refuse. +// Run: node --test test/content/feedback7-thorn2-regen.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; + +/** 引星棘刺 (skill `skillIndex`) beside 角峰 (10,5), 折桠 — 不屈者 — (10,6) and a 禁疗 角峰 (10,7); her skill cast at once. */ +function field(skillIndex) { + const h = makeBattle({ + defs: { enemies: { e_dummy: enemyRec({ key: 'e_dummy', hp: 1e9, speed: 0, atk: 0 }) } }, + timeLimit: 20, autoFinish: false, seed: 2, flags: { dpInit: 99, dpPerSec: 0, dpMax: 999 }, hooks: ['heal'], captureNoisy: true, + units: [ + { uid: 1, chessId: 'chess_char_5_15_a', row: 10, col: 4, dir: 'RIGHT', skillIndex }, + { uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 5 }, + { uid: 3, chessId: 'chess_char_2_17_a', row: 10, col: 6 }, + { uid: 4, chessId: 'chess_char_1_02_a', row: 10, col: 7 }, + ], + }); + h.step(); + const [thorn, plain, unyield, banned] = [1, 2, 3, 4].map((uid) => h.b.allyUnits.find((u) => u.uid === uid)); + h.b.addBuff(banned, { key: 'test:healFree', status: 'healFree', flags: { noHeal: true, healFree: true } }); // 禁疗 + plain.hp = plain.s.maxHp * 0.3; + unyield.hp = unyield.s.maxHp * 0.2; // S1 picks the lowest HP ratio: its 3×3 covers all three + banned.hp = banned.s.maxHp * 0.3; + const hp0 = new Map([plain, unyield, banned].map((a) => [a, a.hp])); + h.spawn('e_dummy', { pos: [10, 6] }); + thorn.skill.gainSp(999, 'test'); + assert.ok(thorn.skill.activate('manual'), thorn.skill.id); + h.run(3.2); + return { h, thorn, plain, unyield, banned, gained: (a) => a.hp - hp0.get(a) }; +} + +for (const [skillIndex, id, key] of [[0, 'skchr_thorn2_1', 'hp_recovery_per_sec_ratio'], [1, 'skchr_thorn2_2', 'hp_recovery_per_sec_ratio_chr']]) { + test(`引星棘刺 ${id}: the alchemy unit's 生命回复速度 reaches 不屈者 (折桠) and 禁疗 operators as much as the others`, () => { + const { h, thorn, plain, unyield, banned, gained } = field(skillIndex); + assert.equal(thorn.skill.id, id); + assert.equal(unyield.profile.noHeal, true, '不屈者: no heal from others'); + const perSec = thorn.s.atk * thorn.def.skill.bb[key]; + for (const a of [plain, unyield, banned]) { + const regen = a.buffs.find((b) => b.source === thorn && b.mods?.hpRegen > 0); + assert.ok(regen, `${a.defId}: an hpRegen buff`); + assert.ok(Math.abs(regen.mods.hpRegen - perSec) < 1e-6, `${regen.mods.hpRegen} ≈ ${perSec}`); + assert.ok(gained(a) > perSec, `${a.defId} recovered ${gained(a).toFixed(1)}`); + } + assert.ok(Math.abs(gained(unyield) - gained(plain)) < 1, 'the same for 不屈者'); + assert.ok(Math.abs(gained(banned) - gained(plain)) < 1, 'the same under 禁疗'); + assert.equal(h.hooksOf('heal').filter((c) => c.source === thorn).length, 0, 'no heal of hers (治疗加成 does not apply)'); + checkInvariants(h.b); + }); +} diff --git a/test/content/kits_alt_t5.test.js b/test/content/kits_alt_t5.test.js index 1b201a5f..43743413 100644 --- a/test/content/kits_alt_t5.test.js +++ b/test/content/kits_alt_t5.test.js @@ -1000,7 +1000,7 @@ test('凛御银灰 S3 变革已至: skill range, line attacks bird_atk_scale × // ================================================================================================================= // 引星棘刺 -test('引星棘刺 S1 度算浪波: an alchemy unit on the lowest-HP ally: DEF +def and hp ratio × ATK/s on the 3×3 around it (+3 s 心相)', () => { +test('引星棘刺 S1 度算浪波: an alchemy unit on the lowest-HP ally: DEF +def and 生命回复速度 hp ratio × ATK/s on the 3×3 around it (+3 s 心相)', () => { for (const id of pair('15')) { const h = run({ defs: { chess: { t_low: ally('t_low', { stats: { def: 100 } }), t_near: ally('t_near', { stats: { def: 100 } }), t_far: ally('t_far', { stats: { def: 100 } }) }, enemies: { enemy_dummy: dummy('enemy_dummy') } }, @@ -1019,7 +1019,10 @@ test('引星棘刺 S1 度算浪波: an alchemy unit on the lowest-HP ally: DEF + h.run(1.1); for (const a of [low, near]) assert.equal(a.s.def, 100 + bb.def, a.defId); assert.equal(far.s.def, 100); - approx(heals(h, u, (c) => c.target === near)[0].amount, u.s.atk * bb.hp_recovery_per_sec_ratio); + // 生命回复速度 (an hpRegen buff per unit, like 锡人 S2), not a heal of hers + for (const a of [low, near]) approx(a.buffs.find((b) => b.key === z.key)?.mods.hpRegen, u.s.atk * bb.hp_recovery_per_sec_ratio, a.defId); + assert.equal(far.buffs.some((b) => b.key === z.key), false); + assert.equal(heals(h, u, () => true).length, 0); mute(h, u); h.run(z.dur); assert.ok(!u.mem.zones.includes(z), 'expired'); diff --git a/test/content/kits_t5.test.js b/test/content/kits_t5.test.js index 67be8009..802b229a 100644 --- a/test/content/kits_t5.test.js +++ b/test/content/kits_t5.test.js @@ -711,7 +711,7 @@ test('凛御银灰 S2 auto-cast: SKILL_RANGE — an enemy inside the skill range }); // ------------------------------------------------------------------------------------------------------------------ -test('引星棘刺 S2: alchemy unit (+3 s with an op in range) — 120 % ATK arts/s, healing ×0.5 on ground enemies, heals allies; T1 ATK +10 %; T2 ASPD ±10 on long roads', () => { +test('引星棘刺 S2: alchemy unit (+3 s with an op in range) — 120 % ATK arts/s, healing ×0.5 on ground enemies, allies recover (生命回复速度); T1 ATK +10 %; T2 ASPD ±10 on long roads', () => { const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() }, chess: { t_near: ally('t_near') } }, units: [{ chessId: 'chess_char_5_15_a', row: 10, col: 3 }, { chessId: 't_near', row: 11, col: 5 }], @@ -734,7 +734,7 @@ test('引星棘刺 S2: alchemy unit (+3 s with an op in range) — 120 % ATK art const ticks = tagged(h, 'alchemy', e); assert.ok(ticks.length >= 1); approx(ticks[0].amount, u.s.atk * bb.atk_scale, 1e-6); - assert.ok(near.hp > 5000, 'allies inside heal'); + assert.ok(near.hp > 5000, 'allies inside recover'); h.run(bb.projectile_delay_time + t0.projectile_extend); assert.equal(u.mem.zones.length, 0, 'expired'); clean(h); diff --git a/test/golden/bonds.json b/test/golden/bonds.json index 92fb0ba8..ad9db9b7 100644 --- a/test/golden/bonds.json +++ b/test/golden/bonds.json @@ -772,7 +772,7 @@ "bossHpLeft": null, "rngDraws": 180, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":23,"total":23,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":169484,"bossDmg":0,"heal":510,"deaths":0,"layerGains":{}}}, + "players": {"p1":{"killed":23,"total":23,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":169484,"bossDmg":0,"heal":944,"deaths":0,"layerGains":{}}}, "units": [ ["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",3907,1,5,3,20539,0,0,16,2], ["p1",2,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",2738,1,7,4,23,0,0,22,1], @@ -786,15 +786,15 @@ ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null], - ["p1","~1","token","token_10030_mlyss_wtrman",null,null,606.74,0,24,0,2228,839,405,12,null], + ["p1","~1","token","token_10030_mlyss_wtrman",null,null,1041,0,24,0,2228,839,839,12,null], ["p1","~2","token","token_10030_mlyss_wtrman",null,null,1041,0,33,0,2210,0,0,11,null], ["p1","~3","token","token_10030_mlyss_wtrman",null,null,1041,1,39,1,1,0,0,2,null] ], "enemies": {"enemy_1288_duskls":[11,11,0,210,63415],"enemy_1288_duskls_2":[7,7,0,0,48419],"enemy_1292_duskld":[5,5,0,734,57650]}, - "hooks": [1,38,2434,272,309,516,516,306,23,25,18,15,0,11210,207,27,23,0,1,52,0,0,0], + "hooks": [1,38,2434,272,309,516,516,291,23,25,18,15,0,11210,207,27,23,0,1,52,0,0,0], "events": {"atk":326,"deploy":12,"die":25,"dmg":516,"engage":12,"fx":204,"skill":33,"spawn":38,"status":405}, "fx": {"aoe":2,"copy":1,"crit":4,"dp":2,"ember":23,"iceSpike":64,"palsy":2,"sleep":2,"split":3,"weightless":2,"zone":99}, - "snaps": "c0a88fdf 1732f35d 1f713267 96f2eede b366d7bf 3a536525 3a46d36b 5a3b739e 236fc229" + "snaps": "c0a88fdf 1732f35d 1f713267 9299eb00 3f1d45e5 0b6d0e6b 114dd325 7bdf4a44 5474387b" }, "bond-miraShip-low": { "about": "奇迹 low: tier 1 layers 1; chess_char_4_01_a chess_char_2_11_a chess_char_3_18_a chess_char_3_01_a chess_char_3_17_a chess_char_3_20_a chess_char_3_21_a chess_char_4_02_a", @@ -1319,15 +1319,15 @@ "bossHpLeft": null, "rngDraws": 48, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":66,"total":66,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":155889,"bossDmg":0,"heal":2860,"deaths":3,"layerGains":{}}}, + "players": {"p1":{"killed":66,"total":66,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":155889,"bossDmg":0,"heal":2598,"deaths":3,"layerGains":{}}}, "units": [ ["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",3907,1,6,15,28905,0,0,35,2], - ["p1",2,"op","chess_char_1_08_b",1,"uniequip_002_texas",0,0,7,4,6105,3310,62,24,3], - ["p1",3,"op","chess_char_5_13_b",1,"uniequip_002_ghost2",3448.92,1,8,8,12927,6562,398,28,1], + ["p1",2,"op","chess_char_1_08_b",1,"uniequip_002_texas",0,0,7,4,6105,3252,328,24,3], + ["p1",3,"op","chess_char_5_13_b",1,"uniequip_002_ghost2",3448.92,1,8,8,12927,6562,1164,28,1], ["p1",4,"op","chess_char_5_14_b",1,"none",2174,1,79,4,19333,2174,0,19,6], ["p1",5,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",2738,1,77,14,21786,3845,1107,29,1], ["p1",6,"op","chess_char_5_12_b",0,"uniequip_002_dusk",1787,1,4,1,17523,0,0,15,12], - ["p1",7,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",1170,1,5,12,21375,0,1293,22,5], + ["p1",7,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",1170,1,5,12,21375,0,0,22,5], ["p1",8,"op","chess_char_5_20_b",2,"uniequip_002_aglina",1382,1,9,8,27935,0,0,27,3], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], @@ -1338,13 +1338,13 @@ "enemy_1196_msfyin": [28,28,0,0,140], "enemy_1197_sfshu": [5,5,0,0,31104], "enemy_1198_msfshu": [15,15,0,0,105], - "enemy_1207_sfji": [2,2,0,14498,54743], + "enemy_1207_sfji": [2,2,0,14441,54743], "enemy_1208_msfji": [2,2,0,0,124] }, - "hooks": [1,79,2730,216,216,542,541,519,69,69,33,31,0,9330,93,15,66,0,1,79,0,0,1], - "events": {"atk":313,"deploy":10,"die":69,"dmg":541,"engage":8,"fx":142,"heal":12,"skill":64,"spawn":77,"status":201}, + "hooks": [1,79,2730,216,216,542,541,650,69,69,33,31,0,9330,93,15,66,0,1,79,0,0,1], + "events": {"atk":313,"deploy":10,"die":69,"dmg":541,"engage":8,"fx":142,"skill":64,"spawn":77,"status":201}, "fx": {"aoe":11,"crit":6,"dodge":1,"dp":3,"sp":3,"substitute":2,"summon":21,"swap":1,"weightless":3,"zone":91}, - "snaps": "0d5fce4b 008db012 69aeb33a 4ce7c8d2 b1642f13 224f1fa4 94c69830 28a9268a 8c7e5ae4 7bef3667" + "snaps": "0d5fce4b 008db012 69aeb33a d53aa76f 82a3307a f4378f29 8ddaaa01 f66869dd abf9d1db e1fa6240" }, "bond-steadShip-low": { "about": "坚守 low: tier 1 layers 1; chess_char_1_02_a chess_char_1_10_a chess_char_4_11_a chess_char_4_04_a chess_char_4_07_a chess_char_4_09_a chess_char_4_06_a chess_char_4_10_a token_10041_cathy_catsld token_10041_cathy_catsld", diff --git a/test/golden/fields.json b/test/golden/fields.json index 89f0b465..c9df1aef 100644 --- a/test/golden/fields.json +++ b/test/golden/fields.json @@ -150,15 +150,15 @@ "p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":937710,"bossDmg":372608,"heal":6945,"deaths":2,"layerGains":{}} }, "units": [ - ["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",3907,1,9,0,183438,1132,0,53,3], - ["p1",2,"op","chess_char_4_01_b",2,"uniequip_002_rmixer",5482.35,1,11,0,3903,11319,305,35,1], + ["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",3907,1,9,0,183438,1132,168,53,3], + ["p1",2,"op","chess_char_4_01_b",2,"uniequip_002_rmixer",5482.35,1,11,0,3903,11319,311,35,1], ["p1",3,"op","chess_char_5_14_b",1,"none",2174,1,7,0,30509,953,294,58,17], ["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",2738,1,13,0,10026,0,0,66,1], - ["p1",5,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",1170,1,1,0,79315,0,168,32,8], + ["p1",5,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",1170,1,1,0,79315,0,0,32,8], ["p1",6,"op","chess_char_5_20_b",2,"uniequip_002_aglina",2418.5,1,3,0,78933,0,0,44,4], ["p1",7,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",2769.3,1,17,1,26033,0,0,43,2], ["p1",8,"op","chess_char_5_22_b",1,"uniequip_002_nymph",1611,1,19,2,168011,1748,1748,55,18], - ["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",1610,1,5,0,0,0,14119,21,3], + ["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",1610,1,5,0,0,0,14114,21,3], ["p1",10,"op","chess_char_4_02_b",2,"uniequip_002_mostma",1695,1,15,0,32280,1383,55,35,2], ["p2",11,"op","chess_char_4_11_b",1,"uniequip_002_cathy",836.62,1,12,0,0,3919,1500,0,2], ["p2",12,"op","chess_char_4_04_b",1,"uniequip_002_ines",3545.85,1,51,2,253580,496,458,287,8], @@ -174,8 +174,8 @@ ["p2",22,"token","token_10041_cathy_catsld",null,null,100,1,22,0,0,0,0,0,null] ], "enemies": {"enemy_1005_yokai_2":[6,6,0,3179,62190],"enemy_1430_lrrook":[2,2,0,0,802449],"enemy_9017_achunt":[2,0,0,30987,688440]}, - "hooks": [1,50,6000,1312,1325,2789,2834,4850,11,26,131,125,21,35636,978,0,10,0,1,11,0,13,1], - "events": {"atk":1342,"deploy":40,"die":26,"dmg":2826,"engage":17,"fx":602,"heal":43,"skill":256,"spawn":32,"status":489}, + "hooks": [1,50,6000,1312,1325,2789,2834,4861,11,26,131,125,21,35636,978,0,10,0,1,11,0,13,1], + "events": {"atk":1342,"deploy":40,"die":26,"dmg":2826,"engage":17,"fx":602,"heal":41,"skill":256,"spawn":32,"status":489}, "fx": { "aoe": 38, "appear": 2, @@ -210,7 +210,7 @@ "weightless": 4, "zone": 138 }, - "snaps": "7917cfe4 ec53e8ac 09d8b201 823345e1 5d4f2a95 9efdd7ee 56e0addd 78c7726c 4ee3695b 20aee5b5 ba3311c0 d4d127c9 0c500cac d842272b 4cbf4a87 2525db60 e8039c1e 209c57b0 af85310c 92ca4257" + "snaps": "7917cfe4 ec53e8ac 09d8b201 823345e1 5d4f2a95 9efdd7ee 56e0addd 78c7726c 4ee3695b 20aee5b5 ba3311c0 d4d127c9 0c500cac d842272b 4cbf4a87 2525db60 3291ce13 ecb45e94 c2e5c2d0 a4ac7943" }, "boss-boss_2-solo": { "about": "假想敌:铳 solo pool 3000000: chess_char_4_17_b chess_char_4_18_b chess_char_4_23_b chess_char_4_19_b chess_char_4_22_b chess_char_4_20_b chess_char_4_21_b chess_char_4_24_b chess_char_4_25_b chess_char_4_26_b", @@ -432,36 +432,36 @@ "rngDraws": 790, "errors": {"count":0,"kinds":[]}, "players": { - "p1": {"killed":14,"total":14,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1232553,"bossDmg":447559,"heal":54877,"deaths":14,"layerGains":{}}, - "p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1161150,"bossDmg":862838,"heal":6691,"deaths":14,"layerGains":{}} + "p1": {"killed":14,"total":14,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1232553,"bossDmg":447559,"heal":40856,"deaths":14,"layerGains":{}}, + "p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1161150,"bossDmg":862838,"heal":21008,"deaths":14,"layerGains":{}} }, "units": [ ["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",0,0,54,0,155980,8234,420,28,2], ["p1",2,"op","chess_char_4_01_b",2,"uniequip_002_rmixer",1373.6,1,60,2,14823,8720,1235,14,1], - ["p1",3,"op","chess_char_5_14_b",1,"none",0,0,49,0,52926,18466,3193,26,12], + ["p1",3,"op","chess_char_5_14_b",1,"none",0,0,49,0,52926,18466,3851,26,12], ["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",1187.98,1,52,0,28347,13246,275,19,2], - ["p1",5,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",911.19,1,55,2,447919,3364,21815,111,19], - ["p1",6,"op","chess_char_5_20_b",2,"uniequip_002_aglina",298.05,1,61,2,96428,10840,743,39,4], - ["p1",7,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",1227.14,1,19,1,132749,4452,335,209,6], + ["p1",5,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",911.19,1,55,2,447919,3364,765,111,19], + ["p1",6,"op","chess_char_5_20_b",2,"uniequip_002_aglina",298.05,1,61,2,96428,10974,3306,39,4], + ["p1",7,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",1227.14,1,19,1,132749,4452,2194,209,6], ["p1",8,"op","chess_char_5_22_b",1,"uniequip_002_nymph",1176.62,1,57,6,174306,2559,513,45,19], - ["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",0,0,50,0,0,4335,26014,44,3], - ["p1",10,"op","chess_char_4_02_b",2,"uniequip_002_mostma",1293.14,1,15,0,129076,4452,335,71,3], + ["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",0,0,50,0,0,4335,26103,44,3], + ["p1",10,"op","chess_char_4_02_b",2,"uniequip_002_mostma",1293.14,1,15,0,129076,4452,2194,71,3], ["p2",11,"op","chess_char_4_11_b",1,"uniequip_002_cathy",2307.16,1,62,0,0,7381,302,0,1], - ["p2",12,"op","chess_char_4_04_b",1,"uniequip_002_ines",272.31,1,26,0,286831,5830,290,256,8], - ["p2",13,"op","chess_char_4_07_b",1,"uniequip_002_bpipe",1851.09,1,56,0,25219,5607,202,31,7], - ["p2",14,"op","chess_char_4_16_b",2,"uniequip_002_texas2",0,0,53,0,105158,8892,178,187,5], - ["p2",15,"op","chess_char_4_09_b",1,"uniequip_002_mizuki",2548.55,1,59,0,245655,5827,262,55,5], + ["p2",12,"op","chess_char_4_04_b",1,"uniequip_002_ines",283.08,1,26,0,286831,5830,2567,256,8], + ["p2",13,"op","chess_char_4_07_b",1,"uniequip_002_bpipe",1851.09,1,56,0,25219,5698,400,31,7], + ["p2",14,"op","chess_char_4_16_b",2,"uniequip_002_texas2",0,0,53,0,105158,8945,1417,187,5], + ["p2",15,"op","chess_char_4_09_b",1,"uniequip_002_mizuki",2495.49,1,59,0,245655,5833,2774,55,5], ["p2",16,"op","chess_char_4_06_b",1,"uniequip_002_kroos2",0,0,51,0,203707,3584,462,170,3], - ["p2",17,"op","chess_char_4_10_b",1,"uniequip_002_aroma",1573.58,1,14,0,0,4452,515,0,0], - ["p2",18,"op","chess_char_4_12_b",2,"uniequip_002_glady",0,0,20,0,75126,9258,2532,87,5], + ["p2",17,"op","chess_char_4_10_b",1,"uniequip_002_aroma",1613.93,1,14,0,0,4452,3653,0,0], + ["p2",18,"op","chess_char_4_12_b",2,"uniequip_002_glady",0,0,20,0,75126,9265,7033,87,5], ["p2",19,"op","chess_char_4_13_b",1,"uniequip_002_gnosis",0,0,4,1,66605,3677,1688,66,19], - ["p2",20,"op","chess_char_4_14_b",1,"uniequip_002_lionhd",1224.14,1,58,0,152849,2733,262,72,13], + ["p2",20,"op","chess_char_4_14_b",1,"uniequip_002_lionhd",1224.14,1,58,0,152849,2741,713,72,13], ["p2",21,"token","token_10041_cathy_catsld",null,null,100,1,21,0,0,0,0,0,null], ["p2",22,"token","token_10041_cathy_catsld",null,null,100,1,22,0,0,0,0,0,null] ], - "enemies": {"enemy_1010_demon":[4,4,0,2399,200612],"enemy_1438_dspred":[10,10,0,18789,882694],"enemy_1521_dslily":[1,0,0,106920,1310397]}, - "hooks": [1,60,6000,1412,1519,3473,3619,7435,42,44,137,135,14,39147,1338,16,15,0,1,42,0,41,1], - "events": {"atk":1655,"deploy":45,"die":44,"dmg":3602,"engage":17,"fx":970,"heal":326,"skill":272,"spawn":37,"status":1032}, + "enemies": {"enemy_1010_demon":[4,4,0,2399,200612],"enemy_1438_dspred":[10,10,0,18904,882694],"enemy_1521_dslily":[1,0,0,107103,1310397]}, + "hooks": [1,60,6000,1412,1519,3473,3619,11042,42,44,137,135,14,39147,1338,16,15,0,1,42,0,41,1], + "events": {"atk":1655,"deploy":45,"die":44,"dmg":3602,"engage":17,"fx":970,"heal":108,"skill":272,"spawn":37,"status":1032}, "fx": { "aoe": 35, "appear": 5, @@ -499,7 +499,7 @@ "weightless": 4, "zone": 371 }, - "snaps": "c2a7999d 8a4c19ba a9160fe3 7790e2d3 b4c7df2c aeea651a 14cb22cd 25ce984d 1dae4eaa 3b5bc6f4 cea353c7 a707d801 93c5ab85 abfa7f42 649abe2c 3a9580f7 d78d904b 13046d3f 101d5dee 5b6642d4" + "snaps": "c2a7999d 8a4c19ba a9160fe3 d39e3b10 d5866259 fc603481 26c069da 85780057 16e37456 679dc6cc 77114310 92b28c26 ac45852c 83b4f7a0 bb89c9c7 b1813bcb 8e3bcb45 ff125e0a 8d10ee54 5933334e" }, "boss-boss_4-solo": { "about": "盐风主教昆图斯 solo pool 4200000: chess_char_4_17_b chess_char_4_18_b chess_char_4_23_b chess_char_4_19_b chess_char_4_22_b chess_char_4_20_b chess_char_4_21_b chess_char_4_24_b chess_char_4_25_b chess_char_4_26_b", @@ -710,24 +710,24 @@ "rngDraws": 570, "errors": {"count":0,"kinds":[]}, "players": { - "p1": {"killed":4,"total":6,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":0,"dmg":869760,"bossDmg":436648,"heal":2876,"deaths":5,"layerGains":{}}, - "p2": {"killed":0,"total":0,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":0,"dmg":1033990,"bossDmg":505471,"heal":580,"deaths":19,"layerGains":{}} + "p1": {"killed":4,"total":6,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":0,"dmg":869760,"bossDmg":436648,"heal":2146,"deaths":5,"layerGains":{}}, + "p2": {"killed":0,"total":0,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":0,"dmg":1033990,"bossDmg":505471,"heal":1246,"deaths":19,"layerGains":{}} }, "units": [ ["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",3907,1,3,1,147578,0,0,30,2], ["p1",2,"op","chess_char_4_01_b",2,"uniequip_002_rmixer",3537,1,1,0,0,0,0,0,0], ["p1",3,"op","chess_char_5_14_b",1,"none",2174,1,5,1,25423,0,0,0,10], ["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",4654.6,1,11,1,4568,0,0,6,1], - ["p1",5,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",1170,1,7,1,303339,0,551,64,13], + ["p1",5,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",1170,1,7,1,303339,0,0,64,13], ["p1",6,"op","chess_char_5_20_b",2,"uniequip_002_aglina",1382,1,13,0,179149,0,0,76,6], ["p1",7,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",1629,1,66,0,19506,1629,0,43,2], ["p1",8,"op","chess_char_5_22_b",1,"uniequip_002_nymph",1611,1,67,0,135206,3222,0,46,14], - ["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",1610,1,65,0,2158,3220,2325,3,1], + ["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",1610,1,65,0,2158,3220,2146,3,1], ["p1",10,"op","chess_char_4_02_b",2,"uniequip_002_mostma",1695,1,9,0,52833,0,0,38,2], ["p2",11,"op","chess_char_4_11_b",1,"uniequip_002_cathy",2644,1,49,0,4798,9646,260,10,1], ["p2",12,"op","chess_char_4_04_b",1,"uniequip_002_ines",190.91,1,63,0,118842,15240,280,209,6], ["p2",13,"op","chess_char_4_07_b",1,"uniequip_002_bpipe",2383,1,6,0,0,0,0,0,0], - ["p2",14,"op","chess_char_4_16_b",2,"uniequip_002_texas2",0,0,61,0,58463,13682,40,128,7], + ["p2",14,"op","chess_char_4_16_b",2,"uniequip_002_texas2",0,0,61,0,58463,13618,706,128,7], ["p2",15,"op","chess_char_4_09_b",1,"uniequip_002_mizuki",2777.6,1,14,0,205534,0,0,117,6], ["p2",16,"op","chess_char_4_06_b",1,"uniequip_002_kroos2",1561,1,8,0,493990,0,0,140,2], ["p2",17,"op","chess_char_4_10_b",1,"uniequip_002_aroma",1664,1,12,0,0,0,0,0,0], @@ -743,10 +743,10 @@ "enemy_10030_vtwand": [5,5,0,0,0], "enemy_10085_hllevi_2": [2,0,2,0,761019], "enemy_1010_demon": [4,4,0,12402,200612], - "enemy_9032_aclionk": [1,0,0,47314,942119] + "enemy_9032_aclionk": [1,0,0,47250,942119] }, - "hooks": [1,65,6000,1074,1088,2031,2044,359,41,45,91,88,0,30474,660,8,20,2,1,29,0,2,0], - "events": {"atk":1139,"deploy":45,"die":43,"dmg":1988,"engage":15,"fx":618,"heal":11,"leak":2,"skill":179,"spawn":42,"status":942}, + "hooks": [1,65,6000,1074,1088,2031,2044,431,41,45,91,88,0,30474,660,8,20,2,1,29,0,2,0], + "events": {"atk":1139,"deploy":45,"die":43,"dmg":1988,"engage":15,"fx":618,"heal":7,"leak":2,"skill":179,"spawn":42,"status":942}, "fx": { "aoe": 30, "appear": 4, @@ -782,7 +782,7 @@ "weightless": 6, "zone": 260 }, - "snaps": "8ef2441b ee06d28e 0f318fc5 8e4156fd 466da928 f03b4c62 86a1deba 85583d64 215fecf5 ccd39d46 4a08d997 af901ca4 08ece609 76458391 47fbc660 009a9bbe 48624b1a 42116d05 63ab6257 f103fa00" + "snaps": "8ef2441b ee06d28e 0f318fc5 8e4156fd 466da928 e81f42d7 7687b463 1723e66d 7f419938 8e9ddd0f 9f00d62e 2aa9f2dd bca0bec4 7ba9a9bc a548f671 7a235ae3 7fc35fab 2bf5becc eb0dbe2a ee2661d9" }, "boss-boss_6-solo": { "about": "阿利斯泰尔,帝国余晖 solo pool 3980000: chess_char_4_17_b chess_char_4_18_b chess_char_4_23_b chess_char_4_19_b chess_char_4_22_b chess_char_4_20_b chess_char_4_21_b chess_char_4_24_b chess_char_4_25_b chess_char_4_26_b", @@ -963,27 +963,27 @@ "rngDraws": 666, "errors": {"count":0,"kinds":[]}, "players": { - "p1": {"killed":16,"total":18,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2677042,"bossDmg":1550396,"heal":69312,"deaths":17,"layerGains":{}}, - "p2": {"killed":0,"total":0,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":0,"dmg":560253,"bossDmg":262771,"heal":9942,"deaths":25,"layerGains":{}} + "p1": {"killed":16,"total":18,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2677042,"bossDmg":1550396,"heal":58431,"deaths":17,"layerGains":{}}, + "p2": {"killed":0,"total":0,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":0,"dmg":560253,"bossDmg":262771,"heal":20725,"deaths":25,"layerGains":{}} }, "units": [ ["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",7814,1,90,1,335665,24097,655,20,3], ["p1",2,"op","chess_char_4_01_b",2,"uniequip_002_rmixer",7074,1,87,0,4861,9337,790,6,1], ["p1",3,"op","chess_char_5_14_b",1,"none",4348,1,71,2,352602,19433,5661,5,16], ["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",0,0,70,1,48055,29763,5829,84,2], - ["p1",5,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",0,0,74,5,875533,5355,11555,71,9], + ["p1",5,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",0,0,74,5,875533,5355,675,71,9], ["p1",6,"op","chess_char_5_20_b",2,"uniequip_002_aglina",0,0,75,2,761501,15781,75,35,3], ["p1",7,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",3258,1,81,0,5686,3481,223,18,2], ["p1",8,"op","chess_char_5_22_b",1,"uniequip_002_nymph",1855.46,1,80,2,211918,4930,342,30,11], ["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",3082.61,1,72,0,0,14900,44126,38,4], ["p1",10,"op","chess_char_4_02_b",2,"uniequip_002_mostma",0,0,84,2,81221,10225,55,13,2], ["p2",11,"op","chess_char_4_11_b",1,"uniequip_002_cathy",0,0,76,0,0,8420,223,0,2], - ["p2",12,"op","chess_char_4_04_b",1,"uniequip_002_ines",0,0,82,0,27913,14513,155,91,4], + ["p2",12,"op","chess_char_4_04_b",1,"uniequip_002_ines",0,0,82,0,27913,14385,4392,91,4], ["p2",13,"op","chess_char_4_07_b",1,"uniequip_002_bpipe",0,0,91,0,6283,14796,2285,9,3], - ["p2",14,"op","chess_char_4_16_b",2,"uniequip_002_texas2",1619,1,94,0,128154,14639,377,60,8], - ["p2",15,"op","chess_char_4_09_b",1,"uniequip_002_mizuki",0,0,69,1,120204,9521,430,31,3], + ["p2",14,"op","chess_char_4_16_b",2,"uniequip_002_texas2",1619,1,94,0,128154,14622,3856,60,8], + ["p2",15,"op","chess_char_4_09_b",1,"uniequip_002_mizuki",0,0,69,1,120204,9521,1535,31,3], ["p2",16,"op","chess_char_4_06_b",1,"uniequip_002_kroos2",1561,1,89,0,14218,3122,0,84,2], - ["p2",17,"op","chess_char_4_10_b",1,"uniequip_002_aroma",1432.92,1,86,0,76985,4157,348,76,3], + ["p2",17,"op","chess_char_4_10_b",1,"uniequip_002_aroma",1432.92,1,86,0,76985,4206,2311,76,3], ["p2",18,"op","chess_char_4_12_b",2,"uniequip_002_glady",884.71,1,79,0,60016,5447,1868,33,3], ["p2",19,"op","chess_char_4_13_b",1,"uniequip_002_gnosis",0,0,60,0,64038,7927,3949,72,22], ["p2",20,"op","chess_char_4_14_b",1,"uniequip_002_lionhd",0,0,61,0,62441,3559,307,25,4], @@ -991,16 +991,16 @@ ["p2",22,"token","token_10041_cathy_catsld",null,null,100,1,22,0,0,0,0,0,null] ], "enemies": { - "enemy_10027_vtsk": [6,4,1,138057,1019681], + "enemy_10027_vtsk": [6,4,1,137764,1019681], "enemy_1005_yokai": [4,4,0,0,10699], "enemy_1229_darmy_2": [8,8,0,6104,294231], "enemy_9013_acstmk_2": [1,0,0,63941,1814230], - "enemy_9014_acstma": [6,5,0,5613,38754], - "enemy_9015_acstmb": [3,2,0,9688,83569], + "enemy_9014_acstma": [6,5,0,5661,38754], + "enemy_9015_acstmb": [3,2,0,9835,83569], "enemy_9016_acstmr": [8,7,0,0,150] }, - "hooks": [1,92,6000,904,910,2960,3146,7480,72,75,107,101,6,25061,1027,17,36,1,1,61,0,5,9], - "events": {"atk":1083,"deploy":56,"die":74,"dmg":3143,"engage":18,"fx":651,"heal":136,"leak":1,"skill":208,"spawn":58,"status":990}, + "hooks": [1,92,6000,904,910,2960,3146,8374,72,75,107,101,6,25061,1027,17,36,1,1,61,0,5,9], + "events": {"atk":1083,"deploy":56,"die":74,"dmg":3143,"engage":18,"fx":651,"heal":62,"leak":1,"skill":208,"spawn":58,"status":990}, "fx": { "aoe": 42, "appear": 7, @@ -1044,7 +1044,7 @@ "weightless": 3, "zone": 180 }, - "snaps": "cac31b2c d1e28667 87626189 11a56b43 640cf8cf 9a92be69 12784d9a 195ea951 99de9374 65451b7e 90846f89 9bcf64aa 0cf3dfb9 5737ca93 68f962d8 98c8303b e5af0a0e 3c947908 ce14aa80 61dc2e23" + "snaps": "cac31b2c 90d1a0ec 87bdddba 3575492e 800f7f25 cb081919 fae9884f 49982fc7 73093060 fee6e3f4 127adf58 77c1bcef 8bd29c98 92604db2 33074b3f 1b6d3b40 bf3e7471 db0450c8 74523340 b1d3af63" }, "hidden-boss_8-solo": { "about": "假想敌:胄 solo pool 7200000: chess_char_4_17_b chess_char_4_18_b chess_char_4_23_b chess_char_4_19_b chess_char_4_22_b chess_char_4_20_b chess_char_4_21_b chess_char_4_24_b chess_char_4_25_b chess_char_4_26_b", @@ -1274,34 +1274,34 @@ "killed": 10, "total": 12, "unspawned": 0, - "bossHpLeft": 14635261, - "rngDraws": 918, + "bossHpLeft": 14665822, + "rngDraws": 951, "errors": {"count":0,"kinds":[]}, "players": { - "p1": {"killed":10,"total":12,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":459267,"bossDmg":234580,"heal":65462,"deaths":5,"layerGains":{}}, - "p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":520827,"bossDmg":330160,"heal":13987,"deaths":24,"layerGains":{}} + "p1": {"killed":10,"total":12,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":436084,"bossDmg":210358,"heal":52517,"deaths":5,"layerGains":{}}, + "p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":513452,"bossDmg":323820,"heal":16428,"deaths":23,"layerGains":{}} }, "units": [ - ["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",3907,1,5,1,8,0,0,4,1], - ["p1",2,"op","chess_char_4_01_b",2,"uniequip_002_rmixer",5482.35,1,9,0,4,135,135,5,1], - ["p1",3,"op","chess_char_5_14_b",1,"none",3804.5,1,15,1,13,4026,4026,3,5], - ["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",2738,1,17,4,37557,16976,8824,162,1], - ["p1",5,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",1989,1,66,6,268925,2436,20528,44,10], - ["p1",6,"op","chess_char_5_20_b",2,"uniequip_002_aglina",1382,1,19,0,39201,31941,907,22,2], - ["p1",7,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",1629,1,63,0,73883,6323,567,181,5], - ["p1",8,"op","chess_char_5_22_b",1,"uniequip_002_nymph",1611,1,3,0,14,0,0,12,5], - ["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",1610,1,7,0,0,900,30294,38,3], - ["p1",10,"op","chess_char_4_02_b",2,"uniequip_002_mostma",1695,1,67,0,39662,2415,182,24,2], - ["p2",11,"op","chess_char_4_11_b",1,"uniequip_002_cathy",0,0,59,0,9,7056,313,9,1], - ["p2",12,"op","chess_char_4_04_b",1,"uniequip_002_ines",0,0,52,0,114391,9692,1390,156,5], - ["p2",13,"op","chess_char_4_07_b",1,"uniequip_002_bpipe",0,0,64,0,6168,8119,1447,6,4], - ["p2",14,"op","chess_char_4_16_b",2,"uniequip_002_texas2",1619,1,65,1,64558,18411,1406,159,8], + ["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",3907,1,5,0,2,0,0,1,1], + ["p1",2,"op","chess_char_4_01_b",2,"uniequip_002_rmixer",3537,1,9,1,1,0,0,1,0], + ["p1",3,"op","chess_char_5_14_b",1,"none",3804.5,1,15,0,12,5647,3375,1,5], + ["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",2738,1,17,4,36333,19031,19031,167,1], + ["p1",5,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",1989,1,66,7,241213,2449,460,44,8], + ["p1",6,"op","chess_char_5_20_b",2,"uniequip_002_aglina",0,0,19,0,39805,21995,2135,22,2], + ["p1",7,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",1629,1,63,1,81972,6165,1278,176,5], + ["p1",8,"op","chess_char_5_22_b",1,"uniequip_002_nymph",1611,1,3,0,18,0,0,16,8], + ["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",1610,1,7,0,0,0,26079,30,3], + ["p1",10,"op","chess_char_4_02_b",2,"uniequip_002_mostma",1695,1,13,0,36729,5487,159,32,2], + ["p2",11,"op","chess_char_4_11_b",1,"uniequip_002_cathy",9.3,1,59,0,0,8208,120,0,2], + ["p2",12,"op","chess_char_4_04_b",1,"uniequip_002_ines",0,0,52,0,111941,8843,999,153,5], + ["p2",13,"op","chess_char_4_07_b",1,"uniequip_002_bpipe",0,0,64,0,6173,8004,1332,8,6], + ["p2",14,"op","chess_char_4_16_b",2,"uniequip_002_texas2",1619,1,65,0,60967,17304,8156,157,7], ["p2",15,"op","chess_char_4_09_b",1,"uniequip_002_mizuki",0,0,54,0,108553,6094,538,33,3], - ["p2",16,"op","chess_char_4_06_b",1,"uniequip_002_kroos2",0,0,12,0,107199,3077,1516,194,3], - ["p2",17,"op","chess_char_4_10_b",1,"uniequip_002_aroma",0,0,56,0,9951,3760,432,10,1], - ["p2",18,"op","chess_char_4_12_b",2,"uniequip_002_glady",0,0,51,0,19876,6152,1123,40,3], - ["p2",19,"op","chess_char_4_13_b",1,"uniequip_002_gnosis",1670.06,1,58,1,32048,7063,4756,80,17], - ["p2",20,"op","chess_char_4_14_b",1,"uniequip_002_lionhd",0,0,57,0,58074,4319,1067,34,8], + ["p2",16,"op","chess_char_4_06_b",1,"uniequip_002_kroos2",0,0,12,0,106719,3075,1514,195,3], + ["p2",17,"op","chess_char_4_10_b",1,"uniequip_002_aroma",0,0,56,0,9951,3706,378,10,1], + ["p2",18,"op","chess_char_4_12_b",2,"uniequip_002_glady",0,0,51,0,19032,6194,1730,39,3], + ["p2",19,"op","chess_char_4_13_b",1,"uniequip_002_gnosis",1844.82,1,58,1,32044,3599,1466,72,19], + ["p2",20,"op","chess_char_4_14_b",1,"uniequip_002_lionhd",0,0,57,0,58071,3447,195,32,8], ["p2",21,"token","token_10041_cathy_catsld",null,null,100,1,23,0,0,0,0,0,null], ["p2",22,"token","token_10041_cathy_catsld",null,null,100,1,24,0,0,0,0,0,null], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], @@ -1309,58 +1309,56 @@ ], "enemies": { "enemy_1402_tgshd_2": [2,2,0,6812,173864], - "enemy_9010_acpupp": [2,2,0,8972,240778], - "enemy_9021_acduml_2": [1,0,0,1756,564739], - "enemy_9022_acdumm": [1,0,0,2732,0], - "enemy_9023_acdums": [12,10,0,113969,714] + "enemy_9010_acpupp": [2,2,0,9014,240778], + "enemy_9021_acduml_2": [1,0,0,4045,534178], + "enemy_9022_acdumm": [1,0,0,2791,0], + "enemy_9023_acdums": [12,10,0,101874,716] }, - "hooks": [1,65,6000,1089,1207,4470,5144,10933,44,45,88,86,5,25814,1152,16,18,0,1,48,0,41,0], - "events": {"atk":1225,"deploy":45,"die":45,"dmg":4708,"engage":19,"fx":1961,"heal":597,"skill":174,"spawn":42,"status":974}, + "hooks": [1,64,6000,1067,1191,4282,4885,9023,42,44,92,88,0,24850,1157,16,18,0,1,45,0,38,0], + "events": {"atk":1205,"deploy":44,"die":44,"dmg":4464,"engage":18,"fx":1910,"heal":421,"skill":180,"spawn":42,"status":980}, "fx": { - "aoe": 13, + "aoe": 16, "appear": 7, "beam": 22, "blink": 2, - "burst": 41, - "charge": 4, - "counter": 5, - "crit": 109, + "burst": 38, + "charge": 6, + "crit": 111, "dilemma": 3, - "disappear": 9, - "displace": 7, + "disappear": 7, + "displace": 6, "ember": 3, - "explode": 902, + "explode": 903, "explosion": 8, - "expose": 181, - "frostNova": 17, + "expose": 182, + "frostNova": 19, "garrisonGrant": 1, - "iceSpike": 159, + "iceSpike": 154, "levitate": 6, - "overclock": 1, - "phase": 93, + "overclock": 2, + "phase": 94, "pull": 2, "quadShot": 3, "revive": 1, "ripple": 20, "sentry": 4, - "sermon": 1, "shadowWeave": 5, - "shield": 2, + "shield": 1, "shieldBreak": 8, "slippery": 1, - "sp": 6, + "sp": 4, "splash": 2, "stealth": 5, "summon": 8, - "swordRain": 70, - "swordStorm": 8, + "swordRain": 68, + "swordStorm": 7, "telegraph": 3, "tornado": 3, - "tornadoPulse": 14, + "tornadoPulse": 13, "weightless": 2, - "zone": 200 + "zone": 160 }, - "snaps": "f891236c f6672d61 695d2e11 dd4444a7 42309327 9cec6e57 b8fe3b31 cfaa0d6b 4be820e6 693491bb cddbaa3a 1e1a7871 945cbd12 d9b820e0 034525ef 35395d78 56b4abc5 8f47eaee d52f39d7 520c8b02" + "snaps": "f891236c f6672d61 0a4a6ca4 afae713f e1ab6847 61ffa8ff 1fea7373 8d433505 08644824 7e2d3ebd 12ec4b8b 355a93fc 7865f3ff 3e6a63d3 c438b8ec c1c9714f 22bab307 1a3f1681 3374ce73 be996307" }, "hidden-boss_10-solo": { "about": "假想敌:管 solo pool 7600000: chess_char_4_17_b chess_char_4_18_b chess_char_4_23_b chess_char_4_19_b chess_char_4_22_b chess_char_4_20_b chess_char_4_21_b chess_char_4_24_b chess_char_4_25_b chess_char_4_26_b", diff --git a/test/golden/matches.json b/test/golden/matches.json index a8c1f602..9dd59b88 100644 --- a/test/golden/matches.json +++ b/test/golden/matches.json @@ -1515,7 +1515,7 @@ "combat": "client", "boost": null, "setup": {"stageId":"act1autochess_m01","bossId":"boss_5","hiddenBossId":"boss_10","factions":["DOT","INVISIBLE","REFLECTION"]}, - "end": {"victory":false,"roundsPassed":13,"hiddenReached":false,"hiddenCleared":false,"reason":"defeat","teamLp":0,"bossPoolLeft":107915,"virtualMs":119900}, + "end": {"victory":false,"roundsPassed":13,"hiddenReached":false,"hiddenCleared":false,"reason":"defeat","teamLp":0,"bossPoolLeft":87210,"virtualMs":119900}, "rng": {"setup":25,"shop":511,"waves":0,"draft":34,"bots":18074,"meta":23}, "errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0}, "rounds": [ @@ -1576,7 +1576,7 @@ }, { "round": 12, - "h_0": [1,28,14,0,0,6,8,"efcfaab5",123,"arcaneShip 0/7, kazimierzShip 0/8, miraShip 1/18, preciShip 1/24, siracusaShip 1/34, soloShip 1/0, suntShip 1/0, swiftShip 1/47, yanShip 0/13",15,31,16,116], + "h_0": [1,28,14,0,0,6,8,"efcfaab5",123,"arcaneShip 0/7, kazimierzShip 0/8, miraShip 1/18, preciShip 1/24, siracusaShip 1/34, soloShip 1/0, suntShip 1/0, swiftShip 1/47, yanShip 0/13",16,31,15,116], "ai_0": [1,23,12,0,1,6,8,"3f2e303b",262,"arcaneShip 0/2, deputShip 1/32, kazimierzShip 0/8, lateranoShip 1/57, miraShip 1/6, preciShip 0/2, raidShip 0/2, sargonShip 0/2, siracusaShip 1/42, skillfulShip 1/62, suntShip 1/14, swiftShip 1/49",31,31,0,112] }, { @@ -1596,7 +1596,7 @@ "title": {"id":"comment_2","name":"不朽盟约","picId":"comment_icon_2","text":"激活的盟约层数总和最高"}, "trophies": 3, "reward": 320, - "stats": {"activatedLayers":219,"bossDamage":248468,"dmgDealt":2618336,"gold":145,"itemsEquipped":18,"kills":245,"leaks":69,"lpLost":10,"merges":6,"perfectRounds":9,"refreshes":24}, + "stats": {"activatedLayers":219,"bossDamage":251436,"dmgDealt":2637700,"gold":145,"itemsEquipped":18,"kills":245,"leaks":68,"lpLost":10,"merges":6,"perfectRounds":9,"refreshes":24}, "lineup": ["3_13_b@12,3","5_10_a@12,7[1_02_e_b]","5_15_a@11,3[3_11_e_a]","5_02_a@11,8","3_19_b@10,3[6_01_e_b]","1_08_b@10,7[4_11_e_b+4_02_e_a]","4_16_a@10,8[4_03_e_b+3_07_e_a]","6_05_a@9,8[3_01_e_a+1_01_e_a]"], "bonds": ["arcaneShip 0/11","kazimierzShip 0/16","kjeragShip 0/8","miraShip 1/30","preciShip 1/52","sargonShip 1/24","siracusaShip 1/44","soloShip 1/0","suntShip 1/0","swiftShip 1/69","yanShip 0/13"] }, @@ -1610,7 +1610,7 @@ "title": {"id":"comment_3","name":"坚若磐石","picId":"comment_icon_3","text":"目标生命值损失最少"}, "trophies": 3, "reward": 320, - "stats": {"activatedLayers":163,"bossDamage":423617,"dmgDealt":3743301,"gold":141,"itemsEquipped":21,"kills":274,"leaks":47,"lpLost":10,"merges":6,"perfectRounds":12,"refreshes":19}, + "stats": {"activatedLayers":163,"bossDamage":441354,"dmgDealt":3729119,"gold":141,"itemsEquipped":21,"kills":273,"leaks":47,"lpLost":10,"merges":6,"perfectRounds":12,"refreshes":19}, "lineup": ["6_12_a@12,3","5_10_a@12,7","3_21_b@11,3[1_05_e_b+4_08_e_b]","5_20_a@11,8[3_08_e_b+3_07_e_b]","3_01_b@10,3[5_09_e_a+6_05_e_a]","4_16_a@10,7[4_11_e_a+4_02_e_a]","6_06_a@10,8","6_01_a@9,8"], "bonds": ["arcaneShip 0/2","deputShip 0/32","emptyShip 1/0","kazimierzShip 0/8","lateranoShip 1/79","miraShip 1/6","preciShip 1/2","raidShip 0/2","sargonShip 0/2","siracusaShip 1/62","skillfulShip 0/68","suntShip 1/14","swiftShip 0/55","visiShip 1/0"] } diff --git a/test/golden/roster.json b/test/golden/roster.json index b36ae75f..670e72f2 100644 --- a/test/golden/roster.json +++ b/test/golden/roster.json @@ -859,43 +859,43 @@ "round": 12, "seed": 2531322120, "waveId": "act1autochess_h05", - "time": 202.67, - "ticks": 6080, + "time": 200.9, + "ticks": 6027, "reason": "cleared", - "killed": 48, + "killed": 45, "total": 183, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 500, + "rngDraws": 580, "errors": {"count":0,"kinds":[]}, "players": { "p1": { - "killed": 48, + "killed": 45, "total": 183, "perfect": false, - "leaks": 135, + "leaks": 138, "leaksUncounted": 0, "coins": 2, - "dmg": 1669744, + "dmg": 1763932, "bossDmg": 0, - "heal": 36171, - "deaths": 25, + "heal": 37489, + "deaths": 24, "layerGains": {"visiShip":6} } }, "units": [ - ["p1",1,"op","chess_char_5_11_a",1,null,0,0,5,0,161,4864,2699,5,4], - ["p1",2,"op","chess_char_5_19_a",2,null,3241,1,216,1,44918,9945,0,5,2], - ["p1",3,"op","chess_char_5_13_a",1,null,346.6,1,10,10,240895,30649,0,91,1], - ["p1",4,"op","chess_char_5_14_a",1,null,1732,1,219,0,35614,10945,787,21,6], - ["p1",5,"op","chess_char_5_01_b",1,"uniequip_002_excu2",0,0,84,7,118325,9072,4444,33,2], - ["p1",6,"op","chess_char_5_17_a",1,null,2250,1,218,7,101934,32298,7319,119,8], - ["p1",7,"op","chess_char_5_12_a",0,null,1404,1,8,0,56731,0,0,35,25], - ["p1",8,"op","chess_char_5_15_a",1,null,926,1,12,11,625635,984,6782,129,10], - ["p1",9,"op","chess_char_5_20_a",2,null,0,0,14,0,27378,1167,3,9,1], - ["p1",10,"op","chess_char_5_21_a",1,null,1279,1,4,9,348762,0,0,167,4], - ["p1",11,"op","chess_char_5_22_a",1,null,0,0,6,3,68396,1237,0,33,7], - ["p1",12,"op","chess_char_5_23_a",1,null,0,0,98,0,0,9521,13947,26,1], + ["p1",1,"op","chess_char_5_11_a",1,null,0,0,5,0,161,4864,2662,5,4], + ["p1",2,"op","chess_char_5_19_a",2,null,3241,1,214,0,30212,6715,233,3,2], + ["p1",3,"op","chess_char_5_13_a",1,null,2723.22,1,10,7,250972,26019,2102,83,1], + ["p1",4,"op","chess_char_5_14_a",1,null,1732,1,219,0,42989,9190,1097,23,7], + ["p1",5,"op","chess_char_5_01_b",1,"uniequip_002_excu2",0,0,84,7,118325,9972,5344,33,2], + ["p1",6,"op","chess_char_5_17_a",1,null,1632.9,1,218,7,100455,35767,10913,129,10], + ["p1",7,"op","chess_char_5_12_a",0,null,1404,1,8,0,64810,0,0,35,30], + ["p1",8,"op","chess_char_5_15_a",1,null,926,1,12,10,617134,984,541,138,11], + ["p1",9,"op","chess_char_5_20_a",2,null,0,0,14,0,27378,1180,72,9,1], + ["p1",10,"op","chess_char_5_21_a",1,null,1279,1,4,9,369260,0,0,167,4], + ["p1",11,"op","chess_char_5_22_a",1,null,1237,1,154,5,141241,1237,0,86,13], + ["p1",12,"op","chess_char_5_23_a",1,null,0,0,98,0,0,9097,14335,25,1], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,0,2,0,0,0,0,0,null], ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null], @@ -904,24 +904,24 @@ "enemies": { "enemy_10043_sailor": [1,1,0,135,16423], "enemy_10094_crstf": [1,1,0,0,11944], - "enemy_1017_defdrn": [1,0,1,0,7123], + "enemy_1017_defdrn": [1,0,1,0,7955], "enemy_1069_icebrk_2": [1,0,1,0,24872], - "enemy_1161_tidmag_2": [1,0,1,14631,8043], + "enemy_1161_tidmag_2": [1,0,1,14643,9294], "enemy_1195_sfyin_2": [1,0,1,0,9113], "enemy_1208_msfji_2": [1,0,1,0,20], - "enemy_1249_lysdb_2": [60,20,40,28097,677560], - "enemy_1251_lysyta": [63,25,38,13934,368444], - "enemy_1254_lypa_2": [48,1,47,44927,464222], - "enemy_1272_nhtank": [1,0,1,10257,35137], + "enemy_1249_lysdb_2": [60,22,38,29052,758257], + "enemy_1251_lysyta": [63,20,43,10565,358144], + "enemy_1254_lypa_2": [48,1,47,45234,483891], + "enemy_1272_nhtank": [1,0,1,6316,34232], "enemy_1329_cbshld": [1,0,1,0,5006], - "enemy_1430_lrrook": [1,0,1,0,24204], - "enemy_1539_reid": [1,0,1,889,15006], - "enemy_2085_skzjxd": [1,0,1,0,2629] + "enemy_1430_lrrook": [1,0,1,0,25237], + "enemy_1539_reid": [1,0,1,1266,15846], + "enemy_2085_skzjxd": [1,0,1,0,3699] }, - "hooks": [1,219,6080,770,868,5115,5110,3213,74,210,71,71,9,24174,728,87,183,135,1,88,1,8,18], - "events": {"atk":1129,"bounty":1,"deploy":33,"die":75,"dmg":5110,"engage":12,"fx":463,"heal":212,"layer":1,"leak":135,"skill":142,"spawn":199,"status":1543}, - "fx": {"aoe":17,"burst":8,"charge":4,"crit":20,"dodge":18,"extraAttack":9,"garrison":1,"healAoe":4,"iceSpike":122,"shieldBreak":60,"sp":24,"substitute":6,"summon":1,"swap":6,"weightless":1,"zone":162}, - "snaps": "92d6171d a14af2a9 3bf2fd11 f6ecd555 4ac6277e 2784431f 04c94017 a3665bd0 1cd29745 07218cd9 7223440e 4425b22e febfb834 893f5aa5 d58169eb bdce5a88 23c988b2 c7031869 33fd06c1 cba1596a 9878059e" + "hooks": [1,219,6027,822,922,5158,5195,5586,70,209,86,85,9,24812,773,85,183,138,1,84,1,8,16], + "events": {"atk":1158,"bounty":1,"deploy":33,"die":71,"dmg":5195,"engage":12,"fx":499,"heal":84,"layer":1,"leak":138,"skill":171,"spawn":199,"status":1620}, + "fx": {"aoe":26,"burst":8,"charge":4,"crit":25,"dodge":16,"extraAttack":9,"garrison":1,"healAoe":4,"iceSpike":122,"shieldBreak":60,"sp":35,"substitute":7,"summon":1,"swap":6,"weightless":1,"zone":174}, + "snaps": "92d6171d ca0b06f8 42960f3a 87684217 ab629c34 75e98f4c 18767d22 a6e5dca7 856512b9 c589dbb5 c7bd7336 4de4769f 28fe6b79 5df6dabc 5785a6ff d2b06120 ad8b8d1b 1fbc10c9 431c6e77 534d5874" }, "roster-015": { "about": "pass 0 band 6: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_14_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b chess_char_5_15_b", @@ -951,7 +951,7 @@ "coins": 12, "dmg": 1870890, "bossDmg": 0, - "heal": 53573, + "heal": 57774, "deaths": 0, "layerGains": {"indomShip":20} } @@ -959,20 +959,20 @@ "units": [ ["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_horn",2775,1,8,10,133996,9109,0,60,4], ["p1",2,"op","chess_char_5_11_b",1,"uniequip_002_demkni",4784.8,1,6,0,0,10800,13006,0,79], - ["p1",3,"op","chess_char_5_05_b",2,"uniequip_002_ulpia",13035.36,1,140,40,643200,40970,22958,47,6], + ["p1",3,"op","chess_char_5_05_b",2,"uniequip_002_ulpia",13035.36,1,140,40,643200,40970,28350,47,6], ["p1",4,"op","chess_char_5_06_b",2,"uniequip_002_etlchi",3672,1,11,3,37848,492,94,16,3], - ["p1",5,"op","chess_char_5_13_b",1,"uniequip_002_ghost2",3069.46,1,12,0,5213,13165,0,3,1], - ["p1",6,"op","chess_char_5_14_b",1,"none",2174,1,13,16,322116,5249,358,39,41], - ["p1",7,"op","chess_char_5_07_b",2,"uniequip_002_surtr",2680,1,135,14,196134,24694,2556,41,2], + ["p1",5,"op","chess_char_5_13_b",1,"uniequip_002_ghost2",3094.29,1,12,0,5213,13165,1872,3,1], + ["p1",6,"op","chess_char_5_14_b",1,"none",2174,1,13,16,322116,5249,1099,39,41], + ["p1",7,"op","chess_char_5_07_b",2,"uniequip_002_surtr",2680,1,135,14,196134,28522,9190,41,2], ["p1",8,"op","chess_char_5_02_b",2,"uniequip_002_titi",1439,1,4,0,0,0,0,0,0], ["p1",9,"op","chess_char_5_03_b",2,"uniequip_002_blaze2",1472,1,5,0,0,0,0,0,0], - ["p1",10,"op","chess_char_5_10_b",2,"uniequip_002_lisa",1480,1,15,1,37856,19552,1289,44,4], + ["p1",10,"op","chess_char_5_10_b",2,"uniequip_002_lisa",1480,1,15,1,37856,19552,3052,44,4], ["p1",11,"op","chess_char_5_12_b",0,"uniequip_002_dusk",714.8,1,7,3,40451,394,0,6,6], - ["p1",12,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",1170,1,10,20,451379,2283,12845,9,7], + ["p1",12,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",1170,1,10,20,451379,2283,0,9,7], ["-","~1","device","trap_1105_accrate",null,null,100,0,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,0,2,0,0,0,0,0,null], ["-","~3","device","trap_1105_accrate",null,null,100,0,3,0,0,0,0,0,null], - ["p1","~1","token","token_10015_dusk_drgn",null,null,1352.19,0,45,0,2698,1111,466,5,null], + ["p1","~1","token","token_10015_dusk_drgn",null,null,1997,0,45,0,2698,1111,1111,5,null], ["p1","~1","token","token_10039_ulpia_block",null,null,3500,0,56,0,0,0,0,0,null], ["p1","~2","token","token_10039_ulpia_block",null,null,3500,0,88,0,0,0,0,0,null], ["p1","~3","token","token_10039_ulpia_block",null,null,3500,0,104,0,0,0,0,0,null], @@ -984,12 +984,12 @@ "enemy_10097_crshd": [1,1,0,0,15552], "enemy_1019_jshoot": [1,1,0,291,2799], "enemy_1071_dftman": [1,1,0,0,4355], - "enemy_1161_tidmag": [1,1,0,482,9331], + "enemy_1161_tidmag": [1,1,0,492,9331], "enemy_1162_magmot": [1,1,0,1368,18662], "enemy_1196_msfyin": [1,1,0,0,3], - "enemy_1209_sfden": [1,1,0,0,21773], + "enemy_1209_sfden": [1,1,0,10,21773], "enemy_1210_msfden": [1,1,0,0,54], - "enemy_1267_nhpbr_2": [36,36,0,23523,307930], + "enemy_1267_nhpbr_2": [36,36,0,23881,307930], "enemy_1270_nhstlk_2": [27,27,0,16273,671846], "enemy_1272_nhtank_2": [1,1,0,1200,23328], "enemy_1329_cbshld_2": [1,1,0,1845,20218], @@ -999,10 +999,10 @@ "enemy_5601_entlec": [3,3,0,0,6304], "enemy_9006_actoxi": [30,30,0,10741,699840] }, - "hooks": [1,142,5425,343,343,2083,2705,4099,110,120,153,151,0,15568,4219,52,110,0,1,225,2,0,0], - "events": {"atk":402,"bounty":6,"deploy":29,"die":120,"dmg":2098,"engage":10,"fx":465,"heal":1553,"layer":2,"skill":304,"spawn":131,"status":720}, + "hooks": [1,142,5425,343,343,2083,2703,6079,110,120,153,151,0,15568,4219,52,110,0,1,226,2,0,0], + "events": {"atk":402,"bounty":6,"deploy":29,"die":120,"dmg":2098,"engage":10,"fx":465,"heal":1416,"layer":2,"skill":304,"spawn":131,"status":720}, "fx": {"anchor":6,"aoe":45,"candle":3,"ember":2,"garrison":2,"healAoe":79,"overload":4,"revive":1,"sp":101,"substitute":1,"summon":3,"swap":1,"telegraph":1,"teleport":9,"zone":207}, - "snaps": "22cf0582 8a0c0b85 516b60a1 84aa4163 e05892d0 18489b1b 648324df 80fac9f4 376b8bee 85dbe4cb e627378e d788a444 4b8ef6d8 e2cf34c7 e37bfa3f c7e3226a 4c5d887f 61f8d5f0" + "snaps": "22cf0582 8a0c0b85 516b60a1 f957bc41 a27fb0f6 21d33906 3bc26a7d 85b16ae8 c8640c77 f3a1da65 61819a67 4d4ccd2c 69151c8b 4a2b06c1 80f8423e 97cb86a1 deeff35c b42e69d1" }, "roster-016": { "about": "pass 0 band 6: chess_char_5_19_b chess_char_6_03_a chess_char_6_06_a chess_char_5_17_b chess_char_5_20_b chess_char_5_21_b chess_char_5_22_b chess_char_5_23_b chess_char_6_01_a chess_char_6_02_a chess_char_6_04_a chess_char_6_05_a token_10017_skadi2_dedant", @@ -2017,64 +2017,64 @@ "round": 12, "seed": 3754088384, "waveId": "act1autochess_h05", - "time": 210.5, - "ticks": 6315, + "time": 221.1, + "ticks": 6633, "reason": "cleared", - "killed": 46, + "killed": 47, "total": 167, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 418, + "rngDraws": 178, "errors": {"count":0,"kinds":[]}, "players": { "p1": { - "killed": 46, + "killed": 47, "total": 167, "perfect": false, - "leaks": 121, + "leaks": 120, "leaksUncounted": 0, "coins": 2, - "dmg": 412266, + "dmg": 403494, "bossDmg": 0, - "heal": 39432, - "deaths": 29, - "layerGains": {"indomShip":65} + "heal": 47666, + "deaths": 26, + "layerGains": {"indomShip":30} } }, "units": [ - ["p1",1,"op","chess_char_5_11_a",0,null,2474.56,1,200,2,22853,11105,5555,42,5], - ["p1",2,"op","chess_char_5_19_a",0,null,3221.7,1,202,3,42457,18975,143,16,2], - ["p1",3,"op","chess_char_5_13_a",0,null,0,0,163,10,104087,26324,70,36,1], - ["p1",4,"op","chess_char_5_14_a",0,null,1732,1,201,2,8120,5409,1482,23,3], - ["p1",5,"op","chess_char_5_06_b",0,"uniequip_003_etlchi",0,0,157,14,130469,13814,5951,32,8], - ["p1",6,"op","chess_char_5_17_a",0,null,0,0,198,0,9636,19979,192,19,6], - ["p1",7,"op","chess_char_5_12_a",1,null,0,0,3,3,14164,3044,75,13,2], - ["p1",8,"op","chess_char_5_15_a",0,null,926,1,203,6,18667,926,14185,84,25], - ["p1",9,"op","chess_char_5_20_a",0,null,0,0,187,0,7091,3476,152,15,1], - ["p1",10,"op","chess_char_5_21_a",0,null,0,0,190,0,5581,7990,180,9,0], - ["p1",11,"op","chess_char_5_22_a",0,null,1237,1,204,6,46595,1389,0,72,2], - ["p1",12,"op","chess_char_5_23_a",0,null,0,0,156,0,0,5131,11429,21,8], + ["p1",1,"op","chess_char_5_11_a",0,null,0,0,175,1,14689,14105,7927,29,5], + ["p1",2,"op","chess_char_5_19_a",0,null,0,0,187,4,37230,11144,3583,17,2], + ["p1",3,"op","chess_char_5_13_a",0,null,2313.27,1,197,9,97109,16590,1330,31,1], + ["p1",4,"op","chess_char_5_14_a",0,null,719.25,1,198,2,10998,7283,2806,33,3], + ["p1",5,"op","chess_char_5_06_b",0,"uniequip_003_etlchi",0,0,10,18,148358,13823,9857,39,9], + ["p1",6,"op","chess_char_5_17_a",0,null,2250,1,200,0,15762,27066,1599,29,8], + ["p1",7,"op","chess_char_5_12_a",1,null,0,0,3,3,11483,1787,383,10,1], + ["p1",8,"op","chess_char_5_15_a",0,null,697.29,1,199,3,15771,1390,235,69,20], + ["p1",9,"op","chess_char_5_20_a",0,null,0,0,180,0,7091,3427,103,15,1], + ["p1",10,"op","chess_char_5_21_a",0,null,0,0,186,0,4781,7990,180,8,0], + ["p1",11,"op","chess_char_5_22_a",0,null,0,0,193,7,37672,2726,252,60,2], + ["p1",12,"op","chess_char_5_23_a",0,null,0,0,183,0,0,5809,18905,32,12], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], - ["p1","~1","token","token_10015_dusk_drgn",null,null,1226.94,0,38,0,2547,787,17,8,null] + ["p1","~1","token","token_10015_dusk_drgn",null,null,1715.06,0,38,0,2547,787,505,8,null] ], "enemies": { - "enemy_1005_yokai_3": [1,0,1,4843,11508], - "enemy_1009_lurker": [72,16,56,703,105924], - "enemy_10159_mntrjn": [1,0,1,0,25020], - "enemy_1019_jshoot_2": [51,24,27,16056,139867], + "enemy_1005_yokai_3": [1,0,1,6184,8755], + "enemy_1009_lurker": [72,17,55,579,106043], + "enemy_10159_mntrjn": [1,0,1,0,25571], + "enemy_1019_jshoot_2": [51,22,29,17053,115897], "enemy_1118_lidbox_2": [1,1,0,39,14774], "enemy_1196_msfyin": [1,1,0,0,3], - "enemy_1252_lysytb_2": [1,0,1,206,12737], - "enemy_1364_spnaxe_2": [1,0,1,13143,13996], - "enemy_1404_msnip": [36,4,32,76406,127059], - "enemy_1516_jakill": [1,0,1,6954,7274], - "enemy_9011_acrefr": [1,0,1,0,5226] + "enemy_1252_lysytb_2": [1,0,1,307,12737], + "enemy_1364_spnaxe_2": [1,0,1,5874,7375], + "enemy_1404_msnip": [36,6,30,75984,145857], + "enemy_1516_jakill": [1,0,1,7909,6021], + "enemy_9011_acrefr": [1,0,1,0,9501] }, - "hooks": [1,204,6315,955,955,3544,3574,1860,75,197,63,63,0,22106,192,109,167,121,1,80,13,0,6], - "events": {"atk":1040,"bounty":1,"deploy":35,"die":76,"dmg":3536,"engage":12,"fx":118,"heal":363,"layer":13,"leak":121,"skill":126,"spawn":182,"status":263}, - "fx": {"aoe":3,"dodge":6,"dollEnd":4,"garrison":13,"healAoe":5,"hpShare":1,"palsy":1,"reborn":2,"revive":8,"shield":35,"sp":8,"substitute":5,"summon":1,"swap":1,"zone":25}, - "snaps": "0092f82c d956b3ce a0ea1881 5a64de8a 599b4202 9e1ae601 5374146f 16bd3aaf fde61ed2 91162832 b910803a ac839a60 a1563b88 f4c5c089 2603620b 128b4ac4 3c6b3ca2 b40bc0ae ad9e9e42 6f2dd105 b73c731d" + "hooks": [1,200,6633,907,907,3373,3390,8730,73,194,64,64,0,20306,167,105,167,120,1,76,6,0,10], + "events": {"atk":1015,"bounty":1,"deploy":31,"die":74,"dmg":3386,"engage":12,"fx":64,"heal":73,"layer":6,"leak":120,"skill":128,"spawn":182,"status":171}, + "fx": {"aoe":3,"crit":2,"dodge":10,"dollEnd":1,"garrison":6,"healAoe":5,"hpShare":1,"palsy":1,"reborn":1,"revive":4,"sp":4,"substitute":3,"summon":1,"swap":2,"zone":20}, + "snaps": "0092f82c ff2b39c2 ec300a50 280fca14 776e2fec 99dfc47a 3e6b6598 13676f11 9744c299 6f00c4e5 040ad1c7 d83a4d16 3926c670 f34f4c6b de9b6acf 4477f6ab 44c403f0 c156273c 20f49ab4 dffa19a7 3a1bdade 6cead901 e5e5f0d6" }, "roster-034": { "about": "pass 1 band 6: chess_char_5_08_b chess_char_5_11_b chess_char_5_19_b chess_char_5_13_b chess_char_5_14_b chess_char_5_07_b chess_char_5_17_b chess_char_5_10_b chess_char_5_12_b chess_char_5_15_b chess_char_5_20_b chess_char_5_21_b", @@ -2088,26 +2088,26 @@ "time": 230, "ticks": 6900, "reason": "timeout", - "killed": 82, + "killed": 81, "total": 104, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 619, + "rngDraws": 639, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":82,"total":104,"perfect":false,"leaks":22,"leaksUncounted":0,"coins":6,"dmg":4801724,"bossDmg":0,"heal":139288,"deaths":1,"layerGains":{}}}, + "players": {"p1":{"killed":81,"total":104,"perfect":false,"leaks":23,"leaksUncounted":0,"coins":6,"dmg":4772471,"bossDmg":0,"heal":144036,"deaths":1,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_08_b",0,"uniequip_003_horn",2381.35,1,7,25,1560529,36004,1132,96,83], - ["p1",2,"op","chess_char_5_11_b",0,"uniequip_003_demkni",4194.43,1,10,2,85198,10165,49823,194,31], - ["p1",3,"op","chess_char_5_19_b",0,"none",3682,1,11,5,218886,7679,577,124,5], - ["p1",4,"op","chess_char_5_13_b",0,"uniequip_003_ghost2",3448.92,1,13,4,163179,41498,641,210,5], - ["p1",5,"op","chess_char_5_14_b",0,"none",3217.16,1,15,0,16840,27606,11049,217,16], - ["p1",6,"op","chess_char_5_07_b",0,"uniequip_003_surtr",2680,1,8,4,292063,15460,635,104,49], - ["p1",7,"op","chess_char_5_17_b",0,"uniequip_003_f12yin",2337.47,1,100,6,203715,3444,325,86,35], - ["p1",8,"op","chess_char_5_10_b",0,"uniequip_003_lisa",1470,1,4,5,177131,0,0,116,6], - ["p1",9,"op","chess_char_5_12_b",1,"uniequip_003_dusk",1667,1,5,22,1559480,0,0,91,5], - ["p1",10,"op","chess_char_5_15_b",0,"none",1907.5,1,12,0,58039,0,75108,166,128], - ["p1",11,"op","chess_char_5_20_b",0,"uniequip_003_aglina",1352,1,14,5,208929,1794,0,138,6], - ["p1",12,"op","chess_char_5_21_b",0,"none",3330.35,1,6,4,257734,0,0,50,3], + ["p1",1,"op","chess_char_5_08_b",0,"uniequip_003_horn",3000,1,7,16,1491888,37623,1068,96,77], + ["p1",2,"op","chess_char_5_11_b",0,"uniequip_003_demkni",4064.38,1,10,1,73736,11343,63644,194,34], + ["p1",3,"op","chess_char_5_19_b",0,"none",3682,1,11,5,219874,7768,7934,139,5], + ["p1",4,"op","chess_char_5_13_b",0,"uniequip_003_ghost2",3448.92,1,13,4,163179,41271,41387,210,5], + ["p1",5,"op","chess_char_5_14_b",0,"none",3261,1,15,0,16840,27231,26974,217,13], + ["p1",6,"op","chess_char_5_07_b",0,"uniequip_003_surtr",2680,1,8,9,325026,17428,924,114,60], + ["p1",7,"op","chess_char_5_17_b",0,"uniequip_003_f12yin",2728,1,100,8,227632,3038,310,107,46], + ["p1",8,"op","chess_char_5_10_b",0,"uniequip_003_lisa",1470,1,4,6,170650,0,0,117,6], + ["p1",9,"op","chess_char_5_12_b",1,"uniequip_003_dusk",1667,1,5,21,1526499,0,0,91,5], + ["p1",10,"op","chess_char_5_15_b",0,"none",1907.5,1,12,1,58730,0,0,166,123], + ["p1",11,"op","chess_char_5_20_b",0,"uniequip_003_aglina",1352,1,14,5,202363,1794,1794,138,6], + ["p1",12,"op","chess_char_5_21_b",0,"none",3330.35,1,6,5,296053,0,0,58,3], ["-","~1","device","trap_1105_accrate",null,null,100,0,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] @@ -2119,16 +2119,16 @@ "enemy_1165_duhond_2": [1,1,0,114,24767], "enemy_1204_msfhu": [1,1,0,0,12], "enemy_1288_duskls": [1,1,0,3189,30964], - "enemy_1381_winman": [27,23,4,16850,933397], - "enemy_1381_winman_2": [39,31,7,37691,1724781], - "enemy_1387_winshd": [30,22,5,75259,1942830], - "enemy_1425_lrcmra_2": [1,0,1,1682,43433], + "enemy_1381_winman": [27,24,3,16551,928870], + "enemy_1381_winman_2": [39,28,10,38254,1664969], + "enemy_1387_winshd": [30,23,4,78842,1981114], + "enemy_1425_lrcmra_2": [1,0,1,1682,40234], "enemy_2009_csaudc": [1,0,1,0,14091] }, - "hooks": [1,120,6900,2592,2592,3942,3943,6279,83,102,372,368,0,35187,5677,97,104,18,1,84,0,0,6], - "events": {"atk":3006,"bounty":3,"deploy":13,"die":84,"dmg":3929,"engage":12,"fx":368,"heal":1084,"leak":18,"skill":740,"spawn":119,"status":797}, - "fx": {"aoe":16,"crit":21,"dodge":6,"ember":1,"healAoe":31,"hpShare":5,"sp":140,"zone":148}, - "snaps": "de3bd2e2 3bc7daf8 18ea893d 1ba5ff71 e9295c82 a68eee00 eca0d338 0a422d3c a30fc3a9 d6b53fa1 e4292430 9a138543 6f59d994 c998b7e9 26b9bbcd 2f9a6a6f 437b79cf acf535b3 9e8b5733 b227076b c64de105 4080fab7 3d4e320a" + "hooks": [1,120,6900,2644,2644,3994,3993,8937,82,102,383,379,0,34812,5720,95,104,19,1,83,0,0,8], + "events": {"atk":3066,"bounty":3,"deploy":13,"die":83,"dmg":3982,"engage":12,"fx":371,"heal":34,"leak":19,"skill":762,"spawn":119,"status":794}, + "fx": {"aoe":13,"crit":20,"dodge":8,"ember":1,"healAoe":34,"hpShare":5,"sp":147,"zone":143}, + "snaps": "de3bd2e2 66e9b470 efceaf9d 858e310e 0f4624f4 096d020b d67a49c3 252c2d4e e44385a0 49f2051f a4fa6a66 dde9cff7 bfc37778 e988e30c 033979c4 7d8f533e d2e87327 180843ba 330c5c8a c36250df a65b77ec 6da88ede b9087957" }, "roster-035": { "about": "pass 1 band 6: chess_char_6_03_a chess_char_6_06_a chess_char_6_07_a chess_char_5_22_b chess_char_5_23_b chess_char_6_01_a chess_char_6_02_a chess_char_6_04_a chess_char_6_05_a chess_char_6_08_a chess_char_6_09_a chess_char_6_11_a token_10017_skadi2_dedant token_10030_mlyss_wtrman", From 977215e14d2698ba410d20014ce6439d5a0bb7be Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 19:30:52 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feedback7=20R22-OSIM:=20enemy=20effects=20P?= =?UTF-8?q?RTS=20marks=20=E4=B8=8D=E5=8F=AF=E5=AF=B9=E7=A9=BA=20no=20longe?= =?UTF-8?q?r=20hit=20the=20flying=20=E7=82=8E=E4=BD=91=20(death=20blasts,?= =?UTF-8?q?=20=E6=B0=B4=E9=81=81=E5=BF=8D=E8=80=85's=20vortex,=20=E9=BC=8E?= =?UTF-8?q?=E6=B2=B8,=20=E7=A5=88=E7=A5=B7=E9=82=80=E7=BA=A6,=20the=20?= =?UTF-8?q?=E8=83=84=20blades);=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Community report relayed by the owner: 炎祐好像吃到了不该吃到的地面伤害. 炎佑 is a flying ally (PRTS “炎佑” 行动方式 飞行). The ground terrain (活性源石), 烹泉 / 沏虹, 庞贝 and 集团军重型火炮 already skipped it, but these 不可对空 effects (PRTS, read for every enemy of data/enemies.json) went through areaAllies / fieldAllies / targetsNear, which exclude 起飞 allies only: - 死亡爆炸 of 高能源石虫 / 冰爆源石虫 / 卷心籽 (archetypes deathBoom: `noAir`, on by default; 寒冷 too); - 水遁忍者 漩涡形态 "每秒对半径1.0范围内的所有我方单位造成…(无视迷彩,不可对空)"; - 鼎沸 "攻击范围内的所有我方单位每秒受到…法术持续伤害与…灼燃损伤(不可对空)"; - “萨科塔昂首” 祈祷邀约 "令全场我方单位(不可对空,无视迷彩)获得15s【受邀祈祷】攻击速度-30"; - “斩胄之剑” / “破胄之锤” "普通攻击对攻击范围内的所有我方单位造成…物理普通伤害,不可对空" (its target rule too). A ground enemy's normal attack and the 不可对空-free effects still reach it (PRTS: enemies that hit air target it). Tests: test/content/feedback7-yanyou-noair.test.js (8; all fail on the parent). --- docs/PLAYING.md | 2 +- docs/SIM.md | 4 + server/sim/content/bosses.js | 7 +- server/sim/content/enemies/archetypes.js | 6 +- server/sim/content/enemies/dot.js | 8 +- server/sim/content/enemies/fly.js | 5 +- server/sim/content/enemies/special.js | 5 +- test/content/feedback7-yanyou-noair.test.js | 88 +++++++++++++++++++++ 8 files changed, 114 insertions(+), 11 deletions(-) create mode 100644 test/content/feedback7-yanyou-noair.test.js diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 2717ca37..0081275f 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -110,7 +110,7 @@ - **附加盟约**(精准、迅捷、灵巧、奥术、坚守、助力、远见、奇迹、投资人、突袭、不屈、调和、协防干员、独行、绝技):多为 2 / 3 名激活;「独行」需要场上**恰好 1 名**,「绝技」按场上的精锐数量计算,没有势力盟约的干员算「协防干员」。 - **本局禁用**:「标准模拟」与官方相同,关闭拉特兰、阿戈尔、卡西米尔、灵巧、奥术、奇迹、投资人、突袭、独行、绝技这 10 个盟约,它们不会激活。你有这些盟约的成员时(奇迹、投资人也算整备区里的成员),盟约栏末尾显示**灰色带 ✕ 的盟约图标和「本局禁用」**,干员卡、盟约标签和盟约详情也这样标注。「远见」「奇迹」「投资人」计入整备区,盟约详情的「成员」人数也一样计入整备区里的成员(临时整备区不算)。 - **调和**:激活时,场上已有成员的核心盟约人数各 +1(只加 1 人,不论有几名调和干员)。盟约详情会写「含调和 +1」,成员列表上方列出带来这 1 人的调和干员(如缪尔赛思)。 -- **炎佑**:场上 6 名以上「炎」干员时召唤炎佑(9 名时两只)。它追踪全场仇恨最高的敌人并悬停在其上方,技能「祛恶之焰」锁定 1 个目标**持续喷火**(最长 20 秒,每秒对目标周围的敌人造成法术伤害并附带灼燃,喷火时不普攻,目标死亡或被沉默时结束);场上暂时没有敌人时停在原地等待。它持有官方的「孤立」:医疗干员不会治疗它,友方的增益、光环和天赋也不会选中它(敌人照常可以攻击它)。 +- **炎佑**:场上 6 名以上「炎」干员时召唤炎佑(9 名时两只)。它追踪全场仇恨最高的敌人并悬停在其上方,技能「祛恶之焰」锁定 1 个目标**持续喷火**(最长 20 秒,每秒对目标周围的敌人造成法术伤害并附带灼燃,喷火时不普攻,目标死亡或被沉默时结束);场上暂时没有敌人时停在原地等待。它持有官方的「孤立」:医疗干员不会治疗它,友方的增益、光环和天赋也不会选中它(敌人照常可以攻击它)。它是飞行单位:标注「不可对空」的敌人效果(高能源石虫 / 冰爆源石虫 / 卷心籽的死亡爆炸、水遁忍者的漩涡、鼎沸、“萨科塔昂首”的祈祷邀约、“斩胄之剑” / “破胄之锤”的攻击等)不会波及它。 - **层数**:盟约通过各种效果叠加层数,层数越高效果越强(例如「炎」攻击力随层数提升、「远见」每 10 层给资金)。**层数整局保留,但只有盟约激活时才生效**,多数情况下也只有激活的盟约能叠层。每个盟约最多 **999 层**(与官方相同,取自官方客户端的常量):到 999 后再获得的层数直接作废,按层数发放的奖励(如「远见」的资金)也随之停止。作战中获得的层数在盟约栏里**实时**显示,作战结束后一直保留到结算(联防期间也不会退回去)。「调和」「协防干员」「独行」「绝技」与官方一样**不显示层数**(盟约栏、详情和结算卡片都不显示),但叠层的特质 / 策略 / 装备照样给它们加层(PRTS 盟约记录:「部分盟约不会显示叠加层数,但是叠加层数的特质/策略/装备等效果仍然对其生效」),这些层数也计入激活层数总和;它们的效果本身与层数无关。 - **特质在哪里生效**:「进入休整期时」「休整期结束时」「刷新时」的特质(详情卡的第一项)只在干员部署在作战区时生效;文字里写着「此干员在整备区时也有效」的拉普兰德、调香师、波登可,以及激活【炎】/【投资人】时的琳琅诗怀雅,在整备区里也生效(临时整备区不算),与官方数据一致;「获得时」「售出时」的特质在获得、售出的那一刻生效,不论干员在哪里。「远见」「奇迹」「投资人」的「整备区的…干员也可用于激活盟约」只是把整备区的成员算进人数,不会让赫默等干员的特质在整备区生效。写着「使已激活的…」的特质要求盟约当时已经激活:例如「叙拉古」只数作战区里不同的叙拉古干员,整备区里的拉普兰德不算在这 3 名里;拉普兰德每回合只在她经历的第一次主动刷新时叠层(免费刷新也算),那次刷新时叙拉古没有激活,这一回合就不会再叠。 - **加成怎么叠**:盟约、策略、装备、机变卡和特质给的「攻击力 / 防御力 / 生命值 +X%」与技能的「攻击力 +X%」**相加**后再乘到属性上(官方:直接乘算),例如精准 +130%、独行 +60%、一件 +40% 的装备合计 +230%,而不是 ×2.3×1.6×1.4。「伤害提升至 X%」「受到的伤害 +X%」(维多利亚、谢拉格、奥术、脆弱等)这类伤害倍率之间仍然相乘;但同名效果只算一个——同一战场两名玩家都开了「奥术」时,敌人身上只挂一层奥术易伤,取两人中较高的那一个(不再相乘);两名「灵知」的坚冰、两名「莱恩哈特」/「缄默德克萨斯」的法抗削减也一样只算最强的一个。「坚守」的反伤计入被打的那名成员(个人对领袖伤害统计里能看到)。 diff --git a/docs/SIM.md b/docs/SIM.md index ea6a9d7b..84d44db9 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -362,6 +362,10 @@ one; the sourceless 毒雾 of 假想敌:蚀裂 skips a 隐匿 one but still re 范围内的所有单位持续视为受到源石污染区影响,技力自然回复速度倍率-80%,每0.5秒受到50真实持续伤害(同类效果取最高)") is the same kind of damage — 无来源 true (like the terrain it stands for [ASSUMED]), the chimera credited — not a 流失 (player report D1 audit): radius 1.2, a tick every 0.5 s, one tick per unit per 0.5 s however many chimeras reach it (`mem.chimeraAt`). +Every enemy effect PRTS marks "不可对空" skips a flying ally (the 炎佑 dragon, `isFlying`): the 死亡爆炸 of 高能源石虫 / 冰爆源石虫 / +卷心籽 (archetypes.js `deathBoom`, `noAir` by default), 水遁忍者's 漩涡形态, 鼎沸's pulses, “萨科塔昂首”'s 祈祷邀约, the attack of +“斩胄之剑” / “破胄之锤”, the splash and blast of 烹泉 / 沏虹, 庞贝's blast and 集团军重型火炮's shell and 燃烧区域 (the first six +reached it until 0.2.1 — a community report). **Knock-outs that are not deaths** (`content/enemies/archetypes.js`; player reports after 0.1.0): a `killed` ability that keeps the enemy alive hides the knock-out from every later `kill` handler, the kill count, kill credit and the bounty — they all diff --git a/server/sim/content/bosses.js b/server/sim/content/bosses.js index eb3cd4be..8c7f663c 100644 --- a/server/sim/content/bosses.js +++ b/server/sim/content/bosses.js @@ -524,11 +524,12 @@ function kitBlade(ab, e, b, tpl) { // 自缚 (moved by hand) · 不可阻挡 · 失衡免疫 (PRTS 天赋 "{{特殊机制|静态刚体}},不可阻挡、失衡免疫…" — data `staticBody` too) b2.addBuff(e2, { key: 'boss:anchor', persist: true, flags: { noMove: true, selfBound: true, unblockable: true, noDisplace: true } }); if (BLADE_ATK_SCALE[e2.defId]) e2.profile.atkScale = BLADE_ATK_SCALE[e2.defId]; + e2.profile.canTarget = (u) => !u.isFlying; // "…不可对空" (below) }, - // 范围物理伤害 — "普通攻击对攻击范围内的所有我方单位造成…物理普通伤害": a normal attack on every operator it can target + // 范围物理伤害 — "普通攻击对攻击范围内的所有我方单位造成…物理普通伤害,不可对空": a normal attack on every operator it can target // (canTargetAlly: no 隐匿 or 迷彩 one — it is never blocked —, PRTS 选择器: a normal attack does not ignore 迷彩; until - // 0.1.2 it took them all, `ranged: false`) - before(c, b2, e2) { const l = targetsNear(b2, e2, e2.base.rangeRadius || 1.6); if (l.length) c.targets = l; }, + // 0.1.2 it took them all, `ranged: false`), never a flying ally (the 炎佑 dragon — until 0.2.1 it was hit) + before(c, b2, e2) { const l = targetsNear(b2, e2, e2.base.rangeRadius || 1.6).filter((u) => !u.isFlying); if (l.length) c.targets = l; }, taken(c, b2, e2) { if (c.amount > 0) { // 受到伤害时令假想敌:胄受到等量的无来源生命流失 (【瘫痪】; 出击模式 [ASSUMED], see PART_TRANSFER) const L = leader(b2); diff --git a/server/sim/content/enemies/archetypes.js b/server/sim/content/enemies/archetypes.js index 19185942..a4e16b1c 100644 --- a/server/sim/content/enemies/archetypes.js +++ b/server/sim/content/enemies/archetypes.js @@ -181,15 +181,17 @@ const deathSpawn = (key, cnt, extra = {}) => ({ /** * Death explosion on the allies within r it can select (areaAllies — PRTS 高能源石虫 / 冰爆源石虫 / 卷心籽 死亡爆炸 "…无视迷彩, - * 不可对空", no 无视无法选择: a 隐匿 ally is spared; the dead enemy blocks nobody). + * 不可对空", no 无视无法选择: a 隐匿 ally is spared; the dead enemy blocks nobody). `noAir` (default — all three say so): + * a flying ally (the 炎佑 dragon) is skipped; until 0.2.1 it took the blast (community report: 炎祐吃到了不该吃到的地面伤害). */ -const deathBoom = ({ scale, type = 'phys', r = BOOM_RADIUS, status = null, sil = true, cond = null }) => ({ +const deathBoom = ({ scale, type = 'phys', r = BOOM_RADIUS, status = null, sil = true, cond = null, noAir = true }) => ({ sil, death(c, b, e, a) { if (c.reason !== 'killed' || (cond && !cond(e, a))) return; const atk = e.s.atk; b.fx('explode', { x: e.x, y: e.y, r, kind: 'deathBoom', id: e.id }); for (const u of areaAllies(b, e, e.x, e.y, r)) { + if (noAir && u.isFlying) continue; if (scale > 0) hurt(b, e, u, atk * scale, type); if (status && u.alive) b.applyStatus(u, status.key, { duration: status.dur, source: e, value: status.value }); } diff --git a/server/sim/content/enemies/dot.js b/server/sim/content/enemies/dot.js index 9489e40d..9eebc0cb 100644 --- a/server/sim/content/enemies/dot.js +++ b/server/sim/content/enemies/dot.js @@ -128,7 +128,13 @@ export const DOT_KITS = Object.freeze({ tick(b, e2) { const r = (e2.def.raw.stats && e2.def.raw.stats.rawRangeRadius) || 1.6; b.fx('explode', { x: e2.x, y: e2.y, r, kind: 'boil' }); - for (const u of areaAllies(b, e2, e2.x, e2.y, r)) { hurt(b, e2, u, e2.s.atk * (T(ab, 'aoe.atk_scale') ?? 1), 'arts'); elem(b, e2, u, 'burn', e2.s.atk * (T(ab, 'aoe.ep_damage_ratio') ?? 0)); } + // PRTS 鼎沸 "攻击范围内的所有我方单位每秒受到…法术持续伤害与…灼燃损伤(不可对空)": never a flying ally (the 炎佑 dragon; + // until 0.2.1 it burned) + for (const u of areaAllies(b, e2, e2.x, e2.y, r)) { + if (u.isFlying) continue; + hurt(b, e2, u, e2.s.atk * (T(ab, 'aoe.atk_scale') ?? 1), 'arts'); + elem(b, e2, u, 'burn', e2.s.atk * (T(ab, 'aoe.ep_damage_ratio') ?? 0)); + } }, }], }); diff --git a/server/sim/content/enemies/fly.js b/server/sim/content/enemies/fly.js index 03d9ab6b..cc27640e 100644 --- a/server/sim/content/enemies/fly.js +++ b/server/sim/content/enemies/fly.js @@ -117,12 +117,13 @@ function kitSteal(ab) { } /** “萨科塔昂首” 【祈祷邀约】 (PRTS "令全场我方单位(不可对空,无视迷彩)获得15s【受邀祈祷】攻击速度-30"): a whole-field skill - * selection — every ally but an unblocking 隐匿 / untargetable / sleeping one (fieldAllies; no 无视无法选择). */ + * selection — every ally but an unblocking 隐匿 / untargetable / sleeping one (fieldAllies; no 无视无法选择) and, 不可对空, a + * flying one (the 炎佑 dragon; until 0.2.1 it was slowed too). */ function kitRoar(ab) { const s = ab.sk.Roar; return [selfFear(ab), skill(s, (b, e) => { b.fx('telegraph', { x: e.x, y: e.y, r: 99, kind: 'roar', id: e.id }); - for (const u of fieldAllies(b, e)) b.addBuff(u, { key: 'ab:roar', duration: s.bb.duration ?? 0, refresh: 'extend', mods: { aspd: s.bb.attack_speed ?? 0 }, visible: true }); + for (const u of fieldAllies(b, e)) if (!u.isFlying) b.addBuff(u, { key: 'ab:roar', duration: s.bb.duration ?? 0, refresh: 'extend', mods: { aspd: s.bb.attack_speed ?? 0 }, visible: true }); }, { sil: true })]; } diff --git a/server/sim/content/enemies/special.js b/server/sim/content/enemies/special.js index 0a64f969..1d1d8a22 100644 --- a/server/sim/content/enemies/special.js +++ b/server/sim/content/enemies/special.js @@ -379,10 +379,11 @@ export const SPECIAL_KITS = Object.freeze({ fire(b, e, a) { a.until = b.time + (T(ab, 'EndRotate.rotate_duration') ?? 0); b.fx('telegraph', { x: e.x, y: e.y, r: 1, kind: 'spin', id: e.id }); }, iv: T(ab, 'RotateDamage.interval') ?? 1, // "每秒对半径1.0范围内的所有我方单位造成…(无视迷彩,不可对空),自身受阻止攻击类异常效果影响期间无法造成此伤害": an area - // selection, skipped while 晕眩 / 冻结 / 浮空 (flag stun), 沉睡 or 缴械 hold it (PRTS 异常效果 §阻止攻击; until 0.1.3 it spun on) + // selection, skipped while 晕眩 / 冻结 / 浮空 (flag stun), 沉睡 or 缴械 hold it (PRTS 异常效果 §阻止攻击; until 0.1.3 it spun on); + // 不可对空: never a flying ally (the 炎佑 dragon — until 0.2.1 it was hit) tick(b, e, a) { if (!(a.until > b.time) || e.s.flags.stun || e.s.flags.sleep || e.s.flags.disarm) return; - for (const u of areaAllies(b, e, e.x, e.y, T(ab, 'RotateDamage.attack@range_radius') ?? 1)) hurt(b, e, u, e.s.atk * (T(ab, 'RotateDamage.attack@atk_scale') ?? 1), 'phys'); + for (const u of areaAllies(b, e, e.x, e.y, T(ab, 'RotateDamage.attack@range_radius') ?? 1)) if (!u.isFlying) hurt(b, e, u, e.s.atk * (T(ab, 'RotateDamage.attack@atk_scale') ?? 1), 'phys'); }, }; // 漩涡形态 "不进行普通攻击" (PRTS 天赋): no blocked attack while it spins (until 0.1.3 its blocker took both) diff --git a/test/content/feedback7-yanyou-noair.test.js b/test/content/feedback7-yanyou-noair.test.js new file mode 100644 index 00000000..b0cf65fa --- /dev/null +++ b/test/content/feedback7-yanyou-noair.test.js @@ -0,0 +1,88 @@ +// test/content/feedback7-yanyou-noair.test.js — 炎佑 is a flying ally (PRTS “炎佑” 行动方式 飞行): the enemy effects PRTS marks +// 不可对空 never reach it (community report: 炎祐好像吃到了不该吃到的地面伤害). The death blasts of 高能源石虫 / 冰爆源石虫 / +// 卷心籽 ("…无视迷彩,不可对空"), 水遁忍者's 漩涡形态 ("每秒对半径1.0范围内的所有我方单位造成…(无视迷彩,不可对空)"), “萨科塔昂首”'s +// 祈祷邀约 ("令全场我方单位(不可对空,无视迷彩)获得15s【受邀祈祷】"), 鼎沸's pulses ("攻击范围内的所有我方单位每秒受到…(不可对空)") +// and the attack of “斩胄之剑” / “破胄之锤” ("普通攻击对攻击范围内的所有我方单位造成…物理普通伤害,不可对空") took it until 0.2.1; a +// ground operator in reach still does. +// Run: node --test test/content/feedback7-yanyou-noair.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, checkInvariants } from '../helpers/battleHarness.js'; +import { spawnYanyou } from '../../server/sim/content/tokens.js'; + +/** 角峰 on (10,5), the 炎佑 dragon moved onto (10,6). */ +function field(seed = 4) { + const h = makeBattle({ timeLimit: 60, autoFinish: false, seed, hooks: ['damaged'], captureNoisy: true, + units: [{ uid: 1, chessId: 'chess_char_1_02_a', row: 10, col: 5 }] }); + h.step(); + const dragon = spawnYanyou(h.b, 'p1', { count: 1 })[0]; + assert.ok(dragon && dragon.isFlying, 'a flying ally'); + assert.ok(h.b.relocate(dragon, 10, 6)); + return { h, dragon, op: h.unit(1) }; +} +const still = (e) => { e.base.moveSpeed = 0; e.s.moveSpeed = 0; }; +const tough = (e) => { e.base.maxHp = 1e9; e.s.maxHp = 1e9; e.hp = 1e9; }; +/** Damage `src` dealt to `t` other than its normal attacks. */ +const taken = (h, t, src) => h.hooksOf('damaged').filter((c) => c.target === t && c.source === src && !c.dmg?.isAttack).reduce((s, c) => s + c.amount, 0); + +for (const [name, key, prep] of [ + ['高能源石虫', 'enemy_1021_bslime', null], + ['冰爆源石虫', 'enemy_1067_snslime', null], + ['卷心籽 (ignited)', 'enemy_10065_ftzlc', (e) => { e.mem.ab.ignited = true; }], +]) { + test(`${name}'s death blast (不可对空) spares 炎佑 and still hits a ground operator`, () => { + const { h, dragon, op } = field(); + const e = h.spawn(key, { pos: [10, 6] }); + still(e); + if (prep) prep(e); + h.step(); + const hp0 = dragon.hp; + h.b.dealDamage(null, e, { amount: e.hp + 10, type: 'true', tags: ['test'] }); + h.run(0.3); + assert.equal(e.alive, false); + assert.ok(taken(h, op, e) > 0, 'the ground operator is hit'); + assert.equal(taken(h, dragon, e), 0, 'the flyer is not'); + assert.equal(dragon.hp, hp0); + if (key === 'enemy_1067_snslime') { + assert.ok(op.findBuff('cold'), '寒冷 on the ground operator'); + assert.equal(dragon.findBuff('cold'), null, 'no 寒冷 on the flyer'); + } + checkInvariants(h.b); + }); +} + +test('水遁忍者\'s 漩涡形态 (不可对空) hits the ground operator beside it, never 炎佑', () => { + const { h, dragon, op } = field(6); + const e = h.spawn('enemy_10116_ymgtop', { pos: [10, 6] }); + still(e); + tough(e); + assert.ok(h.runUntil(() => taken(h, op, e) > 0, 40), 'the vortex reached the operator'); + h.run(3); + assert.equal(taken(h, dragon, e), 0); + checkInvariants(h.b); +}); + +test('“萨科塔昂首”\'s 祈祷邀约 (不可对空) slows the ground operator, not 炎佑', () => { + const { h, dragon, op } = field(7); + const e = h.spawn('enemy_10085_hllevi_2', { pos: [10, 8] }); + still(e); + tough(e); + assert.ok(h.runUntil(() => !!op.findBuff('ab:roar'), 40), 'the ground operator is invited'); + assert.equal(dragon.findBuff('ab:roar'), null); + checkInvariants(h.b); +}); + +for (const [name, key] of [['鼎沸', 'enemy_10054_cjhot'], ['“斩胄之剑”', 'enemy_9014_acstma'], ['“破胄之锤”', 'enemy_9015_acstmb']]) { + test(`${name} (不可对空) hits the ground operator beside it, never 炎佑`, () => { + const { h, dragon, op } = field(8); + const e = h.spawn(key, { pos: [10, 6] }); + still(e); + tough(e); + h.run(12); + const dealt = (t) => h.hooksOf('damaged').filter((c) => c.target === t && c.source === e).reduce((s, c) => s + c.amount, 0); + assert.ok(dealt(op) > 0, 'the ground operator is hit'); + assert.equal(dealt(dragon), 0, 'the flyer is not'); + checkInvariants(h.b); + }); +} From 5a045731f119398d95ddbb863e8222315c65f057 Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 19:31:40 +0800 Subject: [PATCH 5/8] =?UTF-8?q?feedback7=20R22-OSIM:=20=E6=9C=9B's=20place?= =?UTF-8?q?d=20=E6=A3=8B=E5=AD=90=20drop=20their=20=E9=93=B8=E5=AD=90=20?= =?UTF-8?q?=E8=B7=9F=E5=AD=90=20at=20the=20battle=20start=20(undeployable?= =?UTF-8?q?=20ground=20first,=20=E4=B8=8A=20>=20=E5=8F=B3=20>=20=E4=B8=8B?= =?UTF-8?q?=20>=20=E5=B7=A6);=20a=20deployment=20on=20a=20=E8=B7=9F?= =?UTF-8?q?=E5=AD=90's=20tile=20removes=20it;=20S3's=20overflow=20follows?= =?UTF-8?q?=20the=20same=20order;=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Community report relayed by the owner: 望的天赋以及3技能增强的天赋提供的额外棋子是可以出现(官方好像有出现的前后左右先后 顺序的规则)在不可部署地块的. The placement rule was already PRTS 望 铸子 备注's ("有敌人的>没有敌人的;不可部署地块>可部 署地面地块>可部署高台地块…相同优先级时按棋子上>右>下>左"), but only a 棋子's return dropped a 跟子: the battle-start deployment of the pieces the player placed — their 手动部署 in this mode (PRTS 卫戍协议/帮助: a placement in 休整 fires no deployment effect; the piece deploys when the combat starts) — dropped none, so no extra piece appeared, a lone 棋子 never connected and never set off. Now: - the stone kit's deploy hook drops one 跟子 when `ctx.initial` (a return still drops its own in scheduleReturn); - "跟子所在格进行部署(或进行类似部署的行为)时,该跟子会随之消失": any ally deploying on a 跟子's tile removes it (the battle start deploys one piece after another, so a 跟子 may first land on a piece still to come); - S3's overflow (备注 "采用相同的位置选择优先顺序"): enemy tiles first, then the tile class, then her target order / distance / tile [ASSUMED ties] — an enemy on undeployable floor now comes before one on the road. Her stock rule (the owner's decision of 2026-10-06) is unchanged; a 跟子 takes no stock. Tests: test/content/op_wang.test.js — the four tests that encoded "the battle start drops no 跟子" updated, a removal case and an S3 order test added (10; 5 fail on the parent). --- server/sim/content/kits/ops/op-wang.js | 54 +++++++++++-------- test/content/op_wang.test.js | 75 +++++++++++++++++++------- 2 files changed, 88 insertions(+), 41 deletions(-) diff --git a/server/sim/content/kits/ops/op-wang.js b/server/sim/content/kits/ops/op-wang.js index 12caf563..2f62dace 100644 --- a/server/sim/content/kits/ops/op-wang.js +++ b/server/sim/content/kits/ops/op-wang.js @@ -34,11 +34,14 @@ // that touch on a side (上下 / 左右) activate each other and stay active once activated [ASSUMED from the S2 备注]; an // enemy (air units too) on an active one's tile sets it off: its effect, then it is used up (a 棋子 leaves the field; a // 跟子 is gone) [ASSUMED: a trap is spent by its trigger]. A spent 棋子 comes back on its tile ("原地再部署") after its -// redeploy time (2 s), paying its cost and one of her stock, while 望 is on the field. That return is the mode's -// deployment of her card: it counts as a 手动部署 for 铸子 — -// one 跟子 on a tile next to it (the PRTS order above), at most attack@max_spawn_cnt on the field [ASSUMED: the battle-start -// deployment does not count]. When 望 leaves the field her 棋子 and 跟子 vanish (PRTS 分支特性信息); the 棋子 come back -// with her. +// redeploy time (2 s), paying its cost and one of her stock, while 望 is on the field. 铸子 "手动部署棋子时,望在相邻位置额外 +// 部署一枚棋子": the 棋子 the player placed deploy at the battle start (PRTS 卫戍协议/帮助: a placement in 休整 fires no +// deployment effect, the piece deploys when the combat starts) — that deployment drops one 跟子 on a tile next to it (the +// PRTS order above; community report, 0.2.2 — until 0.2.1 the battle start dropped none, so a lone 棋子 never set off), at +// most attack@max_spawn_cnt on the field; each later return counts as one too [ASSUMED]. The battle start deploys one +// piece after another (Battle.start), so a 跟子 may land on the tile of a piece still to come: "跟子所在格进行部署(或进行类似 +// 部署的行为)时,该跟子会随之消失" (PRTS 铸子 备注) — any deployment on its tile removes it. When 望 leaves the field her 棋子 +// and 跟子 vanish (PRTS 分支特性信息); the 棋子 come back with her. // - T2 料敌机先 (attack@per_atk_scale / attack@per_magic_resist_penetrate_fixed / attack@max_trigger_cnt): an active 棋子 / 跟子 // gets one stack per piece of the unbroken line it is part of (itself included; the longer of its lines; ≤ the cap), // kept once reached [ASSUMED]; its damage ×(1 + n × per) and n × pen RES ignored. TRP-X stage 3: 15 % / 13. @@ -52,8 +55,9 @@ // attack@duration s, each one on its own timer, added up (speed ≥ 0.1). Active as S1. // - S3 天下劫 (MANUAL, data ACTIVE_RANGE on its 4-12; ammo trigger_time): passive — the pieces' trigger and damage area is the // token skill's range (x-6 in the data; PRTS writes x-1), atk_scale × her ATK arts on every enemy there. Active — no -// attacks, range 4-12; +cnt stock, the part over the cap placed at once as 跟子 on the enemies of her range first (her -// target order), then on other tiles of it by the 铸子 order [ASSUMED for the ties: nearest to her, then by tile]; a 棋子 +// attacks, range 4-12; +cnt stock, the part over the cap placed at once as 跟子 on the tiles of her range by the 铸子 order +// (S3 备注 "采用相同的位置选择优先顺序"): enemy tiles first, then 不可部署 > 可部署地面 > 高台 [ASSUMED for the ties: her target +// order among enemy tiles, then nearest to her, then by tile]; a 棋子 // coming back inside her range during it drops up to 3 跟子, one bullet each ("第一天赋额外至多部署3枚棋子并消耗等量弹药"); // it ends when the bullets are spent ("棋子耗尽" [ASSUMED]); bullets left at another end return to the stock. @@ -231,6 +235,9 @@ export default { const w = owner.mem.wang; unit.mem.wangNode = { seq: ++w.seq, act: false, h: false, v: false, stacks: 0 }; w.stock = Math.max(0, w.stock - 1); // it occupies her stock (the owner's decision of 2026-10-06) + // 铸子: the battle-start deployment of a placed 棋子 is its 手动部署 — one 跟子 next to it (a return drops its own in + // scheduleReturn) + if (ctx.initial && up(owner)) dropFollowers(battle, owner, unit.tileR, unit.tileC, 1); }, { owner: unit }); battle.on('death', (ctx) => { if (ctx.unit !== unit || battle.finished) return; @@ -264,28 +271,22 @@ export default { const total = w.stock + Math.max(0, Math.floor(num(b3.cnt))); const over = Math.max(0, total - stockCap); w.stock = Math.min(stockCap, total); - // "将超出上限的棋子优先部署在范围内敌人所在位置": enemies of her (4-12) range first, then other tiles of it - let n = Math.min(over, followCap - w.followers.length); + // "将超出上限的棋子优先部署在范围内敌人所在位置" and the 备注's "采用相同的位置选择优先顺序" (铸子): the free tiles of her + // (4-12) range, those holding an enemy she can target first, then 不可部署 > 可部署地面 > 高台; [ASSUMED] ties: her + // target order among enemy tiles, then nearest to her, then by tile + const n = Math.min(over, followCap - w.followers.length); if (!(n > 0)) return; const taken = new Set(piecesOf(battle, unit).map((p) => keyOf(p.r, p.c))); const foes = sortEnemyTargets(battle, unit, battle.enemiesInKeys(unit.rangeKeys, unit, ANY), unit.profile?.priority ?? null); - for (const e of foes) { - if (n <= 0) break; - const r = Math.round(e.y), c = Math.round(e.x); - if (!followerFree(battle, unit, r, c, taken)) continue; - taken.add(keyOf(r, c)); - addFollower(battle, unit, r, c); - n--; - } - if (n <= 0) return; - const rest = []; + const cands = []; for (const k of unit.rangeKeys ?? []) { const r = Math.floor(k / COLS), c = k % COLS; if (!followerFree(battle, unit, r, c, taken)) continue; - rest.push({ r, c, k, cls: tileClass(battle, r, c), d: Math.max(Math.abs(r - unit.tileR), Math.abs(c - unit.tileC)) }); + const fi = foes.findIndex((e) => e.alive && bodyOnTile(e, r, c)); + cands.push({ r, c, k, foe: fi >= 0 ? 0 : 1, fi: fi >= 0 ? fi : 0, cls: tileClass(battle, r, c), d: Math.max(Math.abs(r - unit.tileR), Math.abs(c - unit.tileC)) }); } - rest.sort((a, b) => a.cls - b.cls || a.d - b.d || a.k - b.k); - for (const t of rest.slice(0, n)) addFollower(battle, unit, t.r, t.c); + cands.sort((a, b) => a.foe - b.foe || a.cls - b.cls || a.fi - b.fi || a.d - b.d || a.k - b.k); + for (const t of cands.slice(0, n)) addFollower(battle, unit, t.r, t.c); }, onEnd({ unit, reason }) { const w = unit.mem.wang; @@ -360,6 +361,15 @@ export default { const w = unit.mem.wang; w.followers = w.followers.filter((f) => f !== n); }; + // "跟子所在格进行部署(或进行类似部署的行为)时,该跟子会随之消失" (PRTS 铸子 备注): any ally deploying on a 跟子's tile + // (priority 50: before that piece's own 铸子 drop) + battle.on('deploy', (ctx) => { + const a = ctx.unit, w = unit.mem.wang; + if (!a || a.side !== 'ally' || !w || !w.followers.length) return; + const n0 = w.followers.length; + w.followers = w.followers.filter((f) => f.r !== a.tileR || f.c !== a.tileC); + if (w.followers.length !== n0) battle.fx('disappear', { x: a.tileC, y: a.tileR, id: unit.id }); + }, { owner: unit, priority: 50 }); battle.on('tick', () => { if (!up(unit) || battle.finished) return; const nodes = piecesOf(battle, unit); diff --git a/test/content/op_wang.test.js b/test/content/op_wang.test.js index a3e21e3b..5b1af38b 100644 --- a/test/content/op_wang.test.js +++ b/test/content/op_wang.test.js @@ -101,12 +101,23 @@ test('望 in every 自选 form: her kit, stats + module attributes, 3-3, blocks assert.equal(validateDiyPicks({ [SLOT[6]]: { charId: WANG, skillIndex: 2, uniEquipId: X } }, { data, kitted: KITTED_CHARS }).ok, true); }); -test('铸子: a lone 棋子 never sets off; two side by side activate each other — an enemy (air too) on one sets it off and it is used up; it stays active once its partner is gone', () => { - { // lone - const { h, st } = field({ tier: 6, elite: true, skill: 1, stones: [[9, 6]] }); +test('铸子: the battle-start deployment of a placed 棋子 is its 手动部署 — one 跟子 next to it, so a lone 棋子 is live; with no free neighbour it never sets off; two side by side activate each other — an enemy (air too) on one sets it off and it is used up; it stays active once its partner is gone', () => { + { // lone: its 跟子 上 of it (its neighbours are road, 下 is off the field) — they connect + const { h, u, st } = field({ tier: 6, elite: true, skill: 1, stones: [[9, 6]] }); + assert.deepEqual(placed(h), [[10, 6]], 'the battle start drops one 跟子, 上 first among equal tiles'); + assert.equal(u.mem.wang.stock, 6, 'the 棋子 takes one of her 7; the 跟子 none'); + h.spawn('enemy_dummy', { pos: [9, 6] }); + h.step(); + assert.ok(!st[0].alive && stoneHits(h).length > 0, 'a lone 棋子 with its 跟子 sets off'); + done(h); + } + { // no free neighbour (operators on 上 / 右 / 左, 下 off the field): no 跟子, never sets off + const others = [[10, 6], [9, 7], [9, 5]].map(([row, col], i) => ({ uid: 10 + i, chessId: 'chess_char_1_01_a', row, col })); + const { h, st } = field({ tier: 6, elite: true, skill: 1, stones: [[9, 6]], others }); + assert.equal(placed(h).length, 0, 'nowhere to put it'); h.spawn('enemy_dummy', { pos: [9, 6] }); h.run(3); - assert.equal(stoneHits(h).length, 0, 'a lone 棋子'); + assert.equal(stoneHits(h).length, 0, 'a 棋子 with no partner'); assert.ok(st[0].alive); done(h); } @@ -115,7 +126,7 @@ test('铸子: a lone 棋子 never sets off; two side by side activate each other h.step(); const a = at(st, 9, 6), b = at(st, 9, 7); assert.ok(a.mem.wangNode.act && b.mem.wangNode.act, 'connected ⇒ active'); - assert.equal(placed(h).length, 0, 'the battle-start deployment drops no 跟子'); + assert.deepEqual(placed(h), [[10, 6], [10, 7]], 'the battle start drops one 跟子 上 of each'); const fl = h.spawn('enemy_fly', { pos: [9, 7] }); h.step(); assert.ok(!b.alive && a.alive, 'the flyer set off its 棋子, which is used up'); @@ -142,11 +153,10 @@ test('铸子: a spent 棋子 comes back on its tile 2 s later for its cost (TRP- const dp0 = h.b.players[0].dp; assert.ok(h.runUntil(() => b.alive, 1), 'back'); approx(h.b.players[0].dp, dp0 - (mod === X ? 2 : 3), `${mod ?? 'none'}: its cost`); - // the 跟子: 上 of (10,7) is (11,7) — deployable ground; no enemy anywhere ⇒ undeployable first: (10,7)'s neighbours on - // the flat stage are all deployable road but 右 (10,8) … the order 上 > 右 > 下 > 左 among equals - const f = u.mem.wang.followers; - assert.equal(f.length, 1, 'one 跟子'); - assert.deepEqual([f[0].r, f[0].c], [11, 7], '上 first among equal tiles'); + // the battle start dropped one 跟子 上 of each (equal road tiles: 上 > 右 > 下 > 左); the return of (10,7) finds its 上 + // (11,7) taken by its own and (10,6) by the other 棋子, so 右 (10,8) + assert.deepEqual(placed(h), [[11, 6], [11, 7], [10, 8]], `${mod ?? 'none'}: 上 at the battle start, then 右`); + assert.equal(u.mem.wang.followers.length, 3); done(h); } { // an enemy tile first; undeployable ground before deployable ground @@ -156,23 +166,36 @@ test('铸子: a spent 棋子 comes back on its tile 2 s later for its cost (TRP- h.step(); assert.ok(!b.alive); h.b.kill(h.b.enemies.find((x) => x.alive), null); + // the battle start: (11,9) deploys first (a column top to bottom) — its 右 (11,10) is undeployable floor, before its + // road 上; then (10,9) takes its floor 右 (10,10) + assert.deepEqual(placed(h), [[11, 10], [10, 10]], '不可部署地块 > 可部署地面地块 (before 上)'); const fl = h.spawn('enemy_fly', { pos: [9, 9] }); // 下 of (10,9) holds an enemy (a flyer: no block on the trap rule) assert.ok(h.runUntil(() => b.alive, 3), 'back (a flyer does not stop it)'); - assert.deepEqual(placed(h), [[9, 9]], 'the enemy tile first (before the undeployable 右 (10,10))'); + assert.deepEqual(placed(h)[2], [9, 9], 'the return: the enemy tile first'); h.step(); assert.ok(h.hooksOf('damaged').some((c) => c.target === fl && c.source === u && c.dmg.tags.includes('wang:stone')), 'the 跟子 sets off at once — its damage is 望\'s'); - assert.equal(u.mem.wang.followers.length, 0); + assert.equal(u.mem.wang.followers.length, 2, 'the two on the floor stay; the one on the flyer is spent'); done(h); } - { // no enemy around: undeployable ground (10,10) before the road + { // a return with no enemy around: undeployable ground (10,10) before the road const { h, u, st } = field({ tier: 6, elite: true, skill: 1, stones: [[10, 9], [11, 9]], dp: 50 }); const b = at(st, 10, 9); const e = h.spawn('enemy_dummy', { pos: [10, 9] }); h.step(); h.b.kill(e, null); + u.mem.wang.followers.length = 0; // (the battle start's 跟子 gone: the floor beside it is free again) + const n0 = placed(h).length; assert.ok(h.runUntil(() => b.alive, 3)); - assert.deepEqual(placed(h), [[10, 10]], '不可部署地块 > 可部署地面地块'); - void u; + assert.deepEqual(placed(h).slice(n0), [[10, 10]], '不可部署地块 > 可部署地面地块'); + done(h); + } + { // a 跟子 on the tile of a piece still to come at the battle start goes when that piece deploys there ("跟子所在格进行部署… + // 时,该跟子会随之消失"): (10,3)'s 上 holds an operator, so its 跟子 takes 右 (10,4) — the next 棋子's tile —, which + // deploys there and drops its own 上 (11,4) + const { h, u } = field({ tier: 6, elite: true, skill: 1, stones: [[10, 3], [10, 4]], others: [{ uid: 10, chessId: 'chess_char_1_01_a', row: 11, col: 3 }] }); + assert.deepEqual(placed(h), [[10, 4], [11, 4]]); + assert.deepEqual(u.mem.wang.followers.map((f) => [f.r, f.c]), [[11, 4]], 'the one on (10,4) is gone'); + assert.ok(h.eventsOf('fx').some((e) => e[1] === 'disappear' && e[2] === 4 && e[3] === 10), 'it vanishes'); done(h); } { // 望 away ⇒ her 棋子 leave and wait for her; her 跟子 are gone @@ -306,13 +329,14 @@ test('S2 连星 (AUTO, SP_FULL): a triggered piece hits every enemy on its conne const { h, u } = field({ tier, elite, skill: 1, stones: [[10, 6], [10, 7]] }); const onLine = h.spawn('enemy_fly', { pos: [10, 9] }); // 3 tiles right of (10,6)…(10,7)+2: on the horizontal line const beyond = h.spawn('enemy_dummy', { pos: [10, 11] }); // 4 tiles past (10,7) - const off = h.spawn('enemy_dummy', { pos: [11, 7] }); // off the line (vertical never activated) + const vertical = h.spawn('enemy_dummy', { pos: [12, 7] }); // its battle-start 跟子 (11,7) is 上 of it: the vertical line is on + const off = h.spawn('enemy_dummy', { pos: [9, 4] }); // on neither line const trig = h.spawn('enemy_runner', { pos: [10, 7] }); h.b.applyStatus(trig, 'stun', { duration: 99, force: true }); // it stays on the tile (the slow is read off its speed stat) h.step(); const hit = stoneHits(h); const victims = new Set(hit.map((c) => c.target)); - assert.ok(victims.has(trig) && victims.has(onLine), `T${tier}: the line, air included`); + assert.ok(victims.has(trig) && victims.has(onLine) && victims.has(vertical), `T${tier}: the lines, air included`); assert.ok(!victims.has(beyond) && !victims.has(off), `T${tier}: not beyond ± 3 / off the line`); for (const c of hit.filter((x) => x.target === trig)) approx(c.amount, u.s.atk * sk.bb['attack@atk_scale'] * 1.24, `T${tier}: 2 stacks`, 1e-4); const slow = trig.findBuff('wang:slow'); @@ -359,8 +383,9 @@ test('S3 天下劫 (MANUAL, ACTIVE_RANGE 4-12, 20 bullets): passive — the piec assert.equal(u.skill.attackOverride()?.noAttack, true, `T${tier}: 停止攻击`); assert.equal(u.mem.wang.stock, 8, `T${tier}: +8, capped`); const fl = placed(h).map(([r, c]) => `${r},${c}`); - assert.equal(fl.length, 5, `T${tier}: (7 − 2 placed) + 8 − 8 over the cap`); - assert.deepEqual(fl.slice(0, 2).sort(), ['10,7', '9,5'], `T${tier}: the enemies' tiles first (${fl})`); + assert.deepEqual(fl.slice(0, 2), ['12,6', '12,7'], `T${tier}: the battle start's 跟子, 上 of each (${fl})`); + assert.equal(fl.length, 7, `T${tier}: then (7 − 2 placed) + 8 − 8 over the cap`); + assert.deepEqual(fl.slice(2, 4).sort(), ['10,7', '9,5'], `T${tier}: the overflow on the enemies' tiles first (${fl})`); assert.ok(h.hooksOf('damaged').some((c) => c.target === a && c.source === u && c.dmg.tags.includes('wang:stone')), `T${tier}: they set off`); // inside her range a 棋子 comes back onto an enemy ("可部署至敌人所在位置") — and sets off again h.b.kill(a, null); h.b.kill(b, null); @@ -390,6 +415,18 @@ test('S3 天下劫 (MANUAL, ACTIVE_RANGE 4-12, 20 bullets): passive — the piec } }); +test('S3 天下劫: the overflow 跟子 take her range by the 铸子 order (S3 备注 "采用相同的位置选择优先顺序") — enemy tiles first, an enemy on undeployable floor before one on the road her target order puts first, then the floor', () => { + const { h, u } = field({ tier: 6, elite: true, skill: 2, row: 10, col: 6, stones: [[11, 4], [11, 5]], dp: 300 }); + assert.deepEqual(placed(h), [[12, 4], [12, 5]], 'the battle start'); + u.skill.gainSp(999); + h.spawn('enemy_dummy', { pos: [10, 10] }); // on tile_floor + h.spawn('enemy_dummy', { pos: [10, 8] }); // on the road, nearer the goal + assert.ok(h.runUntil(() => u.skill.active, 2)); + const fl = placed(h).slice(2).map(([r, c]) => `${r},${c}`); + assert.deepEqual(fl, ['10,10', '10,8', '9,10', '11,10', '12,10'], `(7 − 2) + 8 − 8 = 5: the enemies' tiles by class, then the undeployable tiles (${fl})`); + done(h); +}); + test('the hand: deploying her in prep brings 7 棋子 (TRP-X "可同时部署的陷阱数量提升": 8) — the match\'s data view reads the module variant\'s deploy limit', () => { const gd = new GameData(getData({ log: { warn() {}, error() {}, info() {} } }), 'mode_multi_hard'); const data = { chess: CHESS, backups: BACKUPS }; From 5f98baecc1dc2e164447af571a26c19c8b3347b2 Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 20:07:06 +0800 Subject: [PATCH 6/8] =?UTF-8?q?feedback7=20R22-OSIM:=20=E7=90=B3=E7=90=85?= =?UTF-8?q?=E8=AF=97=E6=80=80=E9=9B=85's=20=E9=A6=99=E6=A7=9F=E7=82=B8?= =?UTF-8?q?=E5=BC=B9=20holds=20=E7=A6=81=E7=96=97,=20loses=20no=20HP=20and?= =?UTF-8?q?=20never=20leaves=20because=20of=20its=20HP=20(the=20owner's=20?= =?UTF-8?q?decision=20D2);=20golden:=20bond-yanShip-high,=20coop2-HARD-4?= =?UTF-8?q?=20move?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's decision D2 of 2026-10-08, a deliberate deviation: PRTS 香槟炸弹 gives it 1000 HP and the note "即使自身生命值未满,模型下方也不会显示生命值槽" (officially it can lose HP), and it had no 无敌 / 禁疗. Now (tokens.js champagneHold, installed by the token kit and by 琳琅诗怀雅's own bomb): - 禁疗: no heal reaches it, no healer picks it (flags noHeal + healFree, no status icon); - no HP loss: every damage to it is cancelled in its `hit` / `elementHit` hooks (活性源石, 污染秽蚀, …) and a 流失 is given back before the knock-out check, so it never leaves because of its HP; - its 见面礼 is unchanged: the first ground enemy that touches it, then it is gone. No 无敌 flag (that would put a badge over it); the client side (its hidden HP bar) belongs to the render stream. Golden: bond-yanShip-high (a healer of that team spent 27 heals — 5040 HP — on a bomb standing on 活性源石; now it attacks nothing there) and coop2-HARD-4 (ai_1's bombs from round 6 on stood on 活性源石 / 污染秽蚀; their longer life changes later rounds' layers). Tests: test/content/feedback7-champagne-hold.test.js (3; 2 fail on the parent, the 见面礼 guard passes on both). --- docs/PLAYING.md | 2 +- .../kits/ops/chess_char_3_04-swire2.js | 2 + server/sim/content/tokens.js | 24 ++++++- test/content/feedback7-champagne-hold.test.js | 67 +++++++++++++++++++ test/golden/bonds.json | 12 ++-- test/golden/matches.json | 18 ++--- 6 files changed, 106 insertions(+), 19 deletions(-) create mode 100644 test/content/feedback7-champagne-hold.test.js diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 0081275f..6ae13276 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -73,7 +73,7 @@ - 拖动时模型一直在指针 / 手指下(脚在指针下方约半格,鼠标和触屏一样),**指针所在的格子**就是落点并高亮;装备拖到哪个格子,就配发给那个格子上的干员;在商店栏上松手则放回原位。 - 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息:头像旁是盟约,下面第一项就是干员的**特质**(如「休整期结束时 …」),之后是特性、属性、攻击范围、技能、模组、装备、天赋。属性是**实时数值**:作战中每秒刷新 4 次(当前生命、攻击、防御、法抗、攻击间隔、阻挡),休整期显示棋盘上的干员**开战时**的数值(已算入装备、盟约层数、特质、策略与机变效果);高于基础值为绿色、低于为红色并标出差值(攻击间隔变短算增益),鼠标悬停可看基础值。基础值是官方数据在该阶级的等级、技能等级(和精锐的模组)下、**满潜能**、0 信赖的数值:所有干员(精锐、补位、自选也一样)都带上全部潜能加成——部署费用、再部署时间、攻击力 / 生命 / 防御 / 法抗 / 攻速,以及「天赋效果增强」,召唤物的天赋也按召唤者的满潜能(维护者 2026-10-07 的决定,GitHub #252:官方赛事视频里的刺玫正是满潜能的攻击 435、费用 15)。 - **召唤物手动摆放**:把带可部署召唤物的干员放上棋盘后,它的召唤物会进入整备区(赫默的医疗探机、巫恋的诅咒娃娃、凯瑟琳的 2 个支援装置、浊心斯卡蒂的海嗣、伺夜的狼群、缪尔赛思的流形;同一干员的召唤物叠在一格,不占部署位),像干员一样拖到棋盘上并选方向。凯瑟琳的支援装置给**它面朝的那一格**上的干员加屏障,所以要放在干员旁边、朝向干员。**战术家的援军**(伺夜的狼群、缪尔赛思的流形,也就是选战术点)和官方一样**只能放在召唤者的攻击范围内**:拖动时只有这些格子会亮起;之后在原地给战术家换朝向时,不在新攻击范围里的援军会退回整备区(还在范围里的保持不动;整备区和临时整备区都满、援军无处可退时,这次换朝向不生效并提示「整备区已满」)。撤退或出售干员时它的召唤物一起移除;**移动干员到别的格子时(包括把召唤物拖到干员身上交换位置),它已摆放的召唤物会退回整备区**,需要重新摆放。整备区满时退回的召唤物进入临时整备区,休整期结束还没处理的会在下一回合重新回到整备区。 -- 作战中:海嗣、狼群、流形、支援装置开战时就在摆放的位置部署;医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次(持续 10 / 15 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、海嗣(以及自选干员的工程蓄水炮、沙地兽、打字机、本能的召唤)这类不会受到攻击、到时间就离场的召唤物不受任何伤害(活性源石也一样)、也不会被治疗,它们的血条就是倒计时:随时间减少,空了就离场。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。狼群最后一只狼影被击倒后进入战术点形态:暂时离开战斗(不阻挡、不攻击、不会被攻击,格子仍然占着),经过狼影的恢复时间(25 秒)后在原地满血、带着 1 只狼影回来,之后重新开始每 25 秒加 1 只狼影;伺夜退场时狼群随之撤退,伺夜再部署回场时狼群重新带着 2 只狼影出现。 +- 作战中:海嗣、狼群、流形、支援装置开战时就在摆放的位置部署;医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次(持续 10 / 15 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、海嗣(以及自选干员的工程蓄水炮、沙地兽、打字机、本能的召唤)这类不会受到攻击、到时间就离场的召唤物不受任何伤害(活性源石也一样)、也不会被治疗,它们的血条就是倒计时:随时间减少,空了就离场。琳琅诗怀雅二技能的香槟炸弹也不会掉血(活性源石和各种伤害都不会)、不会被治疗,不会因为生命值而消失,只在「见面礼」炸到碰到它的第一个地面敌人后消失(维护者 2026-10-08 的决定)。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。狼群最后一只狼影被击倒后进入战术点形态:暂时离开战斗(不阻挡、不攻击、不会被攻击,格子仍然占着),经过狼影的恢复时间(25 秒)后在原地满血、带着 1 只狼影回来,之后重新开始每 25 秒加 1 只狼影;伺夜退场时狼群随之撤退,伺夜再部署回场时狼群重新带着 2 只狼影出现。 - **阻挡**看接触:干员阻挡中心距离约 0.7 格以内、与它接触的敌人(地面干员挡不住飞行和近地悬浮的敌人)。敌人卡在两名干员之间时,阻挡它的干员倒下、撤退或被晕眩后,另一名仍与它接触、还有空余阻挡数的干员会**立刻接替阻挡**;都没有空位时敌人才会走掉。被冻结或晕眩的敌人同样会被接触到的干员挡住:干员再部署到它身边就会阻挡它,隐匿的敌人也随之破隐(沉睡、浮空、恐惧的敌人仍然挡不住)。迎面走来的敌人会停在干员格子前面。**阻挡了就一定能打到**:任何干员(包括站在近战位的远程干员和召唤物)总能攻击自己阻挡的敌人,不管它在不在攻击范围内、在干员哪一侧,并且优先攻击它(医疗干员除外:咒愈师以外的医疗挡住敌人时照常治疗队友,不会攻击它;「停止攻击」类技能期间也除外;技能原文另有限制的也按原文:普罗旺斯二技能开启时不以生命值高于 80% 的敌人为目标,寒檀二技能的冰凌随机落在攻击范围内的地块上)。 - 开战时干员按**逐列从左到右、每列从上到下**的顺序部署(最左一列最先,与阿戈尔吞噬「更靠左和靠上」的顺序相同),召唤物在所有干员之后。被阻挡的敌人先打阻挡它的干员;没被阻挡的远程敌人先打嘲讽等级高的,同级时打最后部署的(召唤物、最右一列靠下的干员和刚再部署的干员更容易挨打)。隐匿的干员只会被它阻挡的敌人攻击,没被它挡住的敌人的溅射、爆炸、范围技能、留下的伤害区域(包括假想敌:蚀裂被击倒后喷出的毒雾)以及深池伙友卫队、扎罗的减攻速光环也落不到它身上(和官方一样;写明「无视无法选择」的效果如污染秽蚀照常能打到,寒霜的减攻速光环和“墓碑”这类地图效果照常生效);**迷彩**(如忍冬三技能期间击倒敌人后、寒芒克洛丝一技能)同样只会被它阻挡的敌人攻击,也显示为半透明,但躲不开溅射、爆炸和范围伤害(同时打范围内所有单位的普通攻击——如“斩胄之剑”“破胄之锤”“灵幛”——打不到它),也不算隐匿(不触发叙拉古等「隐匿时」效果)。叙拉古的隐匿按时(32 秒 + 每层 0.4 秒)结束;忍冬的迷彩按官方持续到她下一次开技能。**蒂比起飞**期间只挡飞行敌人,地面敌人不会选她为目标(普通攻击、溅射、技能范围、燃烧区域和要选中目标的减益都落不到她身上);飞行和悬浮敌人、写明「无视无法选择」的效果(如污染秽蚀)照常能打到她;地面敌人的光环和全场效果(如“墓碑”让全场攻击力、防御力减半)、她攻击敌人时招来的反制(如重装侦察兵的暴露)照常生效,起飞前已挂上的持续伤害也照常跳。 - **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**开启后攻击范围比平时大**的手动技能(如莫斯提马「序时之匙」、银灰「真银斩」、史尔特尔「黄昏」、白面鸮「脑啡肽」)在**开启后的攻击范围内出现敌人**(治疗技能:有受伤的队友)时就释放,不必等敌人走进平时的攻击范围,这是本作按玩家反馈有意与官方不同的地方;**重装干员**的手动技能在受到伤害时释放(例外:深巡、雷蛇的二技能,号角的二、三技能,灰毫的一、二技能按玩家反馈改为攻击范围内有敌人时就释放(深巡二技能按开启后更远的攻击范围判断),余的二技能「厚礼上宾」改为周围两格(技能范围)内有敌人时就释放,这是本作有意与官方不同的地方;深巡一技能仍要受到伤害);执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放(开启后攻击范围扩大的技能——薄绿「风语」、蜜蜡「沙暴扩散」、卡涅利安「食噬之印」、玛恩纳「未宽解的悲哀」,以及安洁莉娜「秘杖·反重力模式」——同样按开启后的攻击范围判断)。**自动触发**的技能按其本身规则:只作用于队友的(如引星棘刺「度算浪波」、浊心斯卡蒂「同葬无光之愿」、纯烬艾雅法拉「无声润物」)技力满就释放,不用等敌人或伤员;伺夜「领袖的呼唤」「领袖的馈赠」只在场上有狼群时释放:狼群处于战术点形态时,「领袖的呼唤」也会释放并让它立刻带着 1 只狼影回到场上(狼影未满时改为加 1 只狼影,已满时把狼群的生命值回满),「领袖的馈赠」也会释放(立即获得费用,强化留给回来的狼群的下一次攻击);又如古米「备用军粮」在身边(技能范围内)有受伤的队友时就会发动,下一次攻击改为治疗该队友(不用先接敌;她的二技能「食粮烹制」是手动技能,按重装规则在受到伤害时释放);塞雷娅「急救」在她即将攻击时、身边有生命不高于一半的队友才发动,这次攻击直接改为治疗;新约能天使二技能的护盾按官方给攻击范围内**仇恨最高**的队友(嘲讽等级高的优先,同级时给最后部署的),不一定是站在最前面挨打的那个。手动技能的自动释放有 3 秒冷却:开战部署后先等 3 秒,之后同一干员两次自动释放至少间隔 3 秒(可充能的技能不会同时放完)。白铁的「铁钳号·原型机」能被我方干员攻击(不受伤害):它在攻击范围 / 技能范围内时,非治疗技能会像遇到敌人一样自动开启并消耗弹药(项目维护者 2026-10-08 按官方体验确认;白铁™多功能平台不会被攻击)。 diff --git a/server/sim/content/kits/ops/chess_char_3_04-swire2.js b/server/sim/content/kits/ops/chess_char_3_04-swire2.js index 0b137b44..fdd47117 100644 --- a/server/sim/content/kits/ops/chess_char_3_04-swire2.js +++ b/server/sim/content/kits/ops/chess_char_3_04-swire2.js @@ -8,6 +8,7 @@ import { groundTile, enemiesOn, } from '../shared/tier3.js'; import { powi } from '../../../detmath.js'; +import { champagneHold } from '../../tokens.js'; /** 琳琅诗怀雅 S3's coin range (PRTS 备注 "前方范围2-4"; range_table "2-4", facing right). */ const SWIRE2_COIN_GRID = Object.freeze([[1, 1], [0, 0], [0, 1], [0, 2], [-1, 1]]); @@ -73,6 +74,7 @@ export default { skill: null, trait: { noAttack: true }, install(battle, bomb) { + champagneHold(battle, bomb); // 禁疗, no HP loss, never gone because of its HP (the owner's decision D2 of 2026-10-08) battle.on('tick', () => { if (!bomb.alive || !bomb.deployed) return; for (const e of battle.enemies) { diff --git a/server/sim/content/tokens.js b/server/sim/content/tokens.js index 77f8a7c4..408d120e 100644 --- a/server/sim/content/tokens.js +++ b/server/sim/content/tokens.js @@ -33,7 +33,8 @@ // (never while 缪尔赛思 is down: her redeploy brings it back; split clones are never her 援军) // 香槟炸弹 untargetable trap: the first ground enemy touching its tile takes owner ATK × atk_scale phys (+1 hit // after duration_switch s on the field) and sluggish; the bomb is used up ('expired', never a knock-out; -// its blast fx carries `consumed: true` so clients play the explosion, not a death sound) +// its blast fx carries `consumed: true` so clients play the explosion, not a death sound); 禁疗, no HP +// loss, never gone because of its HP (champagneHold — the owner's decision D2 of 2026-10-08) // 从不混淆的方向 untargetable marker; when the owner's skill ends it vanishes and the owner returns to its tile // 黄金盟誓 attacks deal true damage (trait); lasts while the owner's skill runs; 维娜 S3 places one on every // free deployable tile around her (kits/ops/chess_char_6_07-siege2.js) — no per-owner deploy limit (SKILL_SUMMON_UNCAPPED) @@ -79,6 +80,7 @@ // // Exports for other content: spawnYanyou, spawnMapChar, findSummonTile, summonToken, tacticalPoint, wolfShadows, // COUNTDOWN_SUMMONS / startCountdown (the countdown summons' 无敌 + 禁疗 and timer bar, shared with the kits that time them), +// champagneHold (香槟炸弹's durability, shared with 琳琅诗怀雅's kit, kits/ops/chess_char_3_04-swire2.js), // wolfShadowInterval, installWolfTacticalPoint, wolfTacticalPoint, wolfReturnNow (the 狼群's 战术点形态, shared with the // 伺夜 kit's own pack, kits/ops/chess_char_3_19-vigil.js), releaseSkillSummon, SKILL_SUMMON_START_DEPLOY, CAT_SHIELD_KEY, // TOKEN_IDS; mapCharTalents and touchGospel (Touch's 攫升 / 超脱 and 恳切福音, shared with the Touch 补位 stand-in kit, @@ -1029,7 +1031,23 @@ function manifold(bb, raw, def) { }; } -/** 香槟炸弹 (琳琅诗怀雅 S2): untargetable trap consumed by the first ground enemy touching its tile. */ +/** + * 香槟炸弹's durability — the owner's decision D2 of 2026-10-08, a deliberate deviation (PRTS 香槟炸弹: 1000 HP and the 备注 + * "即使自身生命值未满,模型下方也不会显示生命值槽" — officially it can lose HP; no 无敌 / 禁疗 note): it holds 禁疗 (no heal + * reaches it, no healer picks it), loses no HP (every damage to it is cancelled — the 活性源石 tick, an element hit too — + * and a 流失 is given back before the knock-out check) and so never leaves because of its HP; it goes when its 见面礼 is + * spent, as before. Flags only: no status icon, no 无敌 badge (its HP bar is hidden by the client). The token kit below + * and 琳琅诗怀雅's own bomb (kits/ops/chess_char_3_04-swire2.js) both install it. Until 0.2.1 a bomb on 活性源石 lost 70 HP/s + * and a medic healed a hurt one. + */ +export function champagneHold(battle, bomb) { + battle.addBuff(bomb, { key: 'token:champagneHold', flags: { noHeal: true, healFree: true }, persist: true, allowDead: true }); + battle.on('hit', (ctx) => { if (ctx.target === bomb) ctx.dmg.cancel = true; }, { owner: bomb, priority: 1000 }); + battle.on('elementHit', (ctx) => { if (ctx.target === bomb) ctx.dmg.cancel = true; }, { owner: bomb, priority: 1000 }); + battle.on('damaged', (ctx) => { if (ctx.target === bomb && bomb.alive) bomb.hp = bomb.s.maxHp; }, { owner: bomb, priority: 1000 }); +} + +/** 香槟炸弹 (琳琅诗怀雅 S2): untargetable trap consumed by the first ground enemy touching its tile (champagneHold). */ function champagne(bb) { const scale = num(bb['attack@atk_scale'] ?? bb.atk_scale, 0); const slug = num(bb['attack@sluggish'] ?? bb.sluggish, 0); @@ -1056,7 +1074,7 @@ function champagne(bb) { }, }, trait: { noAttack: true }, - install(battle, unit) { untargetable(battle, unit); }, + install(battle, unit) { untargetable(battle, unit); champagneHold(battle, unit); }, }; } diff --git a/test/content/feedback7-champagne-hold.test.js b/test/content/feedback7-champagne-hold.test.js new file mode 100644 index 00000000..8d5ed18c --- /dev/null +++ b/test/content/feedback7-champagne-hold.test.js @@ -0,0 +1,67 @@ +// test/content/feedback7-champagne-hold.test.js — 琳琅诗怀雅 S2's 香槟炸弹 (token_10031_swire2_gdtrap) under the owner's +// decision D2 of 2026-10-08: it holds 禁疗, loses no HP (活性源石, damage, a 流失) and never leaves because of its HP; its +// 见面礼 is unchanged (the first ground enemy that touches it, then it is gone). Both kits: 琳琅诗怀雅's own bomb and the +// token kit (content/tokens.js champagne, a bomb spawned without her kit). +// Run: node --test test/content/feedback7-champagne-hold.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; + +const TRAP = 'token_10031_swire2_gdtrap'; +const SW = 'chess_char_3_04_a'; +/** 琳琅诗怀雅 on (9,5) of a field whose only free x-6 tile is (9,6) — 活性源石 when `infected`; she throws one bomb there. */ +function herBomb({ infected = false, units = [], enemies = [] } = {}) { + const row9 = infected ? '##ERRriRrrSrrrrrrrS##' : '##ERRrrRrrSrrrrrrrS##'; + const h = makeBattle({ + flat: { rows: { 9: row9, 10: '##hrrRrrrrfrrrrrrrf##', 11: '##hrrRrrrrfrrrrrrrf##' } }, + defs: { enemies: { e_w: enemyRec({ key: 'e_w', hp: 1e6, speed: 1, def: 0, atk: 0 }) } }, + timeLimit: 60, autoFinish: false, hooks: ['heal', 'death', 'damaged'], captureNoisy: true, + units: [{ uid: 1, chessId: SW, row: 9, col: 5 }, ...units], enemies, + }); + h.step(); + h.unit(1).mem.coins = 1; + assert.ok(h.runUntil(() => h.b.allyUnits.some((t) => t.alive && t.defId === TRAP), 6), 'she threw a bomb'); + const bomb = h.b.allyUnits.find((t) => t.alive && t.defId === TRAP); + assert.deepEqual([bomb.tileR, bomb.tileC], [9, 6]); + return { h, bomb, sw: h.unit(1) }; +} + +test('香槟炸弹 (her kit): no HP loss on 活性源石, 禁疗, a lethal 流失 or hit leaves it at full HP and on the field', () => { + const { h, bomb } = herBomb({ infected: true }); + assert.ok(bomb.s.flags.untargetable && bomb.s.flags.noHeal && bomb.s.flags.healFree, '不可选中 + 禁疗'); + assert.ok(!bomb.s.flags.invulnerable, 'no 无敌 flag (no badge on the client)'); + h.run(3.2); + assert.equal(bomb.hp, bomb.s.maxHp, '活性源石 takes nothing'); + assert.equal(h.b.heal(h.unit(1), bomb, 500), 0, 'no heal reaches it'); + assert.equal(h.b.injuredAlliesInKeys([9 * 21 + 6], h.unit(1)).length, 0, 'no healer picks it'); + h.b.dealDamage(null, bomb, { amount: 1e6, type: 'true', tags: ['test'] }); + h.b.loseHp(bomb, 1e6, { tags: ['test'] }); + h.step(); + assert.ok(bomb.alive && bomb.deployed, 'not gone because of its HP'); + assert.equal(bomb.hp, bomb.s.maxHp); + assert.equal(h.hooksOf('death').filter((c) => c.unit === bomb).length, 0); + checkInvariants(h.b); +}); + +test('香槟炸弹 (her kit): its 见面礼 is unchanged — the first ground enemy touching it is hit and slowed, then the bomb is gone', () => { + const { h, bomb } = herBomb({ enemies: [{ key: 'e_w', route: 0, time: 1 }] }); + assert.ok(h.runUntil(() => !bomb.alive, 30), 'set off'); + const e = h.b.enemies.find((x) => x.alive); + assert.ok(e && e.hp < 1e6 && e.findBuff('sluggish'), 'hit + 停顿'); + assert.ok(h.hooksOf('death').some((c) => c.unit === bomb && c.reason === 'expired'), 'used up'); + checkInvariants(h.b); +}); + +test('香槟炸弹 (the token kit, spawned without her kit): the same 禁疗, no HP loss, no knock-out', () => { + const h = makeBattle({ timeLimit: 30, autoFinish: false, hooks: ['death'], units: [{ uid: 1, chessId: SW, row: 12, col: 3 }] }); + h.step(); + const bomb = h.b.spawnToken(h.unit(1), TRAP, 9, 6); + assert.ok(bomb && bomb.s.flags.noHeal && bomb.s.flags.healFree && bomb.s.flags.untargetable); + h.b.loseHp(bomb, 1e6, { tags: ['test'] }); + h.b.dealDamage(null, bomb, { amount: 1e6, type: 'true', tags: ['test'] }); + h.step(); + assert.ok(bomb.alive && bomb.hp === bomb.s.maxHp); + assert.equal(h.b.heal(h.unit(1), bomb, 500), 0); + checkInvariants(h.b); +}); diff --git a/test/golden/bonds.json b/test/golden/bonds.json index ad9db9b7..470b2013 100644 --- a/test/golden/bonds.json +++ b/test/golden/bonds.json @@ -1793,7 +1793,7 @@ "bossHpLeft": null, "rngDraws": 10, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":21,"total":21,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":263171,"bossDmg":0,"heal":6511,"deaths":0,"layerGains":{}}}, + "players": {"p1":{"killed":21,"total":21,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":263171,"bossDmg":0,"heal":1471,"deaths":0,"layerGains":{}}}, "units": [ ["p1",1,"op","chess_char_4_17_b",1,"uniequip_002_hsguma",3513,1,8,0,0,0,0,0,0], ["p1",2,"op","chess_char_6_03_b",2,"uniequip_002_yu",3700,1,6,0,0,0,0,0,0], @@ -1803,14 +1803,14 @@ ["p1",6,"op","chess_char_2_04_b",1,"uniequip_002_grabds",1270,1,5,0,0,0,0,0,0], ["p1",7,"op","chess_char_5_03_b",2,"uniequip_002_blaze2",632.96,1,12,3,51359,2310,1471,8,2], ["p1",8,"op","chess_char_5_12_b",0,"uniequip_002_dusk",1787,1,7,4,68403,0,0,4,4], - ["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",1610,1,10,0,0,0,5040,27,2], + ["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",1610,1,10,0,0,0,0,0,0], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null], ["p1","~1","token","enemy_9012_acloon",null,null,17300.16,1,13,4,49667,389,0,2,2], ["p1","~2","token","enemy_9012_acloon",null,null,13634.89,1,14,2,28865,4054,0,1,1], ["p1","~1","token","token_10031_swire2_gdtrap",null,null,1000,0,15,0,0,0,0,0,null], - ["p1","~2","token","token_10031_swire2_gdtrap",null,null,930,1,18,0,0,5110,0,0,null], + ["p1","~2","token","token_10031_swire2_gdtrap",null,null,1000,1,18,0,0,0,0,0,null], ["p1","~3","token","token_10031_swire2_gdtrap",null,null,1000,0,27,0,0,0,0,0,null], ["p1","~4","token","token_10031_swire2_gdtrap",null,null,1000,0,30,0,0,0,0,0,null], ["p1","~5","token","token_10031_swire2_gdtrap",null,null,1000,0,33,0,0,0,0,0,null], @@ -1822,10 +1822,10 @@ ["p1","~10","token","token_10031_swire2_gdtrap",null,null,1000,1,46,0,0,0,0,0,null] ], "enemies": {"enemy_1019_jshoot_2":[7,7,0,1748,47029],"enemy_1381_winman":[9,9,0,0,129890],"enemy_1389_winbab_2":[5,5,0,5215,87091]}, - "hooks": [1,46,2282,121,121,188,224,960,22,30,12,9,6,5355,1,4,21,0,1,23,0,0,0], - "events": {"atk":121,"deploy":22,"die":30,"dmg":224,"engage":6,"fx":46,"heal":27,"skill":25,"spawn":46,"status":3}, + "hooks": [1,46,2282,94,94,188,151,933,22,30,10,8,6,4515,1,4,21,0,1,23,0,0,0], + "events": {"atk":94,"deploy":22,"die":30,"dmg":151,"engage":6,"fx":46,"skill":22,"spawn":46,"status":3}, "fx": {"coin":14,"downed":1,"explode":8,"revive":1,"splash":6,"summon":11,"yanyouFlame":3,"yanyouSummon":2}, - "snaps": "a8dd7569 06196293 7fe10e91 ba972d4d 6d08cfcf fb599bc6 1b50192f 15529175" + "snaps": "663ec4b2 fcbf72b6 e7a436e9 b069f338 26ddf9b5 aebe36fd afb4e8de 79b62d42" } } } diff --git a/test/golden/matches.json b/test/golden/matches.json index 9dd59b88..7610855b 100644 --- a/test/golden/matches.json +++ b/test/golden/matches.json @@ -680,37 +680,37 @@ { "round": 7, "ai_0": [1,25,10,0,0,4,8,"1ec7b320",17,"kazimierzShip 0/2, kjeragShip 0/2, siracusaShip 1/6, skillfulShip 1/6, soloShip 1/3, steadShip 2/2, swiftShip 0/8",26,28,2,49], - "ai_1": [1,26,11,0,1,4,8,"b417fbf1",19,"arcaneShip 1/8, kazimierzShip 0/2, preciShip 1/5, sargonShip 0/8, skillfulShip 1/6, swiftShip 0/8",28,28,0,54] + "ai_1": [1,26,11,0,1,4,8,"b417fbf1",20,"arcaneShip 1/8, kazimierzShip 0/2, preciShip 1/6, sargonShip 0/8, skillfulShip 1/6, swiftShip 0/8",28,28,0,54] }, { "round": 8, "ai_0": [1,25,11,0,0,4,8,"24603f24",20,"kazimierzShip 0/2, kjeragShip 0/2, siracusaShip 1/6, skillfulShip 1/6, soloShip 1/3, steadShip 2/5, swiftShip 0/8",5,9,4,60], - "ai_1": [1,26,12,0,0,4,8,"33304c67",63,"arcaneShip 1/8, kazimierzShip 0/2, preciShip 1/19, sargonShip 1/25, skillfulShip 1/11, soloShip 1/0, swiftShip 0/8",9,9,0,65] + "ai_1": [1,26,12,0,0,4,8,"33304c67",64,"arcaneShip 1/8, kazimierzShip 0/2, preciShip 1/20, sargonShip 1/25, skillfulShip 1/11, soloShip 1/0, swiftShip 0/8",9,9,0,65] }, { "round": 9, "ai_0": [1,19,12,0,4,4,8,"aea06001",50,"deputShip 1/2, kazimierzShip 0/2, kjeragShip 0/2, siracusaShip 1/22, skillfulShip 1/8, soloShip 1/5, steadShip 1/11, suntShip 1/2, swiftShip 0/8",14,23,9,72], - "ai_1": [1,26,12,0,2,4,8,"1bc5731a",105,"arcaneShip 2/14, kazimierzShip 0/2, preciShip 1/33, sargonShip 1/42, skillfulShip 1/16, suntShip 1/0, swiftShip 0/8",23,23,0,77] + "ai_1": [1,26,12,0,2,4,8,"1bc5731a",106,"arcaneShip 2/14, kazimierzShip 0/2, preciShip 1/34, sargonShip 1/42, skillfulShip 1/16, suntShip 1/0, swiftShip 0/8",23,23,0,77] }, { "round": 10, "ai_0": [1,19,16,0,0,5,8,"9b53670a",75,"deputShip 1/4, kazimierzShip 0/2, kjeragShip 0/2, siracusaShip 1/34, skillfulShip 1/10, soloShip 1/7, steadShip 1/16, suntShip 1/4, swiftShip 0/8",5,8,3,88], - "ai_1": [1,26,14,0,4,5,8,"aa0f2565",129,"arcaneShip 2/16, kazimierzShip 0/2, preciShip 1/43, sargonShip 1/52, skillfulShip 1/18, suntShip 1/0, swiftShip 0/8",8,8,0,91] + "ai_1": [1,26,14,0,4,5,8,"aa0f2565",132,"arcaneShip 2/16, kazimierzShip 0/2, preciShip 1/45, sargonShip 1/53, skillfulShip 1/18, suntShip 1/0, swiftShip 0/8",8,8,0,91] }, { "round": 11, "ai_0": [1,9,12,0,1,5,8,"9a1f89d4",88,"deputShip 1/6, emptyShip 1/2, kazimierzShip 0/2, kjeragShip 0/2, raidShip 1/3, siracusaShip 1/50, skillfulShip 1/12, soloShip 1/9, steadShip 0/16, suntShip 1/6, swiftShip 0/8",13,27,14,100], - "ai_1": [1,16,16,0,2,5,8,"b7d6fa51",183,"arcaneShip 2/26, kazimierzShip 0/2, preciShip 1/64, sargonShip 1/73, skillfulShip 1/20, suntShip 1/0, swiftShip 0/8",13,27,14,108] + "ai_1": [1,16,16,0,2,5,8,"b7d6fa51",186,"arcaneShip 2/26, kazimierzShip 0/2, preciShip 1/66, sargonShip 1/74, skillfulShip 1/20, suntShip 1/0, swiftShip 0/8",13,27,14,108] }, { "round": 12, "ai_0": [0,0,13,0,0,6,0,"811c9dc5",0,"deputShip 0/6, emptyShip 0/2, kazimierzShip 0/2, kjeragShip 0/2, raidShip 0/3, siracusaShip 0/52, skillfulShip 0/12, soloShip 0/9, steadShip 0/16, suntShip 0/6, swiftShip 0/8",24,50,26,113], - "ai_1": [1,6,14,0,2,6,8,"41045310",233,"arcaneShip 2/26, kazimierzShip 0/2, preciShip 1/88, sargonShip 1/97, skillfulShip 1/22, suntShip 1/0, swiftShip 0/8",36,50,14,121] + "ai_1": [1,6,14,0,2,6,8,"41045310",236,"arcaneShip 2/26, kazimierzShip 0/2, preciShip 1/90, sargonShip 1/98, skillfulShip 1/22, suntShip 1/0, swiftShip 0/8",36,50,14,121] }, { "round": 13, "ai_0": [0,0,0,0,0,6,0,"811c9dc5",0,"deputShip 0/6, emptyShip 0/2, kazimierzShip 0/2, kjeragShip 0/2, raidShip 0/3, siracusaShip 0/52, skillfulShip 0/12, soloShip 0/9, steadShip 0/16, suntShip 0/6, swiftShip 0/8",null,null,null,113], - "ai_1": [0,0,14,0,0,6,0,"811c9dc5",0,"arcaneShip 0/30, kazimierzShip 0/2, preciShip 0/93, sargonShip 0/102, skillfulShip 0/24, swiftShip 0/8",40,49,9,135] + "ai_1": [0,0,14,0,0,6,0,"811c9dc5",0,"arcaneShip 0/30, kazimierzShip 0/2, preciShip 0/98, sargonShip 0/106, skillfulShip 0/24, swiftShip 0/8",42,49,7,135] } ], "players": { @@ -738,9 +738,9 @@ "title": {"id":"comment_5","name":"万事俱备","picId":"comment_icon_5","text":"装备配发数量最多"}, "trophies": 3, "reward": 298, - "stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":3769501,"gold":135,"itemsEquipped":18,"kills":260,"leaks":38,"lpLost":29,"merges":3,"perfectRounds":9,"refreshes":16}, + "stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":3796247,"gold":135,"itemsEquipped":18,"kills":262,"leaks":36,"lpLost":27,"merges":3,"perfectRounds":9,"refreshes":16}, "lineup": [], - "bonds": ["arcaneShip 0/30","kazimierzShip 0/2","preciShip 0/93","sargonShip 0/102","skillfulShip 0/24","swiftShip 0/8"] + "bonds": ["arcaneShip 0/30","kazimierzShip 0/2","preciShip 0/98","sargonShip 0/106","skillfulShip 0/24","swiftShip 0/8"] } } }, From e733171b13f9bbc7a016770a051515240a7ca685 Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 20:14:56 +0800 Subject: [PATCH 7/8] =?UTF-8?q?feedback7=20R22-OSIM:=20=E9=A6=99=E6=A7=9F?= =?UTF-8?q?=E7=82=B8=E5=BC=B9=20no=20longer=20goes=20off=20on=20an=20unrev?= =?UTF-8?q?ealed=20=E9=9A=90=E5=8C=BF=20enemy=20(nor=20a=20sleeping=20one)?= =?UTF-8?q?=20=E2=80=94=20it=20selects=20like=20the=20other=20contact=20tr?= =?UTF-8?q?aps;=20golden:=20bond-yanShip-high,=20coop2-HARD-4=20move?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Community report relayed by the owner: 「有人反馈香槟炸到没有破隐的隐匿敌人了」. Reproduced: a 隐匿 walker crossing 琳琅诗怀雅's bombs set them off (both kits checked hidden / flying / untargetable only). The rule: PRTS 作战机制 §隐匿 "隐匿效果使得获得该效果的单位无法被任何敌方的能力索敌选中…要选中隐匿的目标,则该单位自身需拥有无视隐匿的效果"; 异常效果 lists 隐匿 and 无敌 among the 无法选择 effects (沉睡 = "无敌且无法行动", gamedata_const ba.sleep). Neither the 香槟炸弹 page nor 琳琅诗怀雅's S2 carries a 无视隐匿 / 无视可选性 note, and the other contact traps of the sim (多萝西's 共振装置, 望's 棋子) already select through canTargetEnemy. Now both kits go off on a ground enemy canTargetEnemy accepts (tokens.js CHAMPAGNE_TRIGGER): a 隐匿 enemy sets one off once it is revealed (反隐) or blocked (its 隐匿 lifts), a sleeping one once it wakes (until now the bomb was spent on it for no damage). Placement is unchanged (it already aimed at selectable enemies only). Golden: bond-yanShip-high and coop2-HARD-4 (bombs under 隐匿 enemies of rounds 10–11 — 隐形弩手组长, 访问团强攻冠军, 重弩突袭者 — and sleeping ones of round 7 — 宿主士兵, 粉碎攻坚手 — now wait). Tests: test/content/feedback7-champagne-stealth.test.js (2; both fail on the parent). --- docs/PLAYING.md | 2 +- .../kits/ops/chess_char_3_04-swire2.js | 6 +- server/sim/content/tokens.js | 16 +++++- .../feedback7-champagne-stealth.test.js | 56 +++++++++++++++++++ test/golden/bonds.json | 45 +++++++-------- test/golden/matches.json | 2 +- 6 files changed, 94 insertions(+), 33 deletions(-) create mode 100644 test/content/feedback7-champagne-stealth.test.js diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 6ae13276..30b6f0e1 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -73,7 +73,7 @@ - 拖动时模型一直在指针 / 手指下(脚在指针下方约半格,鼠标和触屏一样),**指针所在的格子**就是落点并高亮;装备拖到哪个格子,就配发给那个格子上的干员;在商店栏上松手则放回原位。 - 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息:头像旁是盟约,下面第一项就是干员的**特质**(如「休整期结束时 …」),之后是特性、属性、攻击范围、技能、模组、装备、天赋。属性是**实时数值**:作战中每秒刷新 4 次(当前生命、攻击、防御、法抗、攻击间隔、阻挡),休整期显示棋盘上的干员**开战时**的数值(已算入装备、盟约层数、特质、策略与机变效果);高于基础值为绿色、低于为红色并标出差值(攻击间隔变短算增益),鼠标悬停可看基础值。基础值是官方数据在该阶级的等级、技能等级(和精锐的模组)下、**满潜能**、0 信赖的数值:所有干员(精锐、补位、自选也一样)都带上全部潜能加成——部署费用、再部署时间、攻击力 / 生命 / 防御 / 法抗 / 攻速,以及「天赋效果增强」,召唤物的天赋也按召唤者的满潜能(维护者 2026-10-07 的决定,GitHub #252:官方赛事视频里的刺玫正是满潜能的攻击 435、费用 15)。 - **召唤物手动摆放**:把带可部署召唤物的干员放上棋盘后,它的召唤物会进入整备区(赫默的医疗探机、巫恋的诅咒娃娃、凯瑟琳的 2 个支援装置、浊心斯卡蒂的海嗣、伺夜的狼群、缪尔赛思的流形;同一干员的召唤物叠在一格,不占部署位),像干员一样拖到棋盘上并选方向。凯瑟琳的支援装置给**它面朝的那一格**上的干员加屏障,所以要放在干员旁边、朝向干员。**战术家的援军**(伺夜的狼群、缪尔赛思的流形,也就是选战术点)和官方一样**只能放在召唤者的攻击范围内**:拖动时只有这些格子会亮起;之后在原地给战术家换朝向时,不在新攻击范围里的援军会退回整备区(还在范围里的保持不动;整备区和临时整备区都满、援军无处可退时,这次换朝向不生效并提示「整备区已满」)。撤退或出售干员时它的召唤物一起移除;**移动干员到别的格子时(包括把召唤物拖到干员身上交换位置),它已摆放的召唤物会退回整备区**,需要重新摆放。整备区满时退回的召唤物进入临时整备区,休整期结束还没处理的会在下一回合重新回到整备区。 -- 作战中:海嗣、狼群、流形、支援装置开战时就在摆放的位置部署;医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次(持续 10 / 15 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、海嗣(以及自选干员的工程蓄水炮、沙地兽、打字机、本能的召唤)这类不会受到攻击、到时间就离场的召唤物不受任何伤害(活性源石也一样)、也不会被治疗,它们的血条就是倒计时:随时间减少,空了就离场。琳琅诗怀雅二技能的香槟炸弹也不会掉血(活性源石和各种伤害都不会)、不会被治疗,不会因为生命值而消失,只在「见面礼」炸到碰到它的第一个地面敌人后消失(维护者 2026-10-08 的决定)。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。狼群最后一只狼影被击倒后进入战术点形态:暂时离开战斗(不阻挡、不攻击、不会被攻击,格子仍然占着),经过狼影的恢复时间(25 秒)后在原地满血、带着 1 只狼影回来,之后重新开始每 25 秒加 1 只狼影;伺夜退场时狼群随之撤退,伺夜再部署回场时狼群重新带着 2 只狼影出现。 +- 作战中:海嗣、狼群、流形、支援装置开战时就在摆放的位置部署;医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次(持续 10 / 15 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、海嗣(以及自选干员的工程蓄水炮、沙地兽、打字机、本能的召唤)这类不会受到攻击、到时间就离场的召唤物不受任何伤害(活性源石也一样)、也不会被治疗,它们的血条就是倒计时:随时间减少,空了就离场。琳琅诗怀雅二技能的香槟炸弹也不会掉血(活性源石和各种伤害都不会)、不会被治疗,不会因为生命值而消失,只在「见面礼」炸到碰到它的第一个地面敌人后消失(维护者 2026-10-08 的决定);隐匿(没有被反隐、也没有被阻挡)或沉睡的敌人选不中,不会触发它。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。狼群最后一只狼影被击倒后进入战术点形态:暂时离开战斗(不阻挡、不攻击、不会被攻击,格子仍然占着),经过狼影的恢复时间(25 秒)后在原地满血、带着 1 只狼影回来,之后重新开始每 25 秒加 1 只狼影;伺夜退场时狼群随之撤退,伺夜再部署回场时狼群重新带着 2 只狼影出现。 - **阻挡**看接触:干员阻挡中心距离约 0.7 格以内、与它接触的敌人(地面干员挡不住飞行和近地悬浮的敌人)。敌人卡在两名干员之间时,阻挡它的干员倒下、撤退或被晕眩后,另一名仍与它接触、还有空余阻挡数的干员会**立刻接替阻挡**;都没有空位时敌人才会走掉。被冻结或晕眩的敌人同样会被接触到的干员挡住:干员再部署到它身边就会阻挡它,隐匿的敌人也随之破隐(沉睡、浮空、恐惧的敌人仍然挡不住)。迎面走来的敌人会停在干员格子前面。**阻挡了就一定能打到**:任何干员(包括站在近战位的远程干员和召唤物)总能攻击自己阻挡的敌人,不管它在不在攻击范围内、在干员哪一侧,并且优先攻击它(医疗干员除外:咒愈师以外的医疗挡住敌人时照常治疗队友,不会攻击它;「停止攻击」类技能期间也除外;技能原文另有限制的也按原文:普罗旺斯二技能开启时不以生命值高于 80% 的敌人为目标,寒檀二技能的冰凌随机落在攻击范围内的地块上)。 - 开战时干员按**逐列从左到右、每列从上到下**的顺序部署(最左一列最先,与阿戈尔吞噬「更靠左和靠上」的顺序相同),召唤物在所有干员之后。被阻挡的敌人先打阻挡它的干员;没被阻挡的远程敌人先打嘲讽等级高的,同级时打最后部署的(召唤物、最右一列靠下的干员和刚再部署的干员更容易挨打)。隐匿的干员只会被它阻挡的敌人攻击,没被它挡住的敌人的溅射、爆炸、范围技能、留下的伤害区域(包括假想敌:蚀裂被击倒后喷出的毒雾)以及深池伙友卫队、扎罗的减攻速光环也落不到它身上(和官方一样;写明「无视无法选择」的效果如污染秽蚀照常能打到,寒霜的减攻速光环和“墓碑”这类地图效果照常生效);**迷彩**(如忍冬三技能期间击倒敌人后、寒芒克洛丝一技能)同样只会被它阻挡的敌人攻击,也显示为半透明,但躲不开溅射、爆炸和范围伤害(同时打范围内所有单位的普通攻击——如“斩胄之剑”“破胄之锤”“灵幛”——打不到它),也不算隐匿(不触发叙拉古等「隐匿时」效果)。叙拉古的隐匿按时(32 秒 + 每层 0.4 秒)结束;忍冬的迷彩按官方持续到她下一次开技能。**蒂比起飞**期间只挡飞行敌人,地面敌人不会选她为目标(普通攻击、溅射、技能范围、燃烧区域和要选中目标的减益都落不到她身上);飞行和悬浮敌人、写明「无视无法选择」的效果(如污染秽蚀)照常能打到她;地面敌人的光环和全场效果(如“墓碑”让全场攻击力、防御力减半)、她攻击敌人时招来的反制(如重装侦察兵的暴露)照常生效,起飞前已挂上的持续伤害也照常跳。 - **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**开启后攻击范围比平时大**的手动技能(如莫斯提马「序时之匙」、银灰「真银斩」、史尔特尔「黄昏」、白面鸮「脑啡肽」)在**开启后的攻击范围内出现敌人**(治疗技能:有受伤的队友)时就释放,不必等敌人走进平时的攻击范围,这是本作按玩家反馈有意与官方不同的地方;**重装干员**的手动技能在受到伤害时释放(例外:深巡、雷蛇的二技能,号角的二、三技能,灰毫的一、二技能按玩家反馈改为攻击范围内有敌人时就释放(深巡二技能按开启后更远的攻击范围判断),余的二技能「厚礼上宾」改为周围两格(技能范围)内有敌人时就释放,这是本作有意与官方不同的地方;深巡一技能仍要受到伤害);执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放(开启后攻击范围扩大的技能——薄绿「风语」、蜜蜡「沙暴扩散」、卡涅利安「食噬之印」、玛恩纳「未宽解的悲哀」,以及安洁莉娜「秘杖·反重力模式」——同样按开启后的攻击范围判断)。**自动触发**的技能按其本身规则:只作用于队友的(如引星棘刺「度算浪波」、浊心斯卡蒂「同葬无光之愿」、纯烬艾雅法拉「无声润物」)技力满就释放,不用等敌人或伤员;伺夜「领袖的呼唤」「领袖的馈赠」只在场上有狼群时释放:狼群处于战术点形态时,「领袖的呼唤」也会释放并让它立刻带着 1 只狼影回到场上(狼影未满时改为加 1 只狼影,已满时把狼群的生命值回满),「领袖的馈赠」也会释放(立即获得费用,强化留给回来的狼群的下一次攻击);又如古米「备用军粮」在身边(技能范围内)有受伤的队友时就会发动,下一次攻击改为治疗该队友(不用先接敌;她的二技能「食粮烹制」是手动技能,按重装规则在受到伤害时释放);塞雷娅「急救」在她即将攻击时、身边有生命不高于一半的队友才发动,这次攻击直接改为治疗;新约能天使二技能的护盾按官方给攻击范围内**仇恨最高**的队友(嘲讽等级高的优先,同级时给最后部署的),不一定是站在最前面挨打的那个。手动技能的自动释放有 3 秒冷却:开战部署后先等 3 秒,之后同一干员两次自动释放至少间隔 3 秒(可充能的技能不会同时放完)。白铁的「铁钳号·原型机」能被我方干员攻击(不受伤害):它在攻击范围 / 技能范围内时,非治疗技能会像遇到敌人一样自动开启并消耗弹药(项目维护者 2026-10-08 按官方体验确认;白铁™多功能平台不会被攻击)。 diff --git a/server/sim/content/kits/ops/chess_char_3_04-swire2.js b/server/sim/content/kits/ops/chess_char_3_04-swire2.js index fdd47117..ed925f12 100644 --- a/server/sim/content/kits/ops/chess_char_3_04-swire2.js +++ b/server/sim/content/kits/ops/chess_char_3_04-swire2.js @@ -8,7 +8,8 @@ import { groundTile, enemiesOn, } from '../shared/tier3.js'; import { powi } from '../../../detmath.js'; -import { champagneHold } from '../../tokens.js'; +import { champagneHold, CHAMPAGNE_TRIGGER } from '../../tokens.js'; +import { canTargetEnemy } from '../../../targeting.js'; /** 琳琅诗怀雅 S3's coin range (PRTS 备注 "前方范围2-4"; range_table "2-4", facing right). */ const SWIRE2_COIN_GRID = Object.freeze([[1, 1], [0, 0], [0, 1], [0, 2], [-1, 1]]); @@ -78,7 +79,8 @@ export default { battle.on('tick', () => { if (!bomb.alive || !bomb.deployed) return; for (const e of battle.enemies) { - if (!e.alive || e.hidden || e.isFlying || e.s.flags.untargetable) continue; + // a ground enemy it may select — no 隐匿 one that is neither revealed nor blocked (tokens.js CHAMPAGNE_TRIGGER) + if (!canTargetEnemy(bomb, e, CHAMPAGNE_TRIGGER)) continue; if (!bodyOnTile(e, bomb.tileR, bomb.tileC)) continue; // first enemy touching it; after switchT s on the field the bomb deals its damage one extra time const hits = battle.time - bomb.deployedAt >= switchT - 1e-9 ? 2 : 1; diff --git a/server/sim/content/tokens.js b/server/sim/content/tokens.js index 408d120e..ab00d9f3 100644 --- a/server/sim/content/tokens.js +++ b/server/sim/content/tokens.js @@ -34,7 +34,8 @@ // 香槟炸弹 untargetable trap: the first ground enemy touching its tile takes owner ATK × atk_scale phys (+1 hit // after duration_switch s on the field) and sluggish; the bomb is used up ('expired', never a knock-out; // its blast fx carries `consumed: true` so clients play the explosion, not a death sound); 禁疗, no HP -// loss, never gone because of its HP (champagneHold — the owner's decision D2 of 2026-10-08) +// loss, never gone because of its HP (champagneHold — the owner's decision D2 of 2026-10-08); an +// unrevealed 隐匿 enemy does not set it off (CHAMPAGNE_TRIGGER) // 从不混淆的方向 untargetable marker; when the owner's skill ends it vanishes and the owner returns to its tile // 黄金盟誓 attacks deal true damage (trait); lasts while the owner's skill runs; 维娜 S3 places one on every // free deployable tile around her (kits/ops/chess_char_6_07-siege2.js) — no per-owner deploy limit (SKILL_SUMMON_UNCAPPED) @@ -80,7 +81,8 @@ // // Exports for other content: spawnYanyou, spawnMapChar, findSummonTile, summonToken, tacticalPoint, wolfShadows, // COUNTDOWN_SUMMONS / startCountdown (the countdown summons' 无敌 + 禁疗 and timer bar, shared with the kits that time them), -// champagneHold (香槟炸弹's durability, shared with 琳琅诗怀雅's kit, kits/ops/chess_char_3_04-swire2.js), +// champagneHold / CHAMPAGNE_TRIGGER (香槟炸弹's durability and the enemies it goes off on, shared with 琳琅诗怀雅's kit, +// kits/ops/chess_char_3_04-swire2.js), // wolfShadowInterval, installWolfTacticalPoint, wolfTacticalPoint, wolfReturnNow (the 狼群's 战术点形态, shared with the // 伺夜 kit's own pack, kits/ops/chess_char_3_19-vigil.js), releaseSkillSummon, SKILL_SUMMON_START_DEPLOY, CAT_SHIELD_KEY, // TOKEN_IDS; mapCharTalents and touchGospel (Touch's 攫升 / 超脱 and 恳切福音, shared with the Touch 补位 stand-in kit, @@ -1047,6 +1049,14 @@ export function champagneHold(battle, bomb) { battle.on('damaged', (ctx) => { if (ctx.target === bomb && bomb.alive) bomb.hp = bomb.s.maxHp; }, { owner: bomb, priority: 1000 }); } +/** + * The enemies a 香槟炸弹 may set off on: ground and selectable (canTargetEnemy) — a 隐匿 enemy that is neither revealed nor + * blocked is none (PRTS 作战机制 §隐匿 "隐匿效果使得获得该效果的单位无法被任何敌方的能力索敌选中"; its pages carry no 无视隐匿 / + * 无视可选性 note; the other contact traps — 多萝西's 共振装置, 望's 棋子 — select so too). A community report the owner relayed + * (「香槟炸到没有破隐的隐匿敌人了」): until 0.2.1 it went off on any ground enemy, 隐匿 or not. + */ +export const CHAMPAGNE_TRIGGER = Object.freeze({ canHitFly: false, groundOnly: true }); + /** 香槟炸弹 (琳琅诗怀雅 S2): untargetable trap consumed by the first ground enemy touching its tile (champagneHold). */ function champagne(bb) { const scale = num(bb['attack@atk_scale'] ?? bb.atk_scale, 0); @@ -1059,7 +1069,7 @@ function champagne(bb) { if (!unit.alive) return; let hit = null; for (const e of battle.enemies) { - if (!e.alive || e.hidden || e.isFlying) continue; + if (!canTargetEnemy(unit, e, CHAMPAGNE_TRIGGER)) continue; // (no unrevealed 隐匿 enemy) // touching its tile: a huge enemy on any tile of its body (body.js) const on = e.hitArea ? bodyOnTile(e, unit.tileR, unit.tileC) : Math.abs(e.x - unit.tileC) <= 0.5 && Math.abs(e.y - unit.tileR) <= 0.5; if (on && (!hit || e.spawnSeq < hit.spawnSeq)) hit = e; diff --git a/test/content/feedback7-champagne-stealth.test.js b/test/content/feedback7-champagne-stealth.test.js new file mode 100644 index 00000000..1f6b0235 --- /dev/null +++ b/test/content/feedback7-champagne-stealth.test.js @@ -0,0 +1,56 @@ +// test/content/feedback7-champagne-stealth.test.js — 琳琅诗怀雅's 香槟炸弹 does not go off on a 隐匿 enemy that is neither +// revealed nor blocked (community report: 「香槟炸到没有破隐的隐匿敌人了」). PRTS 作战机制 §隐匿: "隐匿效果使得获得该效果的单位无法被 +// 任何敌方的能力索敌选中" — the bomb's pages carry no 无视隐匿 note, and the other contact traps (多萝西's 共振装置, 望's 棋子) +// select their enemies so too. A revealed (反隐) or blocked one, and every visible ground enemy, still set it off. +// Run: node --test test/content/feedback7-champagne-stealth.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; + +const TRAP = 'token_10031_swire2_gdtrap'; +const trapHits = (h, e) => h.hooksOf('damaged').filter((c) => c.target === e && c.dmg?.tags?.includes('trap')); + +test('her bombs: a 隐匿 walker crosses one unharmed; a visible walker after it sets it off', () => { + const h = makeBattle({ + timeLimit: 120, autoFinish: false, hooks: ['damaged', 'death'], captureNoisy: true, + defs: { enemies: { e_w: enemyRec({ key: 'e_w', hp: 1e6, speed: 1, def: 0, atk: 0 }) } }, + units: [{ uid: 1, chessId: 'chess_char_3_04_a', row: 9, col: 5 }], + }); + h.step(); + const sw = h.unit(1); + const bombAt = (r, c) => h.b.allyUnits.find((t) => t.alive && t.defId === TRAP && t.tileR === r && t.tileC === c); + for (let i = 0; i < 15 && !bombAt(9, 7); i++) { sw.mem.coins = 3; h.run(2); } + const bomb = bombAt(9, 7); + assert.ok(bomb, 'a bomb on (9,7), on the walkers\' road before her block'); + const hidden = h.spawn('e_w', { route: 0 }); + h.b.addBuff(hidden, { key: 'test:stealth', persist: true, flags: { stealth: true } }); + assert.ok(h.runUntil(() => hidden.x < 6.6, 20), 'it walked over (9,7)'); + assert.ok(bomb.alive, 'not set off'); + assert.equal(trapHits(h, hidden).length, 0); + const seen = h.spawn('e_w', { route: 0 }); + assert.ok(h.runUntil(() => !bomb.alive, 20), 'a visible walker sets it off'); + assert.ok(trapHits(h, seen).length > 0 && seen.findBuff('sluggish')); + checkInvariants(h.b); +}); + +test('a bomb (the token kit) under a standing 隐匿 enemy waits; revealed (反隐), it goes off', () => { + const h = makeBattle({ + timeLimit: 30, autoFinish: false, hooks: ['damaged'], captureNoisy: true, + defs: { enemies: { e_d: enemyRec({ key: 'e_d', hp: 1e6, speed: 0, def: 0, atk: 0 }) } }, + units: [{ uid: 1, chessId: 'chess_char_3_04_a', row: 12, col: 3 }], enemies: [{ key: 'e_d', pos: [9, 7] }], + }); + h.step(); + const e = h.b.enemies.find((x) => x.alive); + h.b.addBuff(e, { key: 'test:stealth', persist: true, flags: { stealth: true } }); + const bomb = h.b.spawnToken(h.unit(1), TRAP, 9, 7); + assert.ok(bomb); + h.run(2); + assert.ok(bomb.alive && !e.blockedBy, 'a 隐匿 enemy on its tile does not set it off'); + assert.equal(trapHits(h, e).length, 0); + h.b.addBuff(e, { key: 'test:reveal', persist: true, flags: { reveal: true } }); + h.step(2); + assert.ok(!bomb.alive, 'revealed: it goes off'); + assert.ok(trapHits(h, e).length > 0); + checkInvariants(h.b); +}); diff --git a/test/golden/bonds.json b/test/golden/bonds.json index 470b2013..17a8dafd 100644 --- a/test/golden/bonds.json +++ b/test/golden/bonds.json @@ -1784,48 +1784,41 @@ "round": 10, "seed": 3858016853, "waveId": "act1autochess_h03", - "time": 76.07, - "ticks": 2282, + "time": 83.5, + "ticks": 2505, "reason": "cleared", "killed": 21, "total": 21, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 10, + "rngDraws": 3, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":21,"total":21,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":263171,"bossDmg":0,"heal":1471,"deaths":0,"layerGains":{}}}, + "players": {"p1":{"killed":21,"total":21,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":261981,"bossDmg":0,"heal":3087,"deaths":0,"layerGains":{}}}, "units": [ ["p1",1,"op","chess_char_4_17_b",1,"uniequip_002_hsguma",3513,1,8,0,0,0,0,0,0], - ["p1",2,"op","chess_char_6_03_b",2,"uniequip_002_yu",3700,1,6,0,0,0,0,0,0], + ["p1",2,"op","chess_char_6_03_b",2,"uniequip_002_yu",3700,1,6,0,0,0,972,0,1], ["p1",3,"op","chess_char_3_03_b",1,"uniequip_002_swire",1898,1,9,0,0,0,0,0,0], - ["p1",4,"op","chess_char_3_04_b",1,"uniequip_002_swire2",2323,1,11,8,64447,0,0,0,0], + ["p1",4,"op","chess_char_3_04_b",1,"uniequip_002_swire2",2323,1,11,8,63257,0,0,7,0], ["p1",5,"op","chess_char_1_03_b",1,"uniequip_002_leizi",1391,1,4,0,0,0,0,4,1], ["p1",6,"op","chess_char_2_04_b",1,"uniequip_002_grabds",1270,1,5,0,0,0,0,0,0], - ["p1",7,"op","chess_char_5_03_b",2,"uniequip_002_blaze2",632.96,1,12,3,51359,2310,1471,8,2], - ["p1",8,"op","chess_char_5_12_b",0,"uniequip_002_dusk",1787,1,7,4,68403,0,0,4,4], + ["p1",7,"op","chess_char_5_03_b",2,"uniequip_002_blaze2",1469.32,1,12,3,51359,3089,2115,9,2], + ["p1",8,"op","chess_char_5_12_b",0,"uniequip_002_dusk",1787,1,7,4,68403,0,0,5,5], ["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",1610,1,10,0,0,0,0,0,0], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null], - ["p1","~1","token","enemy_9012_acloon",null,null,17300.16,1,13,4,49667,389,0,2,2], - ["p1","~2","token","enemy_9012_acloon",null,null,13634.89,1,14,2,28865,4054,0,1,1], - ["p1","~1","token","token_10031_swire2_gdtrap",null,null,1000,0,15,0,0,0,0,0,null], - ["p1","~2","token","token_10031_swire2_gdtrap",null,null,1000,1,18,0,0,0,0,0,null], - ["p1","~3","token","token_10031_swire2_gdtrap",null,null,1000,0,27,0,0,0,0,0,null], - ["p1","~4","token","token_10031_swire2_gdtrap",null,null,1000,0,30,0,0,0,0,0,null], - ["p1","~5","token","token_10031_swire2_gdtrap",null,null,1000,0,33,0,0,0,0,0,null], - ["p1","~6","token","token_10031_swire2_gdtrap",null,null,1000,0,36,0,0,0,0,0,null], - ["p1","~1","token","token_10015_dusk_drgn",null,null,0,0,39,0,430,1997,0,1,null], - ["p1","~7","token","token_10031_swire2_gdtrap",null,null,1000,0,41,0,0,0,0,0,null], - ["p1","~8","token","token_10031_swire2_gdtrap",null,null,1000,0,42,0,0,0,0,0,null], - ["p1","~9","token","token_10031_swire2_gdtrap",null,null,1000,0,44,0,0,0,0,0,null], - ["p1","~10","token","token_10031_swire2_gdtrap",null,null,1000,1,46,0,0,0,0,0,null] + ["p1","~1","token","enemy_9012_acloon",null,null,17260.25,1,13,4,49667,429,0,2,2], + ["p1","~2","token","enemy_9012_acloon",null,null,12970.87,1,14,2,28865,4718,0,1,1], + ["p1","~1","token","token_10031_swire2_gdtrap",null,null,1000,1,15,0,0,0,0,0,null], + ["p1","~2","token","token_10031_swire2_gdtrap",null,null,1000,0,18,0,0,0,0,0,null], + ["p1","~1","token","token_10015_dusk_drgn",null,null,0,0,35,0,430,1997,0,1,null], + ["p1","~3","token","token_10031_swire2_gdtrap",null,null,1000,0,39,0,0,0,0,0,null] ], - "enemies": {"enemy_1019_jshoot_2":[7,7,0,1748,47029],"enemy_1381_winman":[9,9,0,0,129890],"enemy_1389_winbab_2":[5,5,0,5215,87091]}, - "hooks": [1,46,2282,94,94,188,151,933,22,30,10,8,6,4515,1,4,21,0,1,23,0,0,0], - "events": {"atk":94,"deploy":22,"die":30,"dmg":151,"engage":6,"fx":46,"skill":22,"spawn":46,"status":3}, - "fx": {"coin":14,"downed":1,"explode":8,"revive":1,"splash":6,"summon":11,"yanyouFlame":3,"yanyouSummon":2}, - "snaps": "663ec4b2 fcbf72b6 e7a436e9 b069f338 26ddf9b5 aebe36fd afb4e8de 79b62d42" + "enemies": {"enemy_1019_jshoot_2":[7,7,0,3530,47029],"enemy_1381_winman":[9,9,0,0,129890],"enemy_1389_winbab_2":[5,5,0,5490,87091]}, + "hooks": [1,39,2505,127,127,234,178,1651,22,24,12,12,6,5604,1,12,21,0,1,24,0,0,0], + "events": {"atk":127,"deploy":15,"die":24,"dmg":175,"engage":7,"fx":28,"skill":28,"spawn":39,"status":4}, + "fx": {"coin":7,"downed":2,"explode":2,"firewall":1,"revive":1,"splash":6,"summon":4,"yanyouFlame":3,"yanyouSummon":2}, + "snaps": "663ec4b2 fcbf72b6 b58a2d82 2eee0ab3 bd3eacca 24e2c6f1 97aa22e6 b2e6492a 0b5b586f" } } } diff --git a/test/golden/matches.json b/test/golden/matches.json index 7610855b..b2452dca 100644 --- a/test/golden/matches.json +++ b/test/golden/matches.json @@ -738,7 +738,7 @@ "title": {"id":"comment_5","name":"万事俱备","picId":"comment_icon_5","text":"装备配发数量最多"}, "trophies": 3, "reward": 298, - "stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":3796247,"gold":135,"itemsEquipped":18,"kills":262,"leaks":36,"lpLost":27,"merges":3,"perfectRounds":9,"refreshes":16}, + "stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":3795677,"gold":135,"itemsEquipped":18,"kills":262,"leaks":36,"lpLost":27,"merges":3,"perfectRounds":9,"refreshes":16}, "lineup": [], "bonds": ["arcaneShip 0/30","kazimierzShip 0/2","preciShip 0/98","sargonShip 0/106","skillfulShip 0/24","swiftShip 0/8"] } From 69d791f9c698f44b3834861e46965212405e74cc Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 20:52:58 +0800 Subject: [PATCH 8/8] =?UTF-8?q?feedback7=20R22-OSIM:=20a=20skill=20that=20?= =?UTF-8?q?=E7=99=BD=E9=93=81's=20=E9=93=81=E9=92=B3=E5=8F=B7=20opens=20no?= =?UTF-8?q?w=20acts=20on=20it=20=E2=80=94=20=E8=95=BE=E7=BC=AA=E5=AE=89=20?= =?UTF-8?q?S3=20/=20=E8=89=BE=E4=B8=BD=E5=A6=AE=20S3=20/=20=E9=99=88=20S2?= =?UTF-8?q?=20/=20=E5=BC=95=E6=98=9F=E6=A3=98=E5=88=BA=20S2=20spend=20on?= =?UTF-8?q?=20it,=20skills=20that=20pick=20enemies=20only=20stay=20closed;?= =?UTF-8?q?=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grok's review of the 铁钳号 change (HIGH): the device opened every non-heal skill, but kits that pick their victims with enemiesInKeys only were spent on nothing — 蕾缪安 S3 went active with 0 locks, 艾丽妮 S3 and 陈 S2 struck nothing, 引星棘刺 S2 threw at the empty far tile. Audit: every skill of every chess (loadouts) and of the 自选 pool, placed with only the device in range; of the 351 skills the device opened, 67 never touched it. - skills.js `allyTargetsOk`: the device counts for a skill's start only if the skill acts on it — SkillSpec `allyTargets` (true / false) from the kit, else [ASSUMED] a skill acting through the unit's own attacks (timed and still attacking, or a cast that is the next attack) on a unit that attacks. An onStart-only cast or a no-attack timed skill picks enemies itself and waits for one (德克萨斯 S2 剑雨, 锡人 S1/S2, 铃兰 S3 … about 50 skills). - Spend on the device (allyTargets: true; its kit cancels the hits, its 受击回复 counts): 蕾缪安 S3 locks it and spends her bullets (ammoUsed, so the per-bullet layers come) and shells it; 艾丽妮 S3 strikes it; 陈 S2 draws on it after every enemy; 引星棘刺 S2 throws onto it and its pulses hit it. queries.js allyTargetsInRadius for their areas. - Explicitly closed ([ASSUMED], allyTargets: false): 普罗旺斯 S2 (never shoots above 80 % HP), 卡涅利安 S1 (charged: no attack), 山 S2 (blocked enemies), 余 S2 / S3, 迷迭香 S3 (blocked only), 荒芜拉普兰德 S1 / S2 (drone locks), 淬羽赫默 S1–S3 and 遥 S1–S3 (heal attacks), 机械师 S3, 珊比 S2 / S3, 陈 S3 / 艾丽妮 S2 (their guards already refused). - 雪猎's empty-magazine cast back to enemies only (its S2 does not act on the device). Re-audit: 281 skills open on the device alone and all act on it (蕾缪安 S3 through her locks); none of those that acted on it before stopped. Docs: SIM.md, design/engine.md, PLAYING.md. Tests: test/content/feedback7-allytarget-kits.test.js (5; all fail on the parent). --- docs/PLAYING.md | 2 +- docs/SIM.md | 8 +- docs/design/engine.md | 2 +- server/sim/battle/queries.js | 16 ++- .../content/kits/ops/chess_char_1_07-prove.js | 4 +- .../kits/ops/chess_char_3_11-snhunt.js | 4 +- .../kits/ops/chess_char_4_24-billro.js | 2 + .../kits/ops/chess_char_5_15-thorn2.js | 7 +- .../kits/ops/chess_char_5_17-f12yin.js | 3 + .../kits/ops/chess_char_6_01-lemuen.js | 11 +- .../content/kits/ops/chess_char_6_03-yu.js | 3 + .../kits/ops/chess_char_6_12-rosmon.js | 2 + .../kits/ops/chess_char_6_18-whitw2.js | 3 + server/sim/content/kits/ops/op-chen.js | 7 ++ server/sim/content/kits/ops/op-haruka.js | 6 +- server/sim/content/kits/ops/op-irene.js | 13 ++- server/sim/content/kits/ops/op-mcnist.js | 2 + server/sim/content/kits/ops/op-slent2.js | 7 +- server/sim/content/kits/ops/op-thumpy.js | 3 + server/sim/skills.js | 29 ++++- .../content/feedback7-allytarget-kits.test.js | 106 ++++++++++++++++++ 21 files changed, 217 insertions(+), 23 deletions(-) create mode 100644 test/content/feedback7-allytarget-kits.test.js 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'); +});