0.2.3: integrate guardian healing and mixed skill triggers

This commit is contained in:
sganggs committed 2026-10-09 17:32:32 +08:00
commit e4e28117bb
9 files changed
+155 -50

No files matched your search

+7 -5
View File
@@ -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 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: `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' 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`, skills only; GitHub #260). A mixed attack/heal skill may set `trigger.enemies` to also open for a selectable enemy
`grid`) = the basic rule **and** such an ally on the grid: the cast replaces the attack about to be made (塞雷娅 S1 "触发 on that range (凯尔希·思衡托 S2), retaining the injured-ally condition; `DEFAULT` with `trigger.allies` (+ `hpAtMost`,
时会替换当次攻击", ≤ half HP); a cast whose ally condition fails before that attack is withdrawn, its charge returned; `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 `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 (深巡 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); 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) duration, // s (duration kind; optional cap for ammo)
ammo, // attacks (ammo kind) ammo, // attacks (ammo kind)
spCost, initSp, charges, spType: 'time'|'attack'|'hurt'|'none', // optional overrides of the data values 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) 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) 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) mods: { …mod keys }, // buff while active (instant: only during the pending attack)
flags: { …flags }, flags: { …flags },
targeting: { maxTargets, rangeGrid, rangeExtend, priority, allInRange, canHitFly, targeting: { maxTargets, rangeGrid, rangeExtend, priority, allInRange, canHitFly,
+4 -1
View File
@@ -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 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. 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*): **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 ```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 蕾缪安'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 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. 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).
+19
View File
@@ -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 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. 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.
+7
View File
@@ -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) // (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; } 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); 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; } if (!targets.length) { u.trait.hadTarget = false; storeEnergy(b, u, prof); return; }
u.trait.hadTarget = true; u.trait.hadTarget = true;
if (sk && sk.onAboutToAttack()) { if (sk && sk.onAboutToAttack()) {
@@ -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 // 塞雷娅 — 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. // 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. // 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. // 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 // 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 %. // 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 { return {
skills: lazySkills({ skills: lazySkills({
skchr_demkni_1: () => ({ skchr_demkni_1: () => ({
// 自动触发: its own rule, no 技能策略 — PRTS 备注 "此技能仅在周围有符合血量条件的友方单位时可触发,触发时会替换当次 // AUTO: PRTS limits this charged heal to a healable ally at ≤ half HP; it replaces that attack.
// 攻击" and the corrected text "血量小于等于一半": checked when she is about to attack (an enemy target, the basic // It uses her attack interval even without an enemy. If the patient recovers before the attack,
// rule), an ally of the skill area at ≤ half HP casts it and that attack becomes the heal (unlike 古米's 备用军粮, // the engine withdraws the cast and refunds its charge.
// no heal mode is left waiting: the engine withdraws a cast whose ally was healed before the attack)
kind: instantKind(chess, def), kind: instantKind(chess, def),
trigger: { rule: 'DEFAULT', grid, allies: true, hpAtMost: HALF_HP }, trigger: { rule: 'DEFAULT', grid, allies: true, hpAtMost: HALF_HP },
targeting: { rangeGrid: grid }, targeting: { rangeGrid: grid },
+2
View File
@@ -268,6 +268,8 @@ export default {
[S1]: { kind: 'duration', mods: { atkPct: num(b1.atk), aspd: num(b1.attack_speed) } }, [S1]: { kind: 'duration', mods: { atkPct: num(b1.atk), aspd: num(b1.attack_speed) } },
[S2]: { [S2]: {
kind: 'ammo', 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))), ammo: Math.max(1, Math.floor(num(b2['attack@trigger_time'], 10))),
mods: { atkPct: num(b2.atk) }, mods: { atkPct: num(b2.atk) },
targeting: s2?.rangeGrid ? { rangeGrid: s2.rangeGrid } : undefined, targeting: s2?.rangeGrid ? { rangeGrid: s2.rangeGrid } : undefined,
+6 -4
View File
@@ -101,8 +101,9 @@ export class SkillRuntime {
if (this.rule.startsWith('CUSTOM_RANGE')) this.rule = 'CUSTOM_RANGE'; if (this.rule.startsWith('CUSTOM_RANGE')) this.rule = 'CUSTOM_RANGE';
this.triggerGrid = trig.grid ?? trig.rangeGrid ?? d.trigger?.grid ?? null; 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: // 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.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.triggerHpAtMost = Number.isFinite(+trig.hpAtMost) && +trig.hpAtMost > 0 ? +trig.hpAtMost : 1;
this.healSkill = s.heal ?? (unit.profile && unit.profile.dmgType === 'heal' && !!unit.profile.heal); 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 // 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); if (b.rangeChanged(u)) b._refreshRange(u);
const keys = range || u.baseRangeKeys || u.rangeKeys; const keys = range || u.baseRangeKeys || u.rangeKeys;
if (keys) { 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; 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 卫戍协议/帮助 // 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.active && this.isTimed) return false;
if (this.rule === 'TAKE_DAMAGE' || this.rule === 'NEVER' || TICK_RULES.has(this.rule)) 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.pending || this._opCooling()) return false;
if (!this._defaultCondition()) return false; // Guardian charged heals supply their own attack target; no enemy is required (#406).
if (this.triggerAllies && !this._allyTriggerSatisfied()) return false; 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 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; } try { return this.activate('DEFAULT'); } finally { this._beforeAttack = false; }
} }
+71
View File
@@ -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, []);
});
}
+35 -35
View File
@@ -2394,38 +2394,38 @@
"round": 13, "round": 13,
"seed": 4104625554, "seed": 4104625554,
"waveId": "act1autochess_h06", "waveId": "act1autochess_h06",
"time": 213.53, "time": 224.2,
"ticks": 6406, "ticks": 6726,
"reason": "cleared", "reason": "cleared",
"killed": 54, "killed": 60,
"total": 96, "total": 96,
"unspawned": 0, "unspawned": 0,
"bossHpLeft": null, "bossHpLeft": null,
"rngDraws": 0, "rngDraws": 0,
"errors": {"count":0,"kinds":[]}, "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": [ "units": [
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_shu",0,0,6,3,28220,22841,8556,68,3], ["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,3764,7244,15696,24,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,3,36219,18295,24201,93,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,8,29213,25714,7056,77,1], ["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,8,40747,35870,26790,94,1], ["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,13,9,55531,31153,19129,132,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,105,4,26549,22529,10129,96,5], ["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,0,0,94,2,12140,19351,10447,60,1], ["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,3,15284,4893,22539,66,2], ["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",0,0,8,0,3357,5468,2925,12,1], ["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",0,0,10,4,20997,29429,30955,60,1], ["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,114,5,53610,24940,23541,116,2], ["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], ["-","~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], ["-","~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] ["-","~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]}, "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,114,6406,1483,1483,4018,4279,24024,68,110,21,21,0,15323,0,130,96,42,1,68,0,40,0], "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":1530,"deploy":15,"die":68,"dmg":4279,"engage":12,"fx":129,"heal":304,"leak":42,"skill":42,"spawn":111,"status":104}, "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":18,"burst":40,"healField":35,"teleport":36}, "fx": {"beam":23,"burst":44,"healField":36,"teleport":28},
"snaps": "caea0314 70d99341 a190ed01 c66b7d44 cc4aa097 1a3e11e7 b9309122 fcf78826 cd12b374 81495da7 877f63e5 87ffd853 2c5e098e 7fa84dec 5d10862d d52e4b5d b483e93a 33405189 e58dd069 11d14bc3 7d55f750 e98d98ef" "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": { "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", "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, "round": 13,
"seed": 953190138, "seed": 953190138,
"waveId": "act1autochess_h06", "waveId": "act1autochess_h06",
"time": 229, "time": 230,
"ticks": 6870, "ticks": 6900,
"reason": "cleared", "reason": "timeout",
"killed": 0, "killed": 97,
"total": 123, "total": 123,
"unspawned": 0, "unspawned": 0,
"bossHpLeft": null, "bossHpLeft": null,
"rngDraws": 0, "rngDraws": 0,
"errors": {"count":0,"kinds":[]}, "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": [ "units": [
["p1",1,"op","chess_char_5_diy1_a",0,null,1991.99,1,4,0,0,0,0,0,0], ["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",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",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",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",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",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",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], ["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], ["-","~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], ["-","~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] ["-","~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]}, "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,6870,0,0,0,0,0,0,123,0,0,0,8040,0,0,123,123,1,0,0,0,0], "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": {"deploy":12,"fx":72,"leak":123,"spawn":138}, "events": {"atk":150,"deploy":12,"die":97,"dmg":988,"engage":4,"fx":744,"leak":16,"skill":30,"spawn":138,"status":246},
"fx": {"shield":60,"takeoff":12}, "fx": {"aoe":149,"expose":359,"healAoe":149,"shield":75,"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" "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": { "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", "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",