Let charged guardian heals act without enemies and fix mixed S2 triggers (#406)

This commit is contained in:
sganggs committed 2026-10-09 17:31:42 +08:00
1 parent c9481c40bb
commit e86c7c8304
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
`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,
+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
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).
+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
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)
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()) {
@@ -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 },
+2
View File
@@ -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,
+6 -4
View File
@@ -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; }
}
+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,
"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",