feedback5 K2: stand-in kits for Stormeye, Pith and Raidian — every skill, talent and module at every form

Stormeye (char_611_acnipe; 空弦, 莫斯提马, 百炼嘉维尔 / 妮芙, 蕾缪安 / 异客): 风坠 rolls on every attack instance
(acnipe_t_1: ATK × atk_scale before DEF; S3 talent@prob), 风雨欲来 +0.2 SP/s after `delay` s without an attack
[ASSUMED reading], S1 ATK / ASPD / 100 DEF ignored, S2 an endless toggle (ATK +5 %, 2 targets), S3 3 targets × double
hit plus one physical ATK × 1.0 instance on a target at >= 90 % HP on the attack's first hit (acnipe_s_3[extra], no
风坠 / MAR-X on it); MAR-X stays the fastshot profile's flyScale.

Pith (char_612_accast; 烛煌, 焰影苇草 / 迷迭香 / 荒芜拉普兰德): splash from the trait blackboard, 12 RES ignored, ATK
+10 / +20 % on herself and the 【术师】 of the 4 tiles next to her, S1 ASPD + splash 1.5, S2 ASPD + ATK on every 【术师】,
S3 one ATK × atk_scale_aoe arts hit on every enemy of her range at the cast, then 2 targets, ASPD, splash 1.2 (PRTS
备注); SPC-X range (moduleRangeUp).

Raidian (char_614_acsupo; 灵知, 夕 / 安洁莉娜, 浊心斯卡蒂 / 仇白): 同调 on herself and the operators next to her (×
talent_scale in S3), 诱引 per PRTS 命中率 — one roll per enemy attack in her range, a miss cancels the whole attack
before the enemy riders (hit priority 300), S1 / S2 from the blackboards (S2 flat −0.1 / −0.2 s, 停顿 1.1 / 1.2 s), S3
y-4 + ATK + 脆弱 / 虚弱 aura with the 虚弱 stacking as 90 / 80 % (stackAs); S3 casts by the data's ACTIVE_RANGE;
DEC-X +0.2 SP/s with an enemy in range.

Triggers stay the data's (AUTO attack buffs Stormeye S2 / Pith S1 keep DEFAULT, as the W5C AUTO audit). No golden
scenario fields a stand-in: digests unchanged. Tests: test/content/standin_acnipe / _accast / _acsupo (27).
This commit is contained in:
sganggs committed 2026-10-05 18:34:46 +08:00
1 parent eac9b9737d
commit f317adf003
7 files changed
+1094 -1

No files matched your search

+1 -1
View File
@@ -64,7 +64,7 @@ export const KIT_FILES = Object.freeze([
* A stand-in without a file fights with the generic kit (content/generic.js) plus its unconditional stat talents
* (genericTalents) — the eight 预备干员 need no file.
*/
export const STANDIN_KIT_FILES = Object.freeze([]);
export const STANDIN_KIT_FILES = Object.freeze(['standin-acnipe.js', 'standin-accast.js', 'standin-acsupo.js']);
async function loadKitFile(file) {
try {
@@ -0,0 +1,97 @@
// server/sim/content/kits/ops/standin-accast.js — Pith (char_612_accast) 补位 stand-in kit: 6★ 扩散术师 for 烛煌 (T5) and
// 焰影苇草 / 迷迭香 / 荒芜拉普兰德 (T6), all S3 + SPC-X; every skill at every form.
// Kit contract and the stand-in rules: ../README.md ("Stand-in kits").
//
// Sources: data/backups.json (character_table / skill_table / battle_equip_table at E2 Lv1 rank 4 and E2 Lv60 rank 7),
// PRTS Pith(卫戍协议) (S3 备注 "※溅射范围扩大至1.2") and the client's battle data (charpack char_612_accast,
// battle/buff_template_data.json accast_s_3[AOE]).
// - 扩散术师 trait "攻击造成群体法术伤害": the splash around each struck target, radius = the trait's
// attack@projectile_range (1.1, PRTS 溅射半径一览's branch value); a skill's attack@projectile_range replaces it while it runs.
// - T1 "见我所见" "攻击无视目标12法术抗性": magic_resist_penetrate_fixed — a flat 法术穿透 attribute (charpack accast_t_1:
// attribute 27 from the blackboard; PRTS Pith 备注 "无视法抗效果为增加自身法术穿透属性(直接加算)"): every arts damage she deals.
// - T2 "授我所授" "自身及相邻四格的【术师】干员攻击力+10%" (module SPC-X level 2 / 3: +15 / +20 %): an aura on her own tile and
// the 4 orthogonal ones, 【术师】 operators only (charpack: profession mask CASTER, removed when the target leaves);
// several sources keep the strongest (installAura, [ASSUMED] like every same-named talent aura of the mode).
// - S1 "书我所书" (自动触发, 25 s): ASPD +attack_speed, splash radius attack@projectile_range (1.5). Trigger: the data's
// DEFAULT — an AUTO attack buff waits for her next attack (W5C's AUTO audit: "attack modes / attack buffs" keep waiting).
// - S2 "为我所为" (35 s): her ASPD +attack_speed; "所有【术师】干员攻击力+atk" — an aura on every 【术师】 operator of the field
// (her included) while it runs, refreshed every AURA_IV s; the strongest of several sources applies [ASSUMED].
// - S3 "驭我所驭" (35 s): at the cast, one atk_scale_aoe × ATK arts hit on every enemy in her attack range (accast_s_3[AOE]:
// AOEDamage MAGICAL, its s3_selector; a skill hit, not an attack); then attack@max_target targets (+1), ASPD
// +attack_speed, splash attack@projectile_range (1.2, PRTS 备注).
// - Module SPC-X "攻击范围扩大": her range becomes the module's own grid (the 3×3 caster range + the tile [0,3]; shared
// tier5 moduleRangeUp), so the S3 burst covers it too; HP / ATK in the stats; T2 +20 % at level 3 (composed talents).
import { num, talentBb, skillRec, statBuff, installAura, up } from '../shared/tier1.js';
import { moduleRangeUp } from '../shared/tier5.js';
const S1 = 'skchr_accast_1';
const S2 = 'skchr_accast_2';
const S3 = 'skchr_accast_3';
/** Refresh period / length of the S2 aura buff (the non-stacking aura convention of the kits). */
const AURA_IV = 0.25;
const AURA_DUR = 0.4;
const S2_KEY = 'accast:s2';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** A 【术师】 operator (summons are never 【术师】 operators). */
const isCaster = (a) => !!a && a.kind === 'op' && a.def?.profession === 'CASTER';
/** One of the 4 tiles next to `u` (相邻四格). */
const adjacent4 = (a, u) => Math.abs(a.tileR - u.tileR) + Math.abs(a.tileC - u.tileC) === 1;
/** S2: ATK +v on every 【术师】 operator unless a stronger one from another source is on it. */
function casterAtk(battle, unit, v) {
if (!(v > 0)) return;
for (const a of battle.alliesFor(unit)) {
if (!isCaster(a)) continue;
const cur = a.findBuff(S2_KEY);
if (cur && cur.source !== unit && (cur.data?.v ?? 0) > v && cur.timeLeft > 0.05) continue;
battle.addBuff(a, { key: S2_KEY, duration: AURA_DUR, mods: { atkPct: v }, source: unit, data: { v }, tags: ['aura'] });
}
}
export default {
char_612_accast: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const tb = chess?.trait?.bb ?? {};
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
return {
trait: { splashRadius: num(tb['attack@projectile_range'], 1.1) },
skills: {
[S1]: { kind: 'duration', mods: { aspd: num(b1.attack_speed) }, attack: { splashRadius: num(b1['attack@projectile_range'], 1.5) } },
[S2]: {
kind: 'duration',
mods: { aspd: num(b2.attack_speed) },
onStart({ battle, unit }) { casterAtk(battle, unit, num(b2.atk)); },
},
[S3]: {
kind: 'duration',
mods: { aspd: num(b3.attack_speed) },
targeting: { maxTargets: Math.max(1, Math.floor(num(b3['attack@max_target'], 2))) },
attack: { splashRadius: num(b3['attack@projectile_range'], 1.2) },
onStart({ battle, unit }) {
const scale = num(b3.atk_scale_aoe, 1);
for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile)) {
battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', isSkill: true, tags: ['skill', 'accast:aoe'] });
battle.fx('aoe', { x: e.x, y: e.y, r: 0.5, id: unit.id, skill: 'accast' });
}
},
},
},
talents: [
{ install(battle, unit) { statBuff(battle, unit, 'talent:accast:res', { resIgnoreFlat: num(t0.magic_resist_penetrate_fixed) }); } },
{ install(battle, unit) {
const v = num(t1.atk);
if (v) installAura(battle, unit, { key: 'talent:accast', value: v, select: (a) => isCaster(a) && (a === unit || adjacent4(a, unit)), mods: { atkPct: v } });
} },
],
install(battle, unit) {
moduleRangeUp(battle, unit, chess);
if (unit.skill?.id !== S2) return;
// S2's aura while it runs (its onStart gives the first application at the cast)
const v2 = num(b2.atk);
battle.every(AURA_IV, () => { if (up(unit) && unit.skill.active) casterAtk(battle, unit, v2); }, { owner: unit });
},
};
},
};
@@ -0,0 +1,93 @@
// server/sim/content/kits/ops/standin-acnipe.js — Stormeye (char_611_acnipe) 补位 stand-in kit: 6★ 速射手 for 空弦 (T3, S3),
// 莫斯提马 (T4, S2), 百炼嘉维尔 / 妮芙 (T5, S3 + MAR-X) and 蕾缪安 / 异客 (T6, S3 + MAR-X); every skill at every form.
// Kit contract and the stand-in rules: ../README.md ("Stand-in kits").
//
// Sources: data/backups.json (character_table / skill_table / battle_equip_table at E2 Lv1 rank 4 and E2 Lv60 rank 7)
// and the client's battle data (charpack char_611_acnipe, battle/buff_template_data.json — the templates quoted below).
// - 速射手 trait 优先攻击空中单位 (data `targetPriority` 'fly', `canHitFly`); module MAR-X "攻击空中单位时攻击力提升至110%"
// (trait atk_scale 1.1, template acnipe_e_002_trait: CheckMotionMode FLY → AtkScaleUp on every damage calculation) is
// the fastshot profile's own (professions.js fastshot `flyScale` from the trait blackboard): no kit code. The module's
// HP / ATK are in the stats; at level 3 it raises 风坠 to 190 % (the composed talents).
// - T1 风坠 "攻击时有25%的概率攻击力提升至180%" — acnipe_t_1: ON_CALCULATE_DAMAGE → Dice(prob) → AtkScaleUp(atk_scale): a roll
// on EVERY damage instance of an attack (each target, each hit of S3's double hit), ATK × atk_scale before DEF / RES.
// S3 raises `prob` to its talent@prob.
// - T2 风雨欲来 "自身未进行攻击时,技力回复速度+0.2/秒" (sp_recovery_per_sec, delay 2): +0.2 SP/s once she has made no attack
// for `delay` s — [ASSUMED] the reading of `delay` (no text or PRTS note gives it), counted from her deployment too.
// - S1 破空: ATK +atk, ASPD +attack_speed, 无视 def_penetrate_fixed DEF (defIgnoreFlat) for its 20 s.
// - S2 心手合一 (自动触发, 持续时间无限): ATK +atk and attack@max_target targets until she is knocked out (a toggle). Trigger:
// the data's DEFAULT — an AUTO attack buff waits for her next attack like 能天使 S3 / 乌尔比安 S2 (W5C's AUTO audit:
// "attack modes / attack buffs" keep waiting); being endless it changes nothing but the moment of the cast.
// - S3 旋臂: attack@max_target targets, attack@times hits each (二连击), 风坠 at talent@prob; and "攻击目标生命值高于90%时额外
// 造成1次伤害": the S3 attack carries acnipe_s_3[extra] (`_onlyFeedActiveBuffToFirstOne`: the first hit of the double hit
// only) — FilterByTargetHpRatio GE attack@hp_ratio, then AdvancedApplyDamage PHYSICAL attack@atk_scale_extra × ATK with
// `_emitSourceOnCalculateDamage` false: one extra physical instance per target per attack, which neither 风坠 nor the
// MAR-X trait touches (no isAttack: no "攻击时" effect sees it). [ASSUMED] the HP ratio is read when that first hit lands,
// before its damage (as 异客's 机理分析 kit reads it; the order of the buff and the hit is not in the data).
import { num, talentBb, skillRec, spTimeBonus } from '../shared/tier1.js';
const S1 = 'skchr_acnipe_1';
const S2 = 'skchr_acnipe_2';
const S3 = 'skchr_acnipe_3';
/** Tag of S3's extra damage instance (not an attack: 风坠 skips it). */
const EXTRA = 'acnipe:extra';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const count = (v, d) => Math.max(1, Math.floor(num(v, d)));
const s3On = (unit) => !!(unit.skill && unit.skill.active && unit.skill.id === S3);
export default {
char_611_acnipe: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
return {
skills: {
[S1]: { kind: 'duration', mods: { atkPct: num(b1.atk), aspd: num(b1.attack_speed), defIgnoreFlat: num(b1.def_penetrate_fixed) } },
[S2]: { kind: 'toggle', mods: { atkPct: num(b2.atk) }, targeting: { maxTargets: count(b2['attack@max_target'], 2) } },
[S3]: {
kind: 'duration',
targeting: { maxTargets: count(b3['attack@max_target'], 3) },
attack: {
hits: count(b3['attack@times'], 2),
// the extra instance, after the attack's hits on that target (marked by the install's first-hit check)
onEachHit({ battle, unit, target, kind }) {
const due = unit.mem.acnipeExtra;
if (kind !== 'main' || !due || !due.has(target)) return;
due.delete(target);
if (target.alive) battle.dealDamage(unit, target, { amount: unit.s.atk * num(b3['attack@atk_scale_extra'], 1), type: 'phys', isSkill: true, tags: ['skill', EXTRA] });
},
},
},
},
talents: [
{ install(battle, unit) { // 风坠: every damage instance of an attack rolls
const scale = num(t0.atk_scale, 1), base = num(t0.prob);
if (!(scale > 0) || scale === 1) return;
battle.on('hit', (ctx) => {
const d = ctx.dmg;
if (ctx.source !== unit || !ctx.target || ctx.target.side !== 'enemy' || !d.isAttack || d.type === 'element' || (d.tags || []).includes(EXTRA)) return;
const p = s3On(unit) ? num(b3['talent@prob'], base) : base;
if (p > 0 && battle.rng.chance(p)) d.amount *= scale;
}, { owner: unit });
} },
{ install(battle, unit) { // 风雨欲来: no attack for `delay` s (her deployment counts as the start) ⇒ +SP/s
const delay = num(t1.delay, 0);
spTimeBonus(battle, unit, num(t1.sp_recovery_per_sec), () => battle.time - Math.max(unit.lastAttackAt, unit.deployedAt) >= delay - 1e-9);
} },
],
install(battle, unit) {
if (unit.skill?.id !== S3) return;
// S3 [extra]: the first hit of each attack on a target checks its HP ratio before that hit's damage
const ratio = num(b3['attack@hp_ratio'], 0.9);
battle.on('hit', (ctx) => {
const t = ctx.target, d = ctx.dmg;
if (ctx.source !== unit || !t || t.side !== 'enemy' || !d.isAttack || d.isSplash || !s3On(unit)) return;
const seen = unit.mem.acnipeSeen ??= new WeakMap();
if (d.attackId && seen.get(t) === d.attackId) return;
seen.set(t, d.attackId);
if (t.hpRatio >= ratio - 1e-9) (unit.mem.acnipeExtra ??= new Set()).add(t);
}, { owner: unit });
},
};
},
};
@@ -0,0 +1,156 @@
// server/sim/content/kits/ops/standin-acsupo.js — Raidian (char_614_acsupo) 补位 stand-in kit: 6★ 凝滞师 for 灵知 (T4, S2),
// 夕 / 安洁莉娜 (T5, S3 + DEC-X) and 浊心斯卡蒂 / 仇白 (T6, S3 + DEC-X); every skill at every form.
// Kit contract and the stand-in rules: ../README.md ("Stand-in kits").
//
// Sources: data/backups.json (character_table / skill_table / battle_equip_table at E2 Lv1 rank 4 and E2 Lv60 rank 7),
// PRTS Raidian(卫戍协议) (S3 备注 on its 虚弱), PRTS 命中率 and the client's battle data (charpack char_614_acsupo).
// - 凝滞师 trait "攻击造成法术伤害,并对敌人造成短暂的停顿": arts, 停顿 `sluggish` s on every target (professions.js slower,
// from the trait blackboard); S2 lengthens it (attack@sluggish).
// - T1 同调 "攻击速度+15,相邻的干员攻击速度+10" (module DEC-X level 2 / 3: 18 / 11, 20 / 12): her own ASPD (charpack
// acsupo_t_1[self]) and an aura on the operators of the 4 tiles next to her (acsupo_t_1[ally]: profession mask of every
// operator class — no summon —, removed when the target leaves); several sources keep the strongest [ASSUMED]. S3
// multiplies both by talent_scale (the S3 mode's `_applyTalentScale`).
// - T2 诱引 "攻击范围内敌人的物理和法术命中率降低10%" (damage_hitrate_physical / damage_hitrate_magical): PRTS 命中率 — every
// unit has 100 % 物理 / 法术命中率; an attack of that damage type made at a lower rate rolls once when it starts, and a
// failed roll misses as a whole: every target and hit of that attack, its splash, and the effects it would carry
// ("攻击未命中…在输出伤害事件前就算作处理失败", no 晕眩 / 停顿 …). Here: one roll per enemy attack (its `attackId`) while the
// attacker stands in a Raidian's current attack range (S3's included), made by a battle-wide `hit` handler at
// LURE_PRIORITY — before the enemy content's own hit riders (content/enemies.js, priority 200) and everything else —
// which cancels every damage instance of a missed attack and stops the hook there. A target's own 闪避 is a separate roll
// in the damage pipeline. [ASSUMED] the roll is made at the attack's first damage instance (a ranged enemy's at its
// projectile's arrival; it stands still through its attack clip) rather than at its wind-up; two Raidians' cuts never
// add up (the stronger one counts, like the mode's other same-named effects — the charpack's `disableOverride` might
// mean they coexist); true damage is never affected (PRTS: neither 物理 nor 法术).
// - S1 双声 (24 s): ATK +atk, attack@max_target targets.
// - S2 三形 (14 / 17 s): base attack time + base_attack_time s (PRTS "略微缩短(-0.1)" / "缩短(-0.2)": a flat change, batMod),
// attack@max_target targets, 停顿 attack@sluggish s.
// - S3 信号跃动 (30 / 35 s): range y-4 (the skill's rangeGrid) — its auto-cast is the data's ACTIVE_RANGE (the owner's rule
// of 2026-10-05: an enemy inside the larger range casts it; tools/build-data.mjs resolveTrigger, not this kit) —, ATK
// +acsupo_s_3.atk, T1 × talent_scale, and every enemy in her attack range takes 脆弱 damage_scale − 1 and 虚弱 1 − atk:
// the catalogue statuses (同名效果取最高), refreshed every AURA_IV s while the skill runs. The 虚弱 stacks as `atk`
// (0.9 / 0.8: PRTS 备注 "此技能的虚弱在叠加时视为90%(1级~6级)/80%(7级~专精二)…的虚弱(仅影响叠加优先级,不影响实际效果)"
// — applyStatus `stackAs`), so it outranks every weaker 虚弱 while it lasts.
// - Module DEC-X "攻击范围内存在敌人时技力自然恢复速度+0.2/秒" (hidden talent sp_recovery_per_sec; acsupo_e_002_t: one
// +0.2 whatever the number of enemies); HP / ATK in the stats; T1 changes at level 2 / 3 (composed talents).
import { canTargetEnemy } from '../../../targeting.js';
import { bodyInKeys } from '../../../body.js';
import { num, talentBb, moduleBb, skillRec, statBuff, spTimeBonus, enemyInRange, up, batMod } from '../shared/tier1.js';
const S1 = 'skchr_acsupo_1';
const S2 = 'skchr_acsupo_2';
const S3 = 'skchr_acsupo_3';
/** Refresh period / length of the auras (T1 on allies, S3 on enemies): the non-stacking aura convention of the kits. */
const AURA_IV = 0.25;
const AURA_DUR = 0.4;
const T1_KEY = 'acsupo:t1';
/** 诱引's battle-wide `hit` handler runs before every other one (the enemy content's riders sit at 200). */
const LURE_PRIORITY = 300;
const ANY = Object.freeze({ canHitFly: true });
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const count = (v, d) => Math.max(1, Math.floor(num(v, d)));
const adjacent4 = (a, u) => Math.abs(a.tileR - u.tileR) + Math.abs(a.tileC - u.tileC) === 1;
const s3On = (unit) => !!(unit.skill && unit.skill.active && unit.skill.id === S3);
// ---- 诱引 (T2): one battle-wide handler over every Raidian of the battle -------------------------------------------
/** battle → { srcs: [{ unit, phys, arts }], last: WeakMap(enemy → { id, miss }) }. */
const LURE = new WeakMap();
/** The hit-rate cut (0–1) `e`'s attack of damage type `type` suffers: the strongest of the Raidians whose range holds it. */
function lureCut(st, e, type) {
let cut = 0;
for (const s of st.srcs) {
const u = s.unit, v = Math.abs(type === 'arts' ? s.arts : s.phys);
if (v > cut && up(u) && u.rangeKeySet && canTargetEnemy(u, e, ANY) && bodyInKeys(e, u.rangeKeySet)) cut = v;
}
return Math.min(1, cut);
}
function lureHit(battle, st, ctx) {
const e = ctx.source, d = ctx.dmg;
if (!e || e.side !== 'enemy' || !d || !d.isAttack || d.cancel || (d.type !== 'phys' && d.type !== 'arts')) return;
// one roll per attack: every damage instance of it (each target, a multi-hit) shares the first instance's result
const prev = d.attackId ? st.last.get(e) : null;
let miss;
if (prev && prev.id === d.attackId) miss = prev.miss;
else {
const cut = lureCut(st, e, d.type);
miss = cut > 0 && battle.rng() < cut;
if (d.attackId) st.last.set(e, { id: d.attackId, miss });
}
if (!miss) return;
d.cancel = true;
ctx.stopPropagation = true;
if (ctx.target) battle.fx('dodge', { x: ctx.target.x, y: ctx.target.y, id: ctx.target.id });
}
function installLure(battle, unit, phys, arts) {
if (!phys && !arts) return;
let st = LURE.get(battle);
if (!st) {
st = { srcs: [], last: new WeakMap() };
LURE.set(battle, st);
// no owner: it serves every Raidian of the battle, whichever is on the field
battle.on('hit', (ctx) => lureHit(battle, st, ctx), { priority: LURE_PRIORITY });
}
st.srcs.push({ unit, phys, arts });
}
// ---- the kit ---------------------------------------------------------------------------------------------------------
export default {
char_614_acsupo: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const grid3 = skillRec(chess, S3)?.rangeGrid;
const self = num(t0.attack_speed), ally = num(t0['acsupo_t_1[ally].attack_speed']);
const scale = num(b3.talent_scale, 1);
const fragile = num(b3.damage_scale, 1) - 1, atkMul = num(b3.atk, 1);
/** S3: 脆弱 / 虚弱 on every enemy of her (skill) range — at the cast, then every AURA_IV s while it runs. */
const s3Aura = (battle, unit) => {
for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, ANY)) {
if (fragile > 0) battle.applyStatus(e, 'fragile', { duration: AURA_DUR, value: fragile, source: unit });
if (atkMul > 0 && atkMul < 1 && e.alive) battle.applyStatus(e, 'weaken', { duration: AURA_DUR, value: 1 - atkMul, stackAs: atkMul, source: unit });
}
};
return {
skills: {
[S1]: { kind: 'duration', mods: { atkPct: num(b1.atk) }, targeting: { maxTargets: count(b1['attack@max_target'], 2) } },
[S2]: {
kind: 'duration',
mods: { batPct: batMod(b2.base_attack_time, chess) },
targeting: { maxTargets: count(b2['attack@max_target'], 3) },
attack: { onHitStatus: { key: 'sluggish', duration: num(b2['attack@sluggish'], 1.1) } },
},
[S3]: {
kind: 'duration',
// T1 × talent_scale: her own part here, the allies' in the T1 aura
mods: { atkPct: num(b3['acsupo_s_3.atk']), aspd: self * (scale - 1) },
...(grid3 && grid3.length ? { targeting: { rangeGrid: grid3.map((p) => [p[0], p[1]]) } } : {}),
onStart({ battle, unit }) { s3Aura(battle, unit); },
},
},
talents: [
{ install(battle, unit) { // 同调
statBuff(battle, unit, 'talent:acsupo', { aspd: self });
if (!ally) return;
battle.every(AURA_IV, () => {
if (!up(unit)) return;
const v = ally * (s3On(unit) ? scale : 1);
for (const a of battle.alliesFor(unit)) {
if (a === unit || a.kind !== 'op' || !adjacent4(a, unit)) continue;
const cur = a.findBuff(T1_KEY);
if (cur && cur.source !== unit && (cur.data?.v ?? 0) > v && cur.timeLeft > 0.05) continue;
battle.addBuff(a, { key: T1_KEY, duration: AURA_DUR, mods: { aspd: v }, source: unit, data: { v }, tags: ['aura'] });
}
}, { owner: unit, immediate: true });
} },
{ install(battle, unit) { installLure(battle, unit, num(t1.damage_hitrate_physical), num(t1.damage_hitrate_magical)); } }, // 诱引
],
install(battle, unit) {
spTimeBonus(battle, unit, num(moduleBb(chess).sp_recovery_per_sec), () => enemyInRange(battle, unit)); // DEC-X
if (unit.skill?.id === S3) battle.every(AURA_IV, () => { if (up(unit) && s3On(unit)) s3Aura(battle, unit); }, { owner: unit });
},
};
},
};
+209
View File
@@ -0,0 +1,209 @@
// test/content/standin_accast.test.js — the 补位 stand-in Pith (char_612_accast, kits/ops/standin-accast.js) on every chess
// it replaces (烛煌 T5, 焰影苇草 / 迷迭香 / 荒芜拉普兰德 T6, all S3 + SPC-X), normal and elite: every skill (S1 "书我所书", S2
// "为我所为", S3 "驭我所驭" with its burst), both talents (12 RES ignored; ATK +10 % on herself and the 【术师】 of the 4 tiles
// next to her), the module (SPC-X range, stats, T2 +20 % at level 3), the splash radius per skill, the data triggers,
// anti-air.
// Run: node --test test/content/standin_accast.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { unitForm } from '../../shared/standIn.js';
import { absoluteRangeKeys } from '../../server/sim/targeting.js';
import { COLS } from '../../server/sim/constants.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const PITH = 'char_612_accast';
const [S1, S2, S3] = ['skchr_accast_1', 'skchr_accast_2', 'skchr_accast_3'];
const RECORDS = BACKUPS.units[PITH].standsIn.flatMap((a) => [a, a.replace(/_a$/, '_b')]);
const DUMMIES = {
d: enemyRec({ key: 'd', hp: 1e7, speed: 0 }),
r: enemyRec({ key: 'r', hp: 1e7, res: 50, speed: 0 }),
f: enemyRec({ key: 'f', hp: 1e7, speed: 0, motion: 'FLY' }),
};
const close = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${msg}: ${a} vs ${b}`);
const formOf = (id) => unitForm(BACKUPS, PITH, CHESS[id].status);
const skillOf = (id, sid) => formOf(id).skills.find((s) => s.skillId === sid);
const moduleOf = (id) => {
const c = CHESS[id];
return c.status.equipLevel > 0 && c.backup.uniEquipId ? formOf(id).modules.find((m) => m.uniEquipId === c.backup.uniEquipId) : null;
};
const key = (r, c) => r * COLS + c;
/** Pith at (10,4) facing right (3-6: rows 9–11, cols 4–6; SPC-X adds (10,7)); dummies at `enemies`; extra `units`. */
function arena(chessId, { standIn = true, enemies = [], units = [], sp = 999, seed = 5, timeLimit = 200 } = {}) {
return makeBattle({
defs: { enemies: DUMMIES }, seed, timeLimit, autoFinish: false,
units: [{ chessId, row: 10, col: 4, dir: 'RIGHT', standIn, ...(sp != null ? { carryState: { sp } } : {}) }, ...units],
enemies, hooks: ['skillStart', 'skillEnd', 'damaged', 'attack'], captureNoisy: true,
});
}
const done = (h) => { assert.deepEqual(h.b.errors.map((e) => `${e.label} ${e.message}`), []); checkInvariants(h.b); };
const hits = (h, u, t0 = -1) => h.hooksOf('damaged').filter((c) => c.source === u && c.t >= t0);
test('Pith on every chess it replaces: the stand-in body, its kit, S3; SPC-X on the elites (level 1 at T5, 3 at T6) with its range; arts splash 1.1, anti-air', () => {
assert.equal(RECORDS.length, 8);
for (const id of RECORDS) {
const c = CHESS[id];
const form = formOf(id), mod = moduleOf(id);
const h = arena(id, { sp: null });
h.step();
const u = h.unit(1);
const where = `${id} ${c.name}`;
assert.deepEqual([u.def.charId, u.def.standInFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [PITH, c.charId, S3, false, 'skills'], where);
assert.equal(!!u.def.raw.module?.active, !!mod, `${where}: module only on the elite`);
assert.equal(!!mod, id.endsWith('_b'), where);
if (mod) assert.equal(u.def.raw.module.level, c.tier === 6 ? 3 : 1, where);
assert.equal(u.base.atk, form.stats.atk + (mod ? mod.attr.atk : 0), `${where}: ATK`);
assert.equal(u.base.maxHp, form.stats.maxHp + (mod ? mod.attr.maxHp : 0), `${where}: HP`);
assert.deepEqual([u.profile.dmgType, u.profile.splashRadius, u.profile.canHitFly], ['arts', 1.1, true], `${where}: profile`);
// the range: 3-6 (9 tiles), SPC-X its 3-1 grid (+ the tile [0,3])
const want = absoluteRangeKeys(mod ? mod.talentChanges.find((t) => t.talentIndex === -1).rangeGrid : form.rangeGrid, 10, 4, 'RIGHT', 0);
assert.deepEqual([...u.rangeKeys].sort((a, b) => a - b), [...want].sort((a, b) => a - b), `${where}: range`);
assert.equal(u.rangeKeySet.has(key(10, 7)), !!mod, `${where}: (10,7) only with SPC-X`);
const t1 = u.def.raw.talents.find((t) => t.index === 1);
assert.equal(t1.bb.atk, mod && mod.level === 3 ? 0.2 : 0.1, `${where}: 授我所授`);
done(h);
}
});
test('Pith triggers: the data rules — S1 AUTO DEFAULT (an attack buff waits for her attack), S2 / S3 MANUAL DEFAULT', () => {
for (const id of ['chess_char_5_03_a', 'chess_char_5_03_b']) {
for (const [i, sid] of [[0, S1], [1, S2], [2, S3]]) {
const h = arena(id, { standIn: { skillIndex: i }, sp: null });
h.step();
const u = h.unit(1);
assert.deepEqual([u.skill.id, u.skill.rule, u.def.skill.skillType], [sid, 'DEFAULT', sid === S1 ? 'AUTO' : 'MANUAL'], `${id} ${sid}`);
done(h);
}
}
});
test('"见我所见": her arts hits ignore 12 RES (flat 法术穿透)', () => {
for (const id of ['chess_char_5_03_a', 'chess_char_6_12_b']) {
const h = arena(id, { sp: null, enemies: [{ key: 'r', pos: [10, 6] }] });
const u = h.unit(1);
assert.equal(u.s.resIgnoreFlat, 12);
h.run(1);
const t0 = h.b.time;
assert.ok(h.runUntil(() => hits(h, u, t0).length > 0, 5));
close(hits(h, u, t0)[0].amount, u.s.atk * (1 - (50 - 12) / 100), `${id}: RES 50 − 12`, 1e-3);
done(h);
}
});
test('"授我所授": ATK +10 % (T6 elite: +20 %) on herself and the 【术师】 operators of the 4 tiles next to her — not diagonal, not farther, not another class', () => {
for (const [id, v] of [['chess_char_5_03_a', 0.1], ['chess_char_5_03_b', 0.1], ['chess_char_6_08_b', 0.2]]) {
const h = arena(id, { sp: null, units: [
{ chessId: 'chess_char_1_03_a', row: 10, col: 3 }, // 惊蛰 (CASTER), behind her: adjacent
{ chessId: 'chess_char_1_14_a', row: 9, col: 3 }, // 格雷伊 (CASTER), diagonal
{ chessId: 'chess_char_1_17_a', row: 12, col: 4 }, // 深靛 (CASTER), two tiles away
{ chessId: 'chess_char_1_02_a', row: 11, col: 4 }, // 角峰 (TANK), adjacent
] });
h.run(1);
const [p, adj, diag, far, tank] = [1, 2, 3, 4, 5].map((uid) => h.unit(uid));
const buff = (x) => x.findBuff('talent:accast')?.mods?.atkPct ?? 0;
assert.deepEqual([buff(p), buff(adj), buff(diag), buff(far), buff(tank)], [v, v, 0, 0, 0], id);
close(p.s.atk, p.base.atk * (1 + v), `${id}: her ATK`);
done(h);
}
});
test('S1 "书我所书" (自动触发): no enemy, no cast; at her attack: ASPD +30 / +40 and the splash radius 1.5 for 25 s (1.1 before and after)', () => {
for (const id of ['chess_char_5_03_a', 'chess_char_5_03_b']) {
const sk = skillOf(id, S1);
// (10,6) is a target; (11,7) is 1.41 tiles from it and out of her range: splash only, within 1.5 but not 1.1
const h = arena(id, { standIn: { skillIndex: 0 }, enemies: [{ key: 'd', pos: [10, 6], time: 3 }, { key: 'd', pos: [11, 7], time: 3 }] });
const u = h.unit(1);
h.run(2.9);
assert.equal(u.skill.activations, 0, `${id}: nobody to attack — no cast`);
assert.ok(h.runUntil(() => u.skill.active, 3), `${id}: cast`);
const start = h.b.time;
assert.equal(u.s.aspd, 100 + sk.bb.attack_speed, `${id}: ASPD`);
const side = h.b.enemies.find((e) => Math.round(e.y) === 11);
h.run(5);
assert.ok(hits(h, u, start).some((c) => c.target === side && c.dmg.isSplash), `${id}: the 1.41-tile neighbour takes the splash`);
assert.ok(h.runUntil(() => !u.skill.active, 30));
close(h.b.time - start, sk.duration, `${id}: ${sk.duration} s`, 0.05);
assert.equal(u.s.aspd, 100);
const t1 = h.b.time + 1.5; // the last S1 bolts land
h.run(6);
assert.equal(hits(h, u, t1).filter((c) => c.target === side).length, 0, `${id}: splash 1.1 again`);
assert.ok(hits(h, u, t1).length > 0);
done(h);
}
});
test('S2 "为我所为": her ASPD +35 / +50 and every 【术师】 operator ATK +30 / +35 % (herself included, wherever they stand; no other class) for 35 s', () => {
for (const id of ['chess_char_5_03_a', 'chess_char_5_03_b']) {
const sk = skillOf(id, S2);
const h = arena(id, { standIn: { skillIndex: 1 }, enemies: [{ key: 'd', pos: [10, 6] }], units: [
{ chessId: 'chess_char_1_03_a', row: 12, col: 9 }, // 惊蛰 (CASTER), far away
{ chessId: 'chess_char_1_02_a', row: 9, col: 2 }, // 角峰 (TANK)
] });
const [u, caster, tank] = [1, 2, 3].map((uid) => h.unit(uid));
assert.ok(h.runUntil(() => u.skill.active, 3), `${id}: cast`);
const start = h.b.time;
assert.equal(u.s.aspd, 100 + sk.bb.attack_speed, `${id}: ASPD`);
h.run(0.5);
const buff = (x) => x.findBuff('accast:s2')?.mods?.atkPct ?? 0;
assert.deepEqual([buff(u), buff(caster), buff(tank)], [sk.bb.atk, sk.bb.atk, 0], id);
close(u.s.atk, u.base.atk * (1 + 0.1 + sk.bb.atk), `${id}: her ATK (T2 + S2)`);
h.run(30);
assert.equal(buff(caster), sk.bb.atk, `${id}: kept up while it runs`);
assert.ok(h.runUntil(() => !u.skill.active, 10));
close(h.b.time - start, sk.duration, `${id}: ${sk.duration} s`, 0.05);
h.run(0.5);
assert.deepEqual([buff(u), buff(caster)], [0, 0], `${id}: gone after it`);
done(h);
}
});
test('S3 "驭我所驭": one 100 / 140 % arts hit on every enemy of her range at the cast (SPC-X range included), then 2 targets, ASPD +40 / +55, splash 1.2, 35 s', () => {
for (const id of ['chess_char_5_03_a', 'chess_char_6_08_b']) {
const sk = skillOf(id, S3), elite = id.endsWith('_b');
const h = arena(id, { sp: null, enemies: [[10, 5], [9, 6], [11, 6], [10, 7], [9, 8]].map((pos) => ({ key: 'd', pos })) });
const u = h.unit(1);
h.run(1);
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), `${id}: cast`);
const start = h.hooksOf('skillStart').find((c) => c.unit === u).t, atk = u.s.atk;
const burst = hits(h, u, start).filter((c) => (c.dmg.tags || []).includes('accast:aoe'));
const at = (c) => `${Math.round(c.target.y)},${Math.round(c.target.x)}`;
const want = ['10,5', '9,6', '11,6', ...(elite ? ['10,7'] : [])].sort();
assert.deepEqual(burst.map(at).sort(), want, `${id}: every enemy of her range once — (9,8) is out of it`);
assert.ok(burst.every((c) => Math.abs(c.amount - atk * sk.bb.atk_scale_aoe) < 1e-6 && c.type === 'arts' && c.dmg.isSkill && !c.dmg.isAttack), `${id}: ATK × ${sk.bb.atk_scale_aoe} arts, a skill hit`);
assert.equal(u.s.aspd, 100 + sk.bb.attack_speed, `${id}: ASPD`);
h.run(8);
const atks = h.hooksOf('attack').filter((c) => c.attacker === u && c.t >= start);
assert.ok(atks.length >= 3 && atks.every((c) => c.targets.length === 2), `${id}: 2 targets per attack (the cast's own attack too)`);
assert.equal(h.hooksOf('skillStart').filter((c) => c.unit === u).length, 1, 'one burst per cast');
assert.ok(h.runUntil(() => !u.skill.active, 40));
close(h.b.time - start, sk.duration, `${id}: ${sk.duration} s`, 0.05);
done(h);
}
// the splash radius 1.2: a neighbour 1.15 tiles from her only target, out of her range, is splashed during S3 only
const h = arena('chess_char_5_03_a', { sp: null, enemies: [{ key: 'd', pos: [10, 6] }, { key: 'd', pos: [10, 7.15] }] });
const u = h.unit(1);
h.run(3);
const side = h.b.enemies.find((e) => e.x > 7);
assert.ok(side && hits(h, u).length > 0);
assert.equal(hits(h, u).filter((c) => c.target === side).length, 0, 'splash 1.1: not reached');
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3));
const t0 = h.b.time + 0.01;
h.run(4);
assert.ok(hits(h, u, t0).some((c) => c.target === side && c.dmg.isSplash), 'splash 1.2: reached');
done(h);
});
test('Pith hits air units too: a flyer in range is her target', () => {
const h = arena('chess_char_6_12_a', { sp: null, enemies: [{ key: 'f', pos: [10, 6], route: 2 }] });
const u = h.unit(1);
assert.ok(h.runUntil(() => hits(h, u).length > 0, 5));
assert.ok(hits(h, u)[0].target.isFlying);
done(h);
});
+241
View File
@@ -0,0 +1,241 @@
// test/content/standin_acnipe.test.js — the 补位 stand-in Stormeye (char_611_acnipe, kits/ops/standin-acnipe.js) on every
// chess it replaces (空弦 T3 S3, 莫斯提马 T4 S2, 百炼嘉维尔 / 妮芙 T5 S3 + MAR-X, 蕾缪安 / 异客 T6 S3 + MAR-X), normal and elite:
// every skill (S1 破空, S2 心手合一, S3 旋臂 with its extra hit), both talents (风坠 on every damage instance, 风雨欲来), the
// module (MAR-X ×1.1 on air units, stats, 风坠 190 % at level 3), the data triggers, anti-air and the 'fly' priority.
// Run: node --test test/content/standin_acnipe.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { unitForm } from '../../shared/standIn.js';
import { canTargetAlly } from '../../server/sim/targeting.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const STORM = 'char_611_acnipe';
const [S1, S2, S3] = ['skchr_acnipe_1', 'skchr_acnipe_2', 'skchr_acnipe_3'];
const EXTRA = 'acnipe:extra';
const RECORDS = BACKUPS.units[STORM].standsIn.flatMap((a) => [a, a.replace(/_a$/, '_b')]);
const DUMMIES = {
d: enemyRec({ key: 'd', hp: 1e7, speed: 0 }),
dd: enemyRec({ key: 'dd', hp: 1e7, def: 300, speed: 0 }),
f: enemyRec({ key: 'f', hp: 1e7, speed: 0, motion: 'FLY' }),
};
const close = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${msg}: ${a} vs ${b}`);
const formOf = (id) => unitForm(BACKUPS, STORM, CHESS[id].status);
const skillOf = (id, sid) => formOf(id).skills.find((s) => s.skillId === sid);
/** The module the elite record fields (null without one). */
const moduleOf = (id) => {
const c = CHESS[id];
return c.status.equipLevel > 0 && c.backup.uniEquipId ? formOf(id).modules.find((m) => m.uniEquipId === c.backup.uniEquipId) : null;
};
/** Stormeye at (10,4) facing right (range 3-3: rows 9–11, cols 4–7); dummies at `enemies`. */
function arena(chessId, { standIn = true, enemies = [], sp = 999, seed = 3, timeLimit = 200 } = {}) {
return makeBattle({
defs: { enemies: DUMMIES }, seed, timeLimit, autoFinish: false,
units: [{ chessId, row: 10, col: 4, dir: 'RIGHT', standIn, ...(sp != null ? { carryState: { sp } } : {}) }],
enemies, hooks: ['skillStart', 'skillEnd', 'damaged', 'attack'], captureNoisy: true,
});
}
const done = (h) => { assert.deepEqual(h.b.errors.map((e) => `${e.label} ${e.message}`), []); checkInvariants(h.b); };
/** Damage instances Stormeye dealt (captured `damaged` ctx), optionally from `t0` on. */
const hits = (h, u, t0 = -1) => h.hooksOf('damaged').filter((c) => c.source === u && c.t >= t0);
test('Stormeye on every chess it replaces: the stand-in body, its kit (never generic), the chess backup skill, the module on the T5 / T6 elites only; anti-air, fly first, ground-targetable', () => {
assert.equal(RECORDS.length, 12);
for (const id of RECORDS) {
const c = CHESS[id];
const form = formOf(id), mod = moduleOf(id);
const sk = form.skills.find((s) => s.index === c.backup.skillIndex);
const h = arena(id, { sp: null });
h.step();
const u = h.unit(1);
const where = `${id} ${c.name}`;
assert.deepEqual([u.def.charId, u.def.standInFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [STORM, c.charId, sk.skillId, false, 'skills'], where);
assert.equal(u.skill.kind, sk.skillId === S2 ? 'toggle' : 'duration', where);
assert.equal(!!u.def.raw.module?.active, !!mod, `${where}: module only on the T5 / T6 elite`);
if (mod) assert.equal(u.def.raw.module.level, c.tier === 6 ? 3 : 1, where);
assert.equal(u.base.atk, form.stats.atk + (mod ? mod.attr.atk : 0), `${where}: ATK`);
assert.equal(u.base.maxHp, form.stats.maxHp + (mod ? mod.attr.maxHp : 0), `${where}: HP`);
assert.deepEqual([u.profile.canHitFly, u.profile.priority, u.profile.dmgType, u.profile.flyScale], [true, 'fly', 'phys', mod ? 1.1 : 1], `${where}: profile`);
const t0 = u.def.raw.talents.find((t) => t.index === 0);
assert.equal(t0.bb.atk_scale, mod && mod.level === 3 ? 1.9 : 1.8, `${where}: 风坠`);
// ground-targetable: an enemy ranged attack may select her (no 起飞 / 迷彩 / 隐匿)
assert.ok(canTargetAlly({ blockedBy: null }, u, true), `${where}: an enemy may target her`);
done(h);
}
});
test('Stormeye triggers: the data rules — S1 / S3 MANUAL DEFAULT (no range change), S2 AUTO DEFAULT (an attack buff waits for her next attack, endless)', () => {
for (const id of ['chess_char_3_21_a', 'chess_char_3_21_b']) {
for (const [i, sid] of [[0, S1], [1, S2], [2, S3]]) {
const h = arena(id, { standIn: { skillIndex: i }, sp: null });
h.step();
const u = h.unit(1);
assert.deepEqual([u.skill.id, u.skill.rule, u.def.skill.trigger.rule, u.def.skill.skillType], [sid, 'DEFAULT', 'DEFAULT', sid === S2 ? 'AUTO' : 'MANUAL'], `${id} ${sid}`);
done(h);
}
}
});
test('S1 破空: ATK +15 %, ASPD +30 / +50, 100 DEF ignored on her hits, for 20 s; then all of it ends', () => {
for (const id of ['chess_char_3_21_a', 'chess_char_3_21_b']) {
const sk = skillOf(id, S1);
const h = arena(id, { standIn: { skillIndex: 0 }, enemies: [{ key: 'dd', pos: [10, 6] }] });
const u = h.unit(1);
h.b.rng.chance = () => false; // no 风坠
assert.ok(h.runUntil(() => u.skill.active, 3), `${id}: cast`);
const start = h.b.time, base = u.base.atk;
close(u.s.atk, base * (1 + sk.bb.atk), `${id}: ATK`);
assert.equal(u.s.aspd, 100 + sk.bb.attack_speed, `${id}: ASPD`);
assert.equal(u.s.defIgnoreFlat, 100, `${id}: DEF ignored`);
assert.ok(h.runUntil(() => hits(h, u, start).length > 0, 3));
close(hits(h, u, start)[0].amount, base * (1 + sk.bb.atk) - (300 - 100), `${id}: a hit on DEF 300`, 1e-3);
assert.ok(h.runUntil(() => !u.skill.active, 30));
close(h.b.time - start, sk.duration, `${id}: ${sk.duration} s`, 0.05);
assert.deepEqual([u.s.atk, u.s.aspd, u.s.defIgnoreFlat], [base, 100, 0], `${id}: back`);
done(h);
}
});
test('S2 心手合一 (自动触发): no enemy, no cast; with one it casts at her attack — ATK +5 %, one extra target per attack, endless and no SP meanwhile; a knock-out ends it', () => {
for (const id of ['chess_char_4_02_a', 'chess_char_4_02_b']) {
const sk = skillOf(id, S2);
const h = arena(id, { timeLimit: 400, enemies: [{ key: 'd', pos: [10, 6], time: 5 }, { key: 'd', pos: [9, 6], time: 5 }] });
const u = h.unit(1);
h.run(4.9);
assert.equal(u.skill.activations, 0, `${id}: full SP, nobody to attack — no cast`);
assert.ok(h.runUntil(() => u.skill.active, 3), `${id}: cast`);
assert.equal(h.hooksOf('skillStart')[0].reason, 'DEFAULT');
const base = u.base.atk;
close(u.s.atk, base * (1 + sk.bb.atk), `${id}: ATK`);
h.run(10);
const atks = h.hooksOf('attack').filter((c) => c.attacker === u);
assert.ok(atks.length >= 9 && atks.every((c) => c.targets.length === sk.bb['attack@max_target']), `${id}: ${sk.bb['attack@max_target']} targets per attack`);
h.run(120);
assert.ok(u.skill.active && u.skill.kind === 'toggle', `${id}: still on after 130 s`);
assert.equal(u.skill.sp, 0, `${id}: no SP while it runs`);
h.b.kill(u);
assert.ok(h.runUntil(() => u.alive && u.deployed, 120), 'redeployed');
h.step();
assert.equal(u.skill.active, false, `${id}: off after the knock-out`);
close(u.skill.sp, sk.initSp, `${id}: SP back to its initial ${sk.initSp}`, 0.1);
done(h);
}
});
test('S3 旋臂: 3 targets, a double hit on each, 风坠 at 30 / 35 % (25 % before), one extra 100 % hit per attack on a target at ≥ 90 % HP — none below; 30 s', () => {
for (const id of ['chess_char_3_21_a', 'chess_char_6_01_b']) {
const sk = skillOf(id, S3);
const h = arena(id, { sp: null, enemies: [[10, 5], [10, 6], [9, 6], [11, 6]].map((pos) => ({ key: 'd', pos })) });
const u = h.unit(1);
const probs = [];
h.b.rng.chance = (p) => { probs.push([h.b.time, p, !!(u.skill.active)]); return false; };
// before the cast: one target, 风坠 at the talent's 25 %
assert.ok(h.runUntil(() => hits(h, u).length >= 2, 5));
assert.ok(probs.length >= 2 && probs.every(([, p, on]) => !on && p === 0.25), `${id}: 25 % outside S3`);
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), `${id}: S3 cast`);
const start = h.b.time;
h.run(6);
const during = probs.filter(([t]) => t > start + 0.5);
assert.ok(during.length > 10 && during.every(([, p]) => p === sk.bb['talent@prob']), `${id}: 风坠 at ${sk.bb['talent@prob']}`);
// per attack: 3 targets × (2 attack hits + 1 extra at 100 % ATK, never rolled)
const atk = u.s.atk;
const byAttack = new Map();
for (const c of hits(h, u, start + 0.5)) {
const k = c.dmg.attackId || `x${c.t}`;
if (!byAttack.has(k)) byAttack.set(k, []);
byAttack.get(k).push(c);
}
const attacks = h.hooksOf('attack').filter((c) => c.attacker === u && c.t > start + 0.5 && c.t < h.b.time - 1);
assert.ok(attacks.length >= 3 && attacks.every((c) => c.targets.length === 3), `${id}: 3 targets`);
const normal = hits(h, u, start + 0.5).filter((c) => c.dmg.isAttack);
const extra = hits(h, u, start + 0.5).filter((c) => (c.dmg.tags || []).includes(EXTRA));
assert.ok(normal.every((c) => Math.abs(c.amount - atk) < 1e-6), `${id}: every attack hit = ATK (no 风坠)`);
assert.ok(extra.length > 0 && extra.every((c) => Math.abs(c.amount - atk * sk.bb['attack@atk_scale_extra']) < 1e-6 && !c.dmg.isAttack && c.type === 'phys'), `${id}: the extra hit: ATK × atk_scale_extra, physical, not an attack`);
// every target of an attack: exactly 2 attack hits and 1 extra
const perTarget = new Map();
for (const c of normal.concat(extra)) {
const k = `${c.target.id}`;
perTarget.set(k, perTarget.get(k) ?? { n: 0, x: 0 });
perTarget.get(k)[(c.dmg.tags || []).includes(EXTRA) ? 'x' : 'n']++;
}
for (const [k, v] of perTarget) assert.equal(v.n, 2 * v.x, `${id}: target ${k} — 2 hits per extra (${v.n} / ${v.x})`);
// a target below 90 %: no extra
const low = h.b.enemies.find((e) => perTarget.has(`${e.id}`));
low.hp = low.s.maxHp * 0.85;
const t1 = h.b.time;
h.run(3);
assert.ok(hits(h, u, t1).some((c) => c.target === low && c.dmg.isAttack), 'the low target is still attacked');
assert.equal(hits(h, u, t1).filter((c) => c.target === low && (c.dmg.tags || []).includes(EXTRA)).length, 0, `${id}: no extra below 90 %`);
// 风坠 on: the attack hits ×1.8 / 1.9, the extra stays at 100 %
h.b.rng.chance = () => true;
const t2 = h.b.time;
h.run(2);
const scale = u.def.raw.talents.find((t) => t.index === 0).bb.atk_scale;
const crit = hits(h, u, t2);
assert.ok(crit.filter((c) => c.dmg.isAttack).every((c) => Math.abs(c.amount - u.s.atk * scale) < 1e-6), `${id}: 风坠 ×${scale}`);
assert.ok(crit.filter((c) => (c.dmg.tags || []).includes(EXTRA)).every((c) => Math.abs(c.amount - u.s.atk) < 1e-6), `${id}: never on the extra`);
assert.ok(h.runUntil(() => !u.skill.active, 40));
close(h.b.time - start, sk.duration, `${id}: ${sk.duration} s`, 0.05);
done(h);
}
});
test('风坠: an attack hit at ATK × 1.8 (T6 elite, MAR-X level 3: × 1.9) before DEF, on 25 % of the hits', () => {
for (const id of ['chess_char_3_21_a', 'chess_char_6_05_b']) {
const h = arena(id, { sp: null, enemies: [{ key: 'dd', pos: [10, 6] }] });
const u = h.unit(1);
h.b.rng.chance = () => true;
assert.ok(h.runUntil(() => hits(h, u).length > 0, 5));
const scale = id.endsWith('_b') ? 1.9 : 1.8;
close(hits(h, u)[0].amount, u.s.atk * scale - 300, `${id}: ATK × ${scale} − DEF`, 1e-3);
done(h);
}
// the real roll: about a quarter of 200 hits (seeded)
const h = arena('chess_char_3_21_a', { sp: null, seed: 11, enemies: [{ key: 'd', pos: [10, 6] }], timeLimit: 400 });
const u = h.unit(1);
h.run(42); // S3 is cast at 44 s
const all = hits(h, u).filter((c) => c.dmg.isAttack);
const crits = all.filter((c) => c.amount > u.s.atk * 1.5);
assert.ok(all.length >= 40, `${all.length} hits`);
assert.ok(crits.length / all.length > 0.1 && crits.length / all.length < 0.45, `${crits.length} of ${all.length} hits ×1.8`);
done(h);
});
test('MAR-X (T5 / T6 elite): attacks on air units at ATK × 1.1; the normal form has no bonus; fly targets first', () => {
for (const [id, mul] of [['chess_char_5_18_b', 1.1], ['chess_char_6_01_b', 1.1], ['chess_char_5_18_a', 1]]) {
const h = arena(id, { sp: null, enemies: [{ key: 'd', pos: [10, 5] }, { key: 'f', pos: [10, 7], route: 2 }] });
const u = h.unit(1);
h.b.rng.chance = () => false;
assert.ok(h.runUntil(() => hits(h, u).length >= 3, 6));
const first = hits(h, u);
assert.ok(first.slice(0, 3).every((c) => c.target.isFlying), `${id}: the flyer first, though the ground dummy is nearer`);
close(first[0].amount, u.s.atk * mul, `${id}: ×${mul} on the flyer`);
done(h);
}
});
test('风雨欲来: +0.2 SP/s once she has not attacked for 2 s (her deployment counts); attacking every second, none', () => {
const sk = skillOf('chess_char_3_21_a', S3);
const idle = arena('chess_char_3_21_a', { sp: null });
const u = idle.unit(1);
idle.step();
const t0 = idle.b.time, sp0 = u.skill.sp;
idle.run(12);
const dt = idle.b.time - t0;
close(u.skill.sp - sp0, dt + 0.2 * (dt - 2), 'idle: +1/s, +0.2/s after 2 s', 0.08);
done(idle);
const busy = arena('chess_char_3_21_a', { sp: null, enemies: [{ key: 'd', pos: [10, 6] }] });
const v = busy.unit(1);
busy.step();
const b0 = busy.b.time, bs0 = v.skill.sp;
busy.run(12);
close(v.skill.sp - bs0, busy.b.time - b0, 'attacking: +1/s only', 0.08);
assert.ok(v.skill.sp < sk.spCost);
done(busy);
});
+297
View File
@@ -0,0 +1,297 @@
// test/content/standin_acsupo.test.js — the 补位 stand-in Raidian (char_614_acsupo, kits/ops/standin-acsupo.js) on every
// chess it replaces (灵知 T4 S2, 夕 / 安洁莉娜 T5 S3 + DEC-X, 浊心斯卡蒂 / 仇白 T6 S3 + DEC-X), normal and elite: every skill
// (S1 双声, S2 三形, S3 信号跃动 with its range, auras and the data's ACTIVE_RANGE trigger), both talents (同调 on herself and
// the operators next to her, 诱引's hit-rate cut on the attacks of the enemies in her range — PRTS 命中率), the module
// (DEC-X SP, stats, 同调 at level 3), the 停顿 trait, anti-air; and the engine option behind S3's 虚弱 (applyStatus stackAs).
// Run: node --test test/content/standin_acsupo.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { unitForm } from '../../shared/standIn.js';
import { COLS } from '../../server/sim/constants.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const RAID = 'char_614_acsupo';
const [S1, S2, S3] = ['skchr_acsupo_1', 'skchr_acsupo_2', 'skchr_acsupo_3'];
const RECORDS = BACKUPS.units[RAID].standsIn.flatMap((a) => [a, a.replace(/_a$/, '_b')]);
const ENEMIES = {
d: enemyRec({ key: 'd', hp: 1e7, speed: 0 }),
brute: enemyRec({ key: 'brute', hp: 1e7, atk: 200, bat: 1, speed: 1 }),
holy: enemyRec({ key: 'holy', hp: 1e7, atk: 200, bat: 1, speed: 1, dmgType: 'true' }),
archer: enemyRec({ key: 'archer', hp: 1e7, atk: 200, bat: 1, speed: 0, range: 2.5 }),
};
/** A plain blocker without a kit (no randomness of its own). */
const TANK = chessRec({ id: 't_tank', profession: 'TANK', subProfessionId: 'protector', stats: { maxHp: 1e6, def: 0, atk: 1, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null });
const close = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${msg}: ${a} vs ${b}`);
const formOf = (id) => unitForm(BACKUPS, RAID, CHESS[id].status);
const skillOf = (id, sid) => formOf(id).skills.find((s) => s.skillId === sid);
const moduleOf = (id) => {
const c = CHESS[id];
return c.status.equipLevel > 0 && c.backup.uniEquipId ? formOf(id).modules.find((m) => m.uniEquipId === c.backup.uniEquipId) : null;
};
const key = (r, c) => r * COLS + c;
/** Raidian at (10,4) facing right (y-2: rows 9–11, cols 3–6; S3's y-4 adds (10,7) and rows 8 / 12); extra `units`. */
function arena(chessId, { standIn = true, enemies = [], units = [], sp = 999, seed = 7, timeLimit = 200, setup = null } = {}) {
return makeBattle({
defs: { enemies: ENEMIES, chess: { t_tank: TANK } }, seed, timeLimit, autoFinish: false, setup,
units: [{ chessId, row: 10, col: 4, dir: 'RIGHT', standIn, ...(sp != null ? { carryState: { sp } } : {}) }, ...units],
enemies, hooks: ['skillStart', 'skillEnd', 'damaged', 'attack', 'statusApplied'], captureNoisy: true,
});
}
const done = (h) => { assert.deepEqual(h.b.errors.map((e) => `${e.label} ${e.message}`), []); checkInvariants(h.b); };
const castOf = (h, u) => h.hooksOf('skillStart').find((c) => c.unit === u);
test('Raidian on every chess it replaces: the stand-in body, its kit, the backup skill (S2 for 灵知, S3 else), DEC-X on the T5 / T6 elites; arts + 停顿 0.8 s, anti-air', () => {
assert.equal(RECORDS.length, 10);
for (const id of RECORDS) {
const c = CHESS[id];
const form = formOf(id), mod = moduleOf(id);
const sk = form.skills.find((s) => s.index === c.backup.skillIndex);
const h = arena(id, { sp: null });
h.step();
const u = h.unit(1);
const where = `${id} ${c.name}`;
assert.deepEqual([u.def.charId, u.def.standInFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [RAID, c.charId, sk.skillId, false, 'skills'], where);
assert.equal(sk.skillId, c.tier === 4 ? S2 : S3, where);
assert.equal(!!u.def.raw.module?.active, !!mod, `${where}: module only on the T5 / T6 elite`);
if (mod) assert.equal(u.def.raw.module.level, c.tier === 6 ? 3 : 1, where);
assert.equal(u.base.atk, form.stats.atk + (mod ? mod.attr.atk : 0), `${where}: ATK`);
assert.equal(u.base.maxHp, form.stats.maxHp + (mod ? mod.attr.maxHp : 0), `${where}: HP`);
assert.deepEqual([u.profile.dmgType, u.profile.canHitFly, u.profile.onHitStatus], ['arts', true, { key: 'sluggish', duration: 0.8 }], `${where}: profile`);
assert.equal(u.rangeKeys.length, 12, `${where}: y-2`);
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
assert.deepEqual([t0.attack_speed, t0['acsupo_t_1[ally].attack_speed']], mod && mod.level === 3 ? [20, 12] : [15, 10], `${where}: 同调`);
assert.equal(u.s.aspd, 100 + t0.attack_speed, `${where}: her ASPD`);
done(h);
}
});
test('Raidian triggers: S1 / S2 MANUAL DEFAULT; S3 the data\'s ACTIVE_RANGE over its y-4 (the owner\'s rule), the kit sets none', () => {
for (const id of ['chess_char_4_13_a', 'chess_char_5_12_b']) {
for (const [i, sid] of [[0, S1], [1, S2], [2, S3]]) {
const h = arena(id, { standIn: { skillIndex: i }, sp: null });
h.step();
const u = h.unit(1);
const rule = sid === S3 ? 'ACTIVE_RANGE' : 'DEFAULT';
assert.deepEqual([u.skill.id, u.skill.rule, u.def.skill.trigger.rule, u.kit.skill.trigger], [sid, rule, rule, undefined], `${id} ${sid}`);
if (sid === S3) assert.deepEqual(u.def.skill.trigger.grid, skillOf(id, S3).rangeGrid, 'trigger grid = the skill range');
done(h);
}
}
});
test('S3 ACTIVE_RANGE: an enemy only in its y-4 range ((10,7)) casts it at once, before any attack; none there, or one beyond it, no cast', () => {
for (const id of ['chess_char_5_12_a', 'chess_char_6_15_b']) {
const h = arena(id, { enemies: [{ key: 'd', pos: [10, 7] }] });
const u = h.unit(1);
assert.ok(h.runUntil(() => u.skill.active, 2), `${id}: cast`);
assert.equal(castOf(h, u).reason, 'ACTIVE_RANGE');
assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u && c.t < castOf(h, u).t).length, 0, 'no attack before (it is out of y-2)');
assert.ok(u.rangeKeySet.has(key(10, 7)), 'then in her running range');
h.run(3);
assert.ok(h.hooksOf('attack').some((c) => c.attacker === u), 'and she attacks it');
done(h);
for (const enemies of [[], [{ key: 'd', pos: [10, 8] }]]) {
const g = arena(id, { enemies });
g.run(5);
assert.equal(g.unit(1).skill.activations, 0, `${id}: no cast with ${JSON.stringify(enemies)}`);
done(g);
}
}
});
test('S1 双声: ATK +25 / +40 %, one extra target, 24 s', () => {
for (const id of ['chess_char_4_13_a', 'chess_char_5_20_b']) {
const sk = skillOf(id, S1);
const h = arena(id, { standIn: { skillIndex: 0 }, enemies: [{ key: 'd', pos: [10, 5] }, { key: 'd', pos: [10, 6] }, { key: 'd', pos: [9, 6] }] });
const u = h.unit(1);
assert.ok(h.runUntil(() => u.skill.active, 3));
const start = castOf(h, u).t;
close(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${id}: ATK`);
h.run(6);
const atks = h.hooksOf('attack').filter((c) => c.attacker === u && c.t >= start);
assert.ok(atks.length >= 3 && atks.every((c) => c.targets.length === 2), `${id}: 2 targets`);
assert.ok(h.runUntil(() => !u.skill.active, 30));
close(h.b.time - start, sk.duration, `${id}: ${sk.duration} s`, 0.05);
done(h);
}
});
test('S2 三形: base attack time 1.9 → 1.8 / 1.7 s, 3 targets, 停顿 1.1 / 1.2 s (0.8 s before), 14 / 17 s', () => {
for (const id of ['chess_char_4_13_a', 'chess_char_4_13_b']) {
const sk = skillOf(id, S2);
const h = arena(id, { sp: null, enemies: [[10, 5], [10, 6], [9, 6], [11, 6]].map((pos) => ({ key: 'd', pos })) });
const u = h.unit(1);
h.run(2);
const slow = (t0, t1) => h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish' && c.t >= t0 && c.t < t1).map((c) => c.duration);
assert.ok(slow(0, 2).length > 0 && slow(0, 2).every((d) => d === 0.8), 'the trait: 0.8 s');
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3));
const start = castOf(h, u).t;
close(u.s.interval * u.s.aspd / 100, 1.9 + sk.bb.base_attack_time, `${id}: base attack time`);
h.run(5);
const atks = h.hooksOf('attack').filter((c) => c.attacker === u && c.t >= start);
assert.ok(atks.length >= 3 && atks.every((c) => c.targets.length === 3), `${id}: 3 targets`);
const during = slow(start, h.b.time);
assert.ok(during.length >= 9 && during.every((d) => d === sk.bb['attack@sluggish']), `${id}: 停顿 ${sk.bb['attack@sluggish']} s`);
assert.ok(h.runUntil(() => !u.skill.active, 30));
close(h.b.time - start, sk.duration, `${id}: ${sk.duration} s`, 0.05);
close(u.s.interval * u.s.aspd / 100, 1.9, 'back to 1.9 s');
done(h);
}
});
test('S3 信号跃动: y-4 while it runs, ATK +40 / +50 %, 同调 ×2 / ×2.5, enemies in range 脆弱 + 虚弱 10 / 20 % (the 虚弱 stacking as 90 / 80 %), 30 / 35 s', () => {
for (const id of ['chess_char_5_12_a', 'chess_char_6_04_b']) {
const sk = skillOf(id, S3), elite = id.endsWith('_b');
const t0 = formOf(id).talents.find((t) => t.index === 0).bb;
const self = elite ? 20 : t0.attack_speed, ally = elite ? 12 : t0['acsupo_t_1[ally].attack_speed'];
const h = arena(id, { enemies: [{ key: 'd', pos: [10, 6] }, { key: 'd', pos: [10, 9] }], units: [{ chessId: 't_tank', row: 11, col: 4 }] });
const [u, tank] = [h.unit(1), h.unit(2)];
h.step();
const [inR, outR] = [...h.b.enemies].sort((a, b) => a.x - b.x);
h.b.applyStatus(inR, 'weaken', { value: 0.3, duration: 100 }); // a stronger 虚弱 of another source
assert.ok(h.runUntil(() => u.skill.active, 2), `${id}: cast`);
const start = castOf(h, u).t;
h.run(0.5);
assert.equal(u.rangeKeys.length, 19, `${id}: y-4`);
close(u.s.atk, u.base.atk * (1 + sk.bb['acsupo_s_3.atk']), `${id}: ATK`);
close(u.s.aspd, 100 + self * sk.bb.talent_scale, `${id}: her ASPD`);
close(tank.findBuff('acsupo:t1')?.mods?.aspd ?? 0, ally * sk.bb.talent_scale, `${id}: the neighbour's ASPD`);
const fr = inR.findBuff('fragile'), wk = inR.findBuff('weaken');
close(fr?.data?.value ?? 0, sk.bb.damage_scale - 1, `${id}: 脆弱`);
close(inR.s.dmgTakenMul, sk.bb.damage_scale, `${id}: damage taken`);
assert.deepEqual([wk?.data?.value, wk?.data?.stackAs].map((v) => Math.round(v * 1e6) / 1e6), [Math.round((1 - sk.bb.atk) * 1e6) / 1e6, sk.bb.atk], `${id}: 虚弱 ${1 - sk.bb.atk}, stacking as ${sk.bb.atk}`);
close(inR.s.atk, inR.base.atk * sk.bb.atk, `${id}: its 虚弱 outranks the 30 % one (PRTS 备注)`);
assert.deepEqual([outR.findBuff('fragile') ?? null, outR.findBuff('weaken') ?? null], [null, null], `${id}: nothing out of her range`);
assert.ok(h.runUntil(() => !u.skill.active, 40));
close(h.b.time - start, sk.duration, `${id}: ${sk.duration} s`, 0.05);
h.run(0.5);
assert.deepEqual([u.rangeKeys.length, u.s.aspd, tank.findBuff('acsupo:t1')?.mods?.aspd], [12, 100 + self, ally], `${id}: back`);
assert.equal(inR.findBuff('fragile') ?? null, null, `${id}: 脆弱 gone`);
close(inR.findBuff('weaken')?.data?.value ?? 0, 0.3, `${id}: the 30 % 虚弱 resumes`);
done(h);
}
});
test('同调: ASPD +15 / +20 on herself, +10 / +12 on the operators of the 4 tiles next to her — not diagonal, not farther', () => {
for (const [id, v] of [['chess_char_5_12_a', 10], ['chess_char_6_15_b', 12]]) {
const h = arena(id, { sp: null, units: [
{ chessId: 't_tank', row: 11, col: 4 }, // adjacent
{ chessId: 'chess_char_1_03_a', row: 9, col: 5 }, // diagonal
{ chessId: 'chess_char_1_17_a', row: 10, col: 2 }, // two tiles away
] });
h.run(1);
const [adj, diag, far] = [2, 3, 4].map((uid) => h.unit(uid));
const buff = (x) => x.findBuff('acsupo:t1')?.mods?.aspd ?? 0;
assert.deepEqual([buff(adj), buff(diag), buff(far), buff(h.unit(1))], [v, 0, 0, 0], id);
done(h);
}
});
/** A blocked brute (atk 200, 1 attack/s) on t_tank at `tile`; `rng0`: every direct battle.rng() draw returns that value. */
function lureField(id, { tile = [10, 5], enemy = 'brute', rng0 = null, standIn = true } = {}) {
const h = arena(id, { sp: null, standIn, enemies: [{ key: enemy, pos: tile }], units: [{ chessId: 't_tank', row: tile[0], col: tile[1] }] });
if (rng0 != null) { const real = h.b.rng; h.b.rng = Object.assign(() => rng0, real); }
const seen = [];
h.b.on('hit', (c) => { if (c.source && c.source.side === 'enemy') seen.push(c.dmg.attackId); }); // a later hit handler
return { h, u: h.unit(1), tank: h.unit(2), seen };
}
test('诱引: an enemy attacking inside her range misses as a whole on a failed roll (no damage, no later hit handler), the attack still spent; outside her range, or true damage, never', () => {
for (const id of ['chess_char_4_13_a', 'chess_char_6_04_b']) {
const miss = lureField(id, { rng0: 0 });
miss.h.run(8);
const e = miss.h.b.enemies[0];
assert.ok(e.blockedBy === miss.tank && e.stats.attacks >= 6, `${id}: it attacks every second (${e.stats.attacks})`);
assert.equal(miss.tank.stats.taken, 0, `${id}: every attack missed`);
assert.equal(miss.seen.length, 0, 'no later hit handler saw them');
assert.ok(miss.h.eventsOf('fx').filter((f) => f[1] === 'dodge' && f[4].id === miss.tank.id).length >= 6, 'a miss shows on the target');
done(miss.h);
const land = lureField(id, { rng0: 0.99 });
land.h.run(8);
close(land.tank.stats.taken, land.h.b.enemies[0].stats.attacks * 200, `${id}: a passed roll lands`, 1e-6);
done(land.h);
}
// out of her range ((10,8)): no roll — even a draw of 0 lands
const out = lureField('chess_char_5_12_a', { tile: [10, 8], rng0: 0 });
out.h.run(5);
assert.ok(out.tank.stats.taken > 0, 'outside her range: no cut');
done(out.h);
// true damage has no 物理 / 法术 hit rate
const holy = lureField('chess_char_5_12_a', { enemy: 'holy', rng0: 0 });
holy.h.run(5);
assert.ok(holy.tank.stats.taken > 0, 'true damage: never misses');
done(holy.h);
// the real roll: about one attack in ten (seeded)
const real = lureField('chess_char_4_13_a', {});
real.h.run(150);
const n = real.h.b.enemies[0].stats.attacks, landed = Math.round(real.tank.stats.taken / 200);
assert.ok(n >= 140, `${n} attacks`);
assert.ok(n - landed >= 4 && n - landed <= 30, `${n - landed} of ${n} attacks missed (10 %)`);
done(real.h);
});
test('诱引: one roll per attack — every damage instance of a two-target attack shares it', () => {
const h = arena('chess_char_5_12_a', {
sp: null, enemies: [{ key: 'archer', pos: [10, 6] }],
units: [{ chessId: 't_tank', row: 10, col: 5 }, { chessId: 't_tank', row: 11, col: 5 }],
setup: (b) => b.on('enemySpawn', ({ enemy }) => { enemy.profile.maxTargets = 2; }),
});
const landed = new Map();
h.b.on('damaged', (c) => { if (c.source && c.source.side === 'enemy') landed.set(c.dmg.attackId, (landed.get(c.dmg.attackId) ?? 0) + 1); });
h.run(120);
h.b.applyStatus(h.b.enemies[0], 'disarm', { duration: 100, force: true }); // no new attack; the arrows in flight land
h.run(2);
const atks = h.hooksOf('attack').filter((c) => c.attacker.side === 'enemy');
assert.ok(atks.length >= 100 && atks.every((c) => c.targets.length === 2), `${atks.length} two-target attacks`);
const counts = [...landed.values()];
assert.ok(counts.every((k) => k === 2), 'a landed attack lands on both targets');
const missed = atks.length - landed.size;
assert.ok(missed >= 3 && missed <= 25, `${missed} of ${atks.length} attacks missed whole`);
done(h);
});
test('DEC-X (T5 / T6 elite): +0.2 SP/s while an enemy is in her range; none without one, nor on the normal form', () => {
for (const [id, enemy, rate] of [['chess_char_5_12_b', true, 1.2], ['chess_char_5_12_b', false, 1], ['chess_char_5_12_a', true, 1], ['chess_char_6_15_b', true, 1.2]]) {
const h = arena(id, { sp: null, enemies: enemy ? [{ key: 'd', pos: [10, 6] }] : [] });
const u = h.unit(1);
h.step();
const t0 = h.b.time, sp0 = u.skill.sp;
h.run(10);
assert.equal(u.skill.activations, 0);
close((u.skill.sp - sp0) / (h.b.time - t0), rate, `${id} ${enemy ? 'enemy in range' : 'no enemy'}`, 0.01);
done(h);
}
});
test('engine: applyStatus stackAs — a 同名效果取最高 status competing with another strength than its effect; tails keep it; without it nothing changes', () => {
const h = makeBattle({ defs: { enemies: ENEMIES }, enemies: [{ key: 'd', pos: [10, 6] }], autoFinish: false, timeLimit: 60 });
h.step();
const e = h.b.enemies[0], b = h.b;
const now = () => e.findBuff('weaken');
b.applyStatus(e, 'weaken', { value: 0.3, duration: 10 });
assert.deepEqual(now().data, { value: 0.3, tail: null }, 'the plain data shape');
b.applyStatus(e, 'weaken', { value: 0.1, stackAs: 0.9, duration: 2 });
assert.deepEqual([now().data.value, now().data.stackAs, now().mods.atkMul, now().data.tail.value, now().data.tail.stackAs], [0.1, 0.9, 0.9, 0.3, undefined], 'it outranks the 30 % (its effect stays 10 %); the 30 % waits as the tail');
b.applyStatus(e, 'weaken', { value: 0.5, duration: 1 });
assert.equal(now().data.value, 0.1, 'a 50 % one does not outrank 90 %');
h.run(2.1);
assert.deepEqual([now().data.value, now().data.stackAs], [0.3, undefined], 'the 30 % resumes after it');
h.run(8);
assert.equal(now() ?? null, null, 'all over');
b.applyStatus(e, 'weaken', { value: 0.05, stackAs: 0.8, duration: 20 });
assert.deepEqual(now().data, { value: 0.05, stackAs: 0.8, tail: null });
b.applyStatus(e, 'weaken', { value: 0.2, stackAs: 0.95, duration: 1 });
assert.deepEqual([now().data.value, now().data.tail.value, now().data.tail.stackAs], [0.2, 0.05, 0.8], 'its tail keeps stackAs');
h.run(1.1);
assert.deepEqual([now().data.value, now().data.stackAs], [0.05, 0.8], 'and resumes with it');
b.applyStatus(e, 'weaken', { value: 0.5, duration: 5 });
assert.equal(now().data.value, 0.05, 'a plain 50 % does not outrank it either');
checkInvariants(h.b);
});