From e86c7c83043eb63174c26155ce63a118d8fa480e Mon Sep 17 00:00:00 2001 From: sganggs Date: Fri, 9 Oct 2026 17:31:42 +0800 Subject: [PATCH] Let charged guardian heals act without enemies and fix mixed S2 triggers (#406) --- docs/SIM.md | 12 ++-- docs/design/engine.md | 5 +- docs/history/0.2.3.md | 19 +++++ server/sim/ai.js | 7 ++ .../kits/ops/chess_char_5_11-demkni.js | 9 ++- server/sim/content/kits/ops/op-kalts2.js | 2 + server/sim/skills.js | 10 +-- test/content/heal-triggers-023.test.js | 71 +++++++++++++++++++ test/golden/diy.json | 70 +++++++++--------- 9 files changed, 155 insertions(+), 50 deletions(-) create mode 100644 test/content/heal-triggers-023.test.js diff --git a/docs/SIM.md b/docs/SIM.md index 1a3a2ddc..83e9bb23 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -966,9 +966,11 @@ flag alters movement or 失衡. on `trigger.customRangeGrid` (= that running range, grown by the unit's permanent rangeExtend unless the skill's `targeting.noRangeExtend`), every tick, no attack needed (the 外勤医疗 map character Touch's 恳切福音 too, on its 5-2: set by its kit, content/tokens.js `touchKit` — the map character's record keeps DEFAULT, build-data widens operators' - skills only; GitHub #260); `DEFAULT` with `trigger.allies` (+ `hpAtMost`, - `grid`) = the basic rule **and** such an ally on the grid: the cast replaces the attack about to be made (塞雷娅 S1 "触发 - 时会替换当次攻击", ≤ half HP); a cast whose ally condition fails before that attack is withdrawn, its charge returned; + skills only; GitHub #260). A mixed attack/heal skill may set `trigger.enemies` to also open for a selectable enemy + on that range (凯尔希·思衡托 S2), retaining the injured-ally condition; `DEFAULT` with `trigger.allies` (+ `hpAtMost`, + `grid`) = an attack interval ready **and** such an ally on the grid, without requiring an enemy: + the cast replaces that attack with a heal (塞雷娅 / 黍 S1, ≤ half HP). The check follows cooldown decrement + and control/disarm checks, preserving the same cadence with or without enemies; a cast whose ally condition fails before that attack is withdrawn, its charge returned; `TAKE_DAMAGE` — ready and just hit (重装: "不受技能范围影响,受到伤害时释放技能"; in the data every MANUAL 重装 skill but the six of DESIGN §21.29 — 深巡 / 雷蛇 S2, 号角 S2 / S3, 灰毫 S1 / S2 — which are `DEFAULT`, a deliberate deviation (深巡 S2 then `ACTIVE_RANGE` on its 3-2), and 余 S2 厚礼上宾, `SKILL_RANGE` on its own x-1 since DESIGN §22.10); @@ -1096,8 +1098,8 @@ Element conventions of the kits (user playtest #5 #3; official term dictionary: duration, // s (duration kind; optional cap for ammo) ammo, // attacks (ammo kind) spCost, initSp, charges, spType: 'time'|'attack'|'hurt'|'none', // optional overrides of the data values - trigger: 'DEFAULT' | { rule, grid, allies?, hpAtMost? }, // optional override (allies / hpAtMost: an injured ally condition, §7.1) - heal: bool, // heal-type skill for the DEFAULT trigger (default: unit is a healer) + trigger: 'DEFAULT' | { rule, grid, allies?, hpAtMost?, enemies? }, // optional override (allies / hpAtMost: an injured ally condition, §7.1) + heal: bool, // heal-type skill for the DEFAULT trigger (default: unit is a healer); trigger.enemies also permits enemies mods: { …mod keys }, // buff while active (instant: only during the pending attack) flags: { …flags }, targeting: { maxTargets, rangeGrid, rangeExtend, priority, allInRange, canHitFly, diff --git a/docs/design/engine.md b/docs/design/engine.md index e9f01f9e..fe8bd0c9 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 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. +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`, attack cadence ready and a healable ally of the skill grid at or below that HP ratio suffice without an enemy — 塞雷娅 / 黍 S1 (§28.10), 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 @@ -186,3 +186,6 @@ Each domain file `server/sim/content/{tokens,bonds,garrisons,items,bands,enemies 蕾缪安's wanted tiles extend both attacks and the existing content skill-trigger range (§28.4). New detection requires selectability; an attached timer needs continuous detection range, and each on-field source owns its timers and marks. Leaving clears that source's state without clearing another still-deployed source's marks. + +凯尔希·思衡托 S2 uses its mixed attack/heal target condition for automatic activation: either an injured, healable ally +or a selectable enemy in its expanded running range; operation cooldown and other controls still apply (§28.10). diff --git a/docs/history/0.2.3.md b/docs/history/0.2.3.md index 43788807..a671ba0e 100644 --- a/docs/history/0.2.3.md +++ b/docs/history/0.2.3.md @@ -143,3 +143,22 @@ share the examples' display behavior; the drag template alone cannot establish t Tests cover real push/pull skill paths, default/override metadata, snapshot reversal, and an earlier attack arriving after slide prestart in either field direction. Golden metadata deliberately omits the display flag. +### 28.10 Charged guardian heals and mixed-skill activation — #406 + +Reported by @ChengCC0101. 塞雷娅 / 黍 S1 previously required an enemy to supply the attack that their charged heal +replaces. [PRTS 塞雷娅](https://prts.wiki/w/塞雷娅) and [黍](https://prts.wiki/w/黍) describe an eligible ally at +half HP or below, with the normal attack cadence. `updateAlly` now checks this alternate target after its cooldown +and control/disarm checks; an enemy is unnecessary. An invalid pending heal still returns its charge. This corrects +the enemy requirement inferred in historical §27.31; that earlier conclusion is superseded. + +[PRTS 凯尔希·思衡托](https://prts.wiki/w/凯尔希·思衡托) S2 can attack enemies and heal allies. Its automatic +activation now accepts either target kind on its expanded range, under the existing player-approved range strategy +and operation cooldown. S1/S3 keep their existing trigger rules. This addresses the healing/activation parts of #406; +the separate fatal-hit/silence report still needs an independently justified damage-order fix. + +Verification: 104 targeted tests pass. Independent interaction review found and verified fixes for a one-frame +cadence delay and premature charge use while disarmed; normal/100-ASPD intervals are exactly 1.2/0.6 s. +Sleep, silence, no-heal targets, charge refunds, cooperative healing and S2 target selectability remain covered. + +Golden: all 285 scenarios recomputed; `diy-059`, `diy-133` change because +charged heals now act without enemy targets and S2 can open for enemies. Other scenarios remain byte-identical. diff --git a/server/sim/ai.js b/server/sim/ai.js index d41030c5..139e0701 100644 --- a/server/sim/ai.js +++ b/server/sim/ai.js @@ -101,6 +101,13 @@ export function updateAlly(b, u, dt) { // (a 秘术师's `canAttack` is its own target rule: false = no valid target — 深靛's bound enemies, 维伊's marked ones) if (prof.canAttack && !prof.canAttack(b, u)) { if (prof.storeEnergy) { u.trait.hadTarget = false; storeEnergy(b, u, prof); } return; } let targets = acquireTargets(b, u, prof); + // A DEFAULT charged heal can replace this attack with an eligible ally even when no enemy exists. + // Run after cooldown/control checks so silence, disarm and the exact attack cadence still apply (#406). + if (!targets.length && sk?.triggerAllies && sk.onAboutToAttack()) { + prof = effectiveProfile(u); + if (prof.noAttack || !u.alive) return; + targets = acquireTargets(b, u, prof); + } if (!targets.length) { u.trait.hadTarget = false; storeEnergy(b, u, prof); return; } u.trait.hadTarget = true; if (sk && sk.onAboutToAttack()) { diff --git a/server/sim/content/kits/ops/chess_char_5_11-demkni.js b/server/sim/content/kits/ops/chess_char_5_11-demkni.js index 0cc5b803..4c4eac24 100644 --- a/server/sim/content/kits/ops/chess_char_5_11-demkni.js +++ b/server/sim/content/kits/ops/chess_char_5_11-demkni.js @@ -27,7 +27,7 @@ export default { // 塞雷娅 — S2 药物配置 (instant, time SP): heals every ally in the skill range for heal_scale × ATK (cast when one is // injured). T1 莱茵充能护服: every 20 s on the field ATK +5 % / DEF +4 % (×5). T2 精神回复: +1 SP to every ally she heals. // Module (elite): heals on allies below 50 % ×1.15. - // S1 急救 (instant / 2 charges elite, 自动触发: cast at her attack when an ally of the skill area 周围 is at ≤ half HP): + // S1 急救 (instant / 2 charges elite, 自动触发: cast at her attack interval when an ally of the skill area 周围 is at ≤ half HP): // that attack is instead a heal of the lowest such ally for heal_scale × ATK. // S3 钙质化 (duration; the 重装 strategy TAKE_DAMAGE): allies in the skill area heal // attack@heal_scale × ATK per second; enemies there take arts ×demkni_s_3.damage_scale and move −60 %. @@ -46,10 +46,9 @@ export default { return { skills: lazySkills({ skchr_demkni_1: () => ({ - // 自动触发: its own rule, no 技能策略 — PRTS 备注 "此技能仅在周围有符合血量条件的友方单位时可触发,触发时会替换当次 - // 攻击" and the corrected text "血量小于等于一半": checked when she is about to attack (an enemy target, the basic - // rule), an ally of the skill area at ≤ half HP casts it and that attack becomes the heal (unlike 古米's 备用军粮, - // no heal mode is left waiting: the engine withdraws a cast whose ally was healed before the attack) + // AUTO: PRTS limits this charged heal to a healable ally at ≤ half HP; it replaces that attack. + // It uses her attack interval even without an enemy. If the patient recovers before the attack, + // the engine withdraws the cast and refunds its charge. kind: instantKind(chess, def), trigger: { rule: 'DEFAULT', grid, allies: true, hpAtMost: HALF_HP }, targeting: { rangeGrid: grid }, diff --git a/server/sim/content/kits/ops/op-kalts2.js b/server/sim/content/kits/ops/op-kalts2.js index 2041934d..67fb6aec 100644 --- a/server/sim/content/kits/ops/op-kalts2.js +++ b/server/sim/content/kits/ops/op-kalts2.js @@ -268,6 +268,8 @@ export default { [S1]: { kind: 'duration', mods: { atkPct: num(b1.atk), aspd: num(b1.attack_speed) } }, [S2]: { kind: 'ammo', + // The larger active range contains attack targets as well as patients (#406). + trigger: { rule: 'ACTIVE_RANGE', grid: s2?.rangeGrid, enemies: true }, ammo: Math.max(1, Math.floor(num(b2['attack@trigger_time'], 10))), mods: { atkPct: num(b2.atk) }, targeting: s2?.rangeGrid ? { rangeGrid: s2.rangeGrid } : undefined, diff --git a/server/sim/skills.js b/server/sim/skills.js index 014d105c..34e45e22 100644 --- a/server/sim/skills.js +++ b/server/sim/skills.js @@ -101,8 +101,9 @@ export class SkillRuntime { if (this.rule.startsWith('CUSTOM_RANGE')) this.rule = 'CUSTOM_RANGE'; this.triggerGrid = trig.grid ?? trig.rangeGrid ?? d.trigger?.grid ?? null; // kit options: an injured, healable ally of the trigger grid with an HP ratio of at most `hpAtMost` (SKILL_RANGE: - // instead of an enemy; DEFAULT: in addition to the basic rule) + // instead of an enemy; DEFAULT: a heal target at the normal attack cadence) this.triggerAllies = !!trig.allies; + this.triggerEnemies = !!trig.enemies; // mixed attack/heal skills may also open for a selectable enemy this.triggerHpAtMost = Number.isFinite(+trig.hpAtMost) && +trig.hpAtMost > 0 ? +trig.hpAtMost : 1; this.healSkill = s.heal ?? (unit.profile && unit.profile.dmgType === 'heal' && !!unit.profile.heal); // the official skill strategies automate the manual 开启: only MANUAL skills wait for the operation cooldown @@ -483,7 +484,8 @@ export class SkillRuntime { if (b.rangeChanged(u)) b._refreshRange(u); const keys = range || u.baseRangeKeys || u.rangeKeys; if (keys) { - if (this.healSkill) return b.injuredAlliesInKeys(keys, u).length > 0; + if (this.healSkill) return b.injuredAlliesInKeys(keys, u).length > 0 + || (this.triggerEnemies && b.enemiesInKeys(keys, u, TRIGGER_PROFILE).length > 0); 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 卫戍协议/帮助 @@ -498,8 +500,8 @@ export class SkillRuntime { if (this.active && this.isTimed) return false; if (this.rule === 'TAKE_DAMAGE' || this.rule === 'NEVER' || TICK_RULES.has(this.rule)) return false; if (this.pending || this._opCooling()) return false; - if (!this._defaultCondition()) return false; - if (this.triggerAllies && !this._allyTriggerSatisfied()) return false; + // Guardian charged heals supply their own attack target; no enemy is required (#406). + if (this.triggerAllies ? !this._allyTriggerSatisfied() : !this._defaultCondition()) return false; this._beforeAttack = true; // the attack follows this cast in the same check (ai.js updateAlly): attacks pace it try { return this.activate('DEFAULT'); } finally { this._beforeAttack = false; } } diff --git a/test/content/heal-triggers-023.test.js b/test/content/heal-triggers-023.test.js new file mode 100644 index 00000000..cb9e42a2 --- /dev/null +++ b/test/content/heal-triggers-023.test.js @@ -0,0 +1,71 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; +const GUARDIANS = [ + { chessId: 'chess_char_5_11_b', skillIndex: 0 }, + { diy: { slot: 6, charId: 'char_2025_shu', skillIndex: 0 }, elite: true }, +]; +function guardian(entry) { + const h = makeBattle({ autoFinish: false, captureNoisy: true, hooks: ['heal', 'skillStart'], + defs: { chess: { patient: chessRec({ id: 'patient', skill: null, stats: { maxHp: 100000 } }) } }, + units: [{ uid: 1, ...entry, row: 10, col: 4 }, { uid: 2, chessId: 'patient', row: 10, col: 5 }] }); + h.step();const u = h.unit(1), p = h.unit(2);u.skill.gainSp(999);return { h, u, p }; +} +for (const entry of GUARDIANS) { + const name = entry.chessId ? '塞雷娅' : '黍'; + test(`${name} S1 heals without enemies, respects half HP and keeps attack cadence`, () => { + const { h, u, p } = guardian(entry); + p.hp = p.s.maxHp * 0.5001;h.run(0.5);assert.equal(u.skill.activations, 0); + p.hp = p.s.maxHp * 0.5;u.atkCd = 1; + h.run(0.5);assert.equal(u.skill.activations, 0, 'wait for the attack interval'); + assert.ok(h.runUntil(() => u.skill.activations > 0, 1)); + assert.ok(p.hp > p.s.maxHp * 0.5, 'exact half HP is eligible'); + p.hp = p.s.maxHp * 0.2; + assert.ok(h.runUntil(() => u.skill.activations >= 2, 4)); + const casts = h.hooksOf('skillStart').filter(c => c.unit === u); + assert.ok(casts[1].t - casts[0].t >= u.base.bat - 0.04, 'charges are paced by attacks'); + checkInvariants(h.b);assert.deepEqual(h.b.errors, []); + }); + test(`${name} S1 preserves exact normal/fast attack intervals and waits out disarm`, () => { + for (const aspd of [0, 100]) { + const { h, u, p } = guardian(entry); + p.hp *= 0.1; + if (aspd) h.b.addBuff(u, { key: 'test:aspd', mods: { aspd } }); + h.b.on('heal', c => { if (c.source === u) u.skill.gainSp(999); }); + const charges = u.skill.charges; + h.b.applyStatus(u, 'disarm', { duration: 1 }); + h.run(0.5); + assert.equal(u.skill.activations, 0); + assert.equal(u.skill.pending, false); + assert.equal(u.skill.charges, charges); + h.run(4); + const times = h.hooksOf('heal').filter(c => c.source === u).map(c => c.t); + assert.ok(times.length >= 3); + for (let i = 1; i < times.length; i++) assert.ok(Math.abs(times[i] - times[i - 1] - u.s.interval) < 1e-9); + checkInvariants(h.b);assert.deepEqual(h.b.errors, []); + } + }); + test(`${name} S1 cannot heal a forbidden patient or cast while silenced; invalid pending heal refunds the charge`, () => { + const { h, u, p } = guardian(entry);p.hp *= 0.2; + h.b.addBuff(p, { key: 'test:noheal', flags: { noHeal: true } });h.run(2);assert.equal(u.skill.activations, 0); + h.b.removeBuff(p, 'test:noheal');h.b.applyStatus(u, 'silence', { duration: 2 });h.run(1);assert.equal(u.skill.activations, 0); + h.run(1.1);assert.ok(u.skill.activations > 0); + u.skill.gainSp(999);const charges = u.skill.charges;u.atkCd = 1; + assert.ok(u.skill.activate('test'));p.hp = p.s.maxHp;h.step(); + assert.equal(u.skill.pending, false);assert.equal(u.skill.charges, charges); + checkInvariants(h.b);assert.deepEqual(h.b.errors, []); + }); +} +for (const motion of ['WALK', 'FLY']) { + test(`凯尔希·思衡托 S2 opens for a selectable ${motion} enemy only in the expanded range, without an injured ally`, () => { + const h = makeBattle({ autoFinish: false, + defs: { enemies: { target: enemyRec({ key: 'target', motion, hp: 1e8, atk: 0, speed: 0 }) } }, + units: [{ uid: 1, diy: { slot: 6, charId: 'char_1052_kalts2', skillIndex: 1 }, elite: true, row: 10, col: 4 }] }); + h.step();const u = h.unit(1);u.skill.gainSp(999);h.run(0.5);assert.equal(u.skill.activations, 0); + const e = h.spawn('target', { pos: [10, 7] });h.b.applyStatus(e, 'sleep', { duration: 1 }); + assert.equal(h.b.enemiesInKeys(u.baseRangeKeys, u, { canHitFly: true }).length, 0); + h.run(0.5);assert.equal(u.skill.activations, 0, 'sleep cannot open the mixed skill'); + assert.ok(h.runUntil(() => u.skill.active, 1)); + assert.equal(u.skill.activations, 1);checkInvariants(h.b);assert.deepEqual(h.b.errors, []); + }); +} diff --git a/test/golden/diy.json b/test/golden/diy.json index 0ccd73b4..1f6b3bf1 100644 --- a/test/golden/diy.json +++ b/test/golden/diy.json @@ -2394,38 +2394,38 @@ "round": 13, "seed": 4104625554, "waveId": "act1autochess_h06", - "time": 213.53, - "ticks": 6406, + "time": 224.2, + "ticks": 6726, "reason": "cleared", - "killed": 54, + "killed": 60, "total": 96, "unspawned": 0, "bossHpLeft": null, "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":54,"total":96,"perfect":false,"leaks":42,"leaksUncounted":0,"coins":0,"dmg":325631,"bossDmg":0,"heal":201962,"deaths":14,"layerGains":{}}}, + "players": {"p1":{"killed":60,"total":96,"perfect":false,"leaks":36,"leaksUncounted":0,"coins":0,"dmg":328165,"bossDmg":0,"heal":215296,"deaths":15,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_shu",0,0,6,3,28220,22841,8556,68,3], - ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_shu",0,0,4,0,3764,7244,15696,24,1], - ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_shu",0,0,7,3,36219,18295,24201,93,2], - ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_shu",0,0,9,8,29213,25714,7056,77,1], - ["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_shu",0,0,11,8,40747,35870,26790,94,1], - ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_shu",0,0,13,9,55531,31153,19129,132,1], - ["p1",7,"op","chess_char_6_diy1_a",0,null,0,0,105,4,26549,22529,10129,96,5], - ["p1",8,"op","chess_char_6_diy2_a",1,null,0,0,94,2,12140,19351,10447,60,1], - ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,5,3,15284,4893,22539,66,2], - ["p1",10,"op","chess_char_6_diy2_b",0,"none",0,0,8,0,3357,5468,2925,12,1], - ["p1",11,"op","chess_char_6_diy1_b",1,"none",0,0,10,4,20997,29429,30955,60,1], - ["p1",12,"op","chess_char_6_diy2_b",2,"none",3421,1,114,5,53610,24940,23541,116,2], + ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_shu",2027.59,1,116,4,28271,24195,6202,68,1], + ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_shu",0,0,4,0,1711,23126,33329,30,2], + ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_shu",0,0,7,12,35861,22460,27238,104,2], + ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_shu",0,0,9,3,39020,25122,12684,97,4], + ["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_shu",0,0,11,11,48461,41096,28378,112,1], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_shu",0,0,114,9,47371,22141,16394,112,1], + ["p1",7,"op","chess_char_6_diy1_a",0,null,0,0,103,5,24150,24095,7966,92,3], + ["p1",8,"op","chess_char_6_diy2_a",1,null,2764.3,1,117,1,10276,17246,9358,51,1], + ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,5,1,15673,5966,23558,68,2], + ["p1",10,"op","chess_char_6_diy2_b",0,"none",3421,1,119,1,4178,7509,4920,14,2], + ["p1",11,"op","chess_char_6_diy1_b",1,"none",3421,1,118,3,20756,28537,30597,59,1], + ["p1",12,"op","chess_char_6_diy2_b",2,"none",3421,1,115,6,52439,24767,14672,123,2], ["-","~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] ], - "enemies": {"enemy_1229_darmy":[27,17,10,37119,84479],"enemy_1229_darmy_2":[57,37,20,116481,226930],"enemy_9007_acelem":[12,0,12,75361,111113]}, - "hooks": [1,114,6406,1483,1483,4018,4279,24024,68,110,21,21,0,15323,0,130,96,42,1,68,0,40,0], - "events": {"atk":1530,"deploy":15,"die":68,"dmg":4279,"engage":12,"fx":129,"heal":304,"leak":42,"skill":42,"spawn":111,"status":104}, - "fx": {"beam":18,"burst":40,"healField":35,"teleport":36}, - "snaps": "caea0314 70d99341 a190ed01 c66b7d44 cc4aa097 1a3e11e7 b9309122 fcf78826 cd12b374 81495da7 877f63e5 87ffd853 2c5e098e 7fa84dec 5d10862d d52e4b5d b483e93a 33405189 e58dd069 11d14bc3 7d55f750 e98d98ef" + "enemies": {"enemy_1229_darmy":[27,22,5,39408,94374],"enemy_1229_darmy_2":[57,38,19,128503,234099],"enemy_9007_acelem":[12,0,12,77871,113345]}, + "hooks": [1,119,6726,1553,1553,4410,4689,25170,75,111,22,21,0,17046,0,126,96,36,1,75,0,44,0], + "events": {"atk":1612,"deploy":20,"die":75,"dmg":4689,"engage":12,"fx":131,"heal":320,"leak":36,"skill":43,"spawn":111,"status":111}, + "fx": {"beam":23,"burst":44,"healField":36,"teleport":28}, + "snaps": "caea0314 cba33632 f90e1b1d 5e82f1ff 929abe9c 394a1be8 5e0e99e5 eab4fee5 adfb57e8 aad6dce8 05e8f51c 3738b6d0 721871ea b6845c30 4ce6f2df 47cfe53a 1562c368 1bcdcadc 929a1dfb a34d9a94 d6a40dfd b6073386 8f66b944" }, "diy-060": { "about": "6_diy1_b=shu/S1/002_shu 6_diy2_b=shu/S2/002_shu 6_diy1_b=shu/S3/002_shu 6_diy2_b=shu/S1/003_shu 6_diy1_b=shu/S2/003_shu 6_diy2_b=shu/S3/003_shu 5_diy1_a=pallas/S1 5_diy2_a=pallas/S2 5_diy1_a=pallas/S3 5_diy2_b=pallas/S1 5_diy1_b=pallas/S2 5_diy2_b=pallas/S3", @@ -5769,38 +5769,38 @@ "round": 13, "seed": 953190138, "waveId": "act1autochess_h06", - "time": 229, - "ticks": 6870, - "reason": "cleared", - "killed": 0, + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 97, "total": 123, "unspawned": 0, "bossHpLeft": null, "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":0,"total":123,"perfect":false,"leaks":123,"leaksUncounted":0,"coins":0,"dmg":0,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}}, + "players": {"p1":{"killed":97,"total":123,"perfect":false,"leaks":26,"leaksUncounted":0,"coins":0,"dmg":3464054,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}}, "units": [ ["p1",1,"op","chess_char_5_diy1_a",0,null,1991.99,1,4,0,0,0,0,0,0], - ["p1",2,"op","chess_char_5_diy2_a",1,null,1991.99,1,5,0,0,0,0,0,0], + ["p1",2,"op","chess_char_5_diy2_a",1,null,1991.99,1,5,10,602379,0,0,40,4], ["p1",3,"op","chess_char_5_diy1_a",2,null,1991.99,1,11,0,0,0,0,0,0], ["p1",4,"op","chess_char_5_diy2_b",0,"none",2206.49,1,13,0,0,0,0,0,0], - ["p1",5,"op","chess_char_5_diy1_b",1,"none",2206.49,1,6,0,0,0,0,0,0], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",2206.49,1,6,26,1100819,0,0,30,3], ["p1",6,"op","chess_char_5_diy2_b",2,"none",2206.49,1,14,0,0,0,0,0,0], ["p1",7,"op","chess_char_6_diy1_a",0,null,1991.99,1,7,0,0,0,0,0,0], - ["p1",8,"op","chess_char_6_diy2_a",1,null,1991.99,1,9,0,0,0,0,0,0], + ["p1",8,"op","chess_char_6_diy2_a",1,null,1991.99,1,9,16,531690,0,0,40,4], ["p1",9,"op","chess_char_6_diy1_a",2,null,1991.99,1,10,0,0,0,0,0,0], ["p1",10,"op","chess_char_6_diy2_b",0,"none",2206.49,1,12,0,0,0,0,0,0], - ["p1",11,"op","chess_char_6_diy1_b",1,"none",2206.49,1,15,0,0,0,0,0,0], + ["p1",11,"op","chess_char_6_diy1_b",1,"none",2206.49,1,15,45,1229167,0,0,40,4], ["p1",12,"op","chess_char_6_diy2_b",2,"none",2206.49,1,8,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] ], - "enemies": {"enemy_1006_shield_3":[21,0,21,0,0],"enemy_1043_zomsbr":[66,0,66,0,0],"enemy_1402_tgshd_2":[36,0,36,0,0]}, - "hooks": [1,138,6870,0,0,0,0,0,0,123,0,0,0,8040,0,0,123,123,1,0,0,0,0], - "events": {"deploy":12,"fx":72,"leak":123,"spawn":138}, - "fx": {"shield":60,"takeoff":12}, - "snaps": "73ed7f8a 7b620bb2 4d91bff8 2b838d89 4e28c0a4 4d3057b7 dd413269 d9e2dba6 2f326b81 83e4122d e47cd4da b766c561 ea452dcc 749d7006 6e97bed7 aeb7002d a0cf0a74 38ae1555 f0426635 2ddc66fa 83570095 8351e611 7abf7fb8" + "enemies": {"enemy_1006_shield_3":[21,5,11,0,1729904],"enemy_1043_zomsbr":[66,64,2,0,484262],"enemy_1402_tgshd_2":[36,28,3,0,1249889]}, + "hooks": [1,138,6900,150,150,988,988,6824,97,113,15,15,150,20923,891,0,123,16,1,97,0,0,0], + "events": {"atk":150,"deploy":12,"die":97,"dmg":988,"engage":4,"fx":744,"leak":16,"skill":30,"spawn":138,"status":246}, + "fx": {"aoe":149,"expose":359,"healAoe":149,"shield":75,"takeoff":12}, + "snaps": "73ed7f8a ad542748 3ff80098 ae6a8ec3 59598083 93567826 ed19865f e6485e06 aa8d6124 079e6a4e 392585c2 28570fb0 7bfd39c4 1fddcff9 9573a597 de3a6b22 d1d3361c 838f78dd e71a4502 a1225cf5 639d3bd5 6e01ec4b f97fa410" }, "diy-134": { "about": "5_diy1_a=monstr/S1 5_diy2_a=monstr/S2 5_diy1_a=monstr/S3 5_diy2_b=monstr/S1 5_diy1_b=monstr/S2 5_diy2_b=monstr/S3 5_diy1_b=monstr/S1/002_monstr 5_diy2_b=monstr/S2/002_monstr 5_diy1_b=monstr/S3/002_monstr 6_diy1_a=monstr/S1 6_diy2_a=monstr/S2 6_diy1_a=monstr/S3",