diff --git a/server/sim/content/kits/README.md b/server/sim/content/kits/README.md index 3a3a220a..ee9fecf0 100644 --- a/server/sim/content/kits/README.md +++ b/server/sim/content/kits/README.md @@ -126,6 +126,9 @@ stand-in's, `def.standInFor` the replaced operator's charId. stand-ins is `tools/build-data.mjs STANDIN_TRIGGER_DEVIATIONS` (预备干员-重装, Mechanist: every skill `DEFAULT`, `rawRule` `TAKE_DAMAGE`). - **Summons**: no stand-in has one (DATA.md §18). +- **One character, one implementation**: a stand-in the mode also fields elsewhere reuses that code through a named + export, never through another registry: Touch (`standin-acmedc.js`) runs the 外勤医疗 strategy's Touch S3 and talents, + `content/tokens.js` `touchGospel` / `mapCharTalents`. - `node --test test/content/kits_layout.test.js` checks the file name, the key (a charId of data/backups.json `units`) and the registry entry. diff --git a/server/sim/content/kits/index.js b/server/sim/content/kits/index.js index 3d01f2fc..db7abd2d 100644 --- a/server/sim/content/kits/index.js +++ b/server/sim/content/kits/index.js @@ -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(['standin-acguad.js', 'standin-sharp2.js', 'standin-acspec.js']); +export const STANDIN_KIT_FILES = Object.freeze(['standin-acguad.js', 'standin-sharp2.js', 'standin-acspec.js', 'standin-acmedc.js', 'standin-acfend.js', 'standin-acpion.js']); async function loadKitFile(file) { try { diff --git a/server/sim/content/kits/ops/standin-acfend.js b/server/sim/content/kits/ops/standin-acfend.js new file mode 100644 index 00000000..6f57f56b --- /dev/null +++ b/server/sim/content/kits/ops/standin-acfend.js @@ -0,0 +1,116 @@ +// server/sim/content/kits/ops/standin-acfend.js — Mechanist (char_610_acfend) 补位 stand-in kit: 4 星熊 (S2), 5 号角 / +// 6 余 (S3 + module PRO-X on the elite). Stand-in contract and rules: ../README.md "Stand-in kits". + +import { num, talentBb, traitBb, skillRec, statBuff, toggleBuff, up } from '../shared/tier1.js'; + +const S1 = 'skchr_acfend_1', S2 = 'skchr_acfend_2', S3 = 'skchr_acfend_3'; +/** 反馈装甲 on an enemy she blocks, and its S2 version (×talent_mult): one buff per target whatever applies it (同名). */ +const FEEDBACK_KEY = 'acfend:feedback'; +const FEEDBACK_S2_KEY = 'acfend:feedback2'; +/** PRTS Mechanist(卫戍协议) 天赋备注: 精研材料's HP ratio and 反馈装甲's block are checked every 0.1 s. */ +const CHECK_INTERVAL = 0.1; +/** S3 应力倒置 "每秒…": one pulse per second of the skill, the first 1 s after the cast [ASSUMED: no pulse at the cast]. */ +const STRESS_INTERVAL = 1; + +export default { + // Mechanist (铁卫, blocks 3) — numbers from data/backups.json (normal E2 Lv1 rank 4, elite E2 Lv60 rank 7; skill_table and + // PRTS Mechanist(卫戍协议) agree): + // 精研材料: DEF +def; below hp_ratio HP another +`acfend_t_1[extra].def` (module PRO-X Lv3: 0.2) — PRTS 备注 "生命比例每0.1秒 + // 检测一次". + // 反馈装甲: enemies she blocks ASPD `attack_speed` (−7). PRTS 备注: applied every 0.1 s to the enemies she blocks; then the + // TARGET checks every 0.1 s whether anyone still blocks it — it lasts until nobody blocks it (whoever blocked it), her + // S2 starts, or her S2 ends (a buff with its own 0.1 s check here, so it outlives her knock-out the same way). + // S1 结构稳定 (40 s): max HP +max_hp, DEF +def. + // S2 不变性原理 (20 s): DEF +def, 反馈装甲 ×talent_mult (−14 / −21). PRTS 备注: at the cast the talent's effect on the + // enemies she blocks ends and the enhanced one is applied; at the end every enhanced one she applied ends (the + // 0.1 s talent check then gives the enemies she still blocks the plain one again). + // S3 应力倒置 (30 s): DEF +def, block +block_cnt, every second ATK × atk_scale arts damage to each enemy she blocks. + // Module PRO-X (elite, trait addition): "阻挡敌人时防御力+20%" — DEF +def (trait blackboard) while she blocks. + // Triggers (data, nothing set here): every skill DEFAULT with the official TAKE_DAMAGE as rawRule — the owner's 重装 + // exception for the stand-ins (tools/build-data.mjs STANDIN_TRIGGER_DEVIATIONS): she casts with an enemy in range. + char_610_acfend: (bb, chess) => { + const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1), tb = traitBb(chess); + const r1 = skillRec(chess, S1), r2 = skillRec(chess, S2), r3 = skillRec(chess, S3); + const fbAspd = num(t1.attack_speed, 0); + const fbMult = num(r2?.bb?.talent_mult, 1); + /** Her S2 runs (the enhanced 反馈装甲). */ + const s2On = (unit) => !!(unit.skill && unit.skill.active && unit.skill.id === S2); + /** A 反馈装甲 buff: it ends at the first 0.1 s check of its target that finds nobody blocking it. */ + const feedback = (battle, unit, e, key, aspd) => battle.addBuff(e, { + key, mods: { aspd }, source: unit, data: { v: aspd }, tags: ['talent'], interval: CHECK_INTERVAL, + onTick({ battle: b, unit: t, buff }) { if (!t.blockedBy) b.removeBuff(t, buff); }, + }); + /** Apply 反馈装甲 (its S2 version while S2 runs) to every enemy she blocks — the talent's 0.1 s application. */ + const applyFeedback = (battle, unit) => { + if (!fbAspd || !up(unit)) return; + const enhanced = s2On(unit); + for (const e of unit.blocking) { + if (!e.alive || e.blockedBy !== unit) continue; + if (enhanced) { + if (e.findBuff(FEEDBACK_S2_KEY)) continue; + battle.removeBuff(e, FEEDBACK_KEY); + feedback(battle, unit, e, FEEDBACK_S2_KEY, fbAspd * fbMult); + } else if (!e.findBuff(FEEDBACK_S2_KEY) && !e.findBuff(FEEDBACK_KEY)) { + feedback(battle, unit, e, FEEDBACK_KEY, fbAspd); + } + } + }; + const skills = {}; + if (r1) skills[S1] = { kind: 'duration', mods: { hpPct: num(r1.bb?.max_hp), defPct: num(r1.bb?.def) } }; + if (r2) { + skills[S2] = { + kind: 'duration', + mods: { defPct: num(r2.bb?.def) }, + onStart({ battle, unit }) { applyFeedback(battle, unit); }, + onEnd({ battle, unit }) { + for (const e of battle.enemies) { + const b = e.findBuff(FEEDBACK_S2_KEY); + if (b && b.source === unit) battle.removeBuff(e, b); + } + }, + }; + } + if (r3) { + const scale = num(r3.bb?.atk_scale, 0); + skills[S3] = { + kind: 'duration', + mods: { defPct: num(r3.bb?.def), blockCnt: num(r3.bb?.block_cnt) }, + onStart({ unit }) { unit.mem.acfendStress = 0; }, + onTick({ battle, unit, dt }) { + unit.mem.acfendStress = (unit.mem.acfendStress ?? 0) + dt; + while (unit.mem.acfendStress + 1e-6 >= STRESS_INTERVAL) { + unit.mem.acfendStress -= STRESS_INTERVAL; + const v = battle.blockedTargets(unit, { canHitFly: true }); + if (v.length) battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id, radius: 0.5, skill: S3 }); + for (const e of v) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', isSkill: true, tags: ['skill', 'stress'] }); + } + }, + onEnd({ unit }) { unit.mem.acfendStress = 0; }, + }; + } + return { + skills, + talents: [ + { install(battle, unit) { // 精研材料 + statBuff(battle, unit, 'acfend:t1', { defPct: num(t0.def) }); + const extra = num(t0['acfend_t_1[extra].def']), ratio = num(t0.hp_ratio, 0); + if (!extra || !(ratio > 0)) return; + const check = () => { + const want = up(unit) && unit.hpRatio < ratio - 1e-9; + const has = !!unit.findBuff('acfend:t1:extra'); + if (want && !has) battle.addBuff(unit, { key: 'acfend:t1:extra', mods: { defPct: extra }, tags: ['talent'] }); + else if (!want && has) battle.removeBuff(unit, 'acfend:t1:extra'); + }; + battle.every(CHECK_INTERVAL, check, { owner: unit }); + } }, + { install(battle, unit) { // 反馈装甲 + if (fbAspd) battle.every(CHECK_INTERVAL, () => applyFeedback(battle, unit), { owner: unit }); + } }, + ], + install(battle, unit) { + const md = num(tb.def, 0); // module PRO-X: 阻挡敌人时防御力+20% + if (md) toggleBuff(battle, unit, 'acfend:pro', () => unit.blocking.length > 0, { defPct: md }); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/standin-acmedc.js b/server/sim/content/kits/ops/standin-acmedc.js new file mode 100644 index 00000000..8f28985e --- /dev/null +++ b/server/sim/content/kits/ops/standin-acmedc.js @@ -0,0 +1,91 @@ +// server/sim/content/kits/ops/standin-acmedc.js — Touch (char_613_acmedc) 补位 stand-in kit: 5 塞雷娅, 6 塑心 / 纯烬艾雅法拉 +// (all S3 恳切福音 + module PHY-X on the elite). Stand-in contract and rules: ../README.md "Stand-in kits". + +import { num, traitBb, skillRec } from '../shared/tier1.js'; +import { mapCharTalents, touchGospel } from '../../tokens.js'; + +const S1 = 'skchr_acmedc_1', S2 = 'skchr_acmedc_2', S3 = 'skchr_acmedc_3'; +/** S2 宛如天启's "所有医疗干员攻击力+X%" on every medic operator of hers while the skill runs (one per target: 同名 buff). */ +const APOCALYPSE_KEY = 'acmedc:apocalypse'; + +const isMedicOp = (a) => a.kind === 'op' && a.alive && a.deployed && !a.hidden && a.def?.profession === 'MEDIC'; + +export default { + // Touch (医师) — skills from data/backups.json (normal E2 Lv1 skill rank 4, elite E2 Lv60 rank 7; skill_table and PRTS + // Touch(卫戍协议) agree): + // S1 慨赠 (20 s): ATK +atk, each heal has an `attack@prob` chance of one extra heal [ASSUMED: of the same target, the + // same amount — the text names no other target]. + // S2 宛如天启 (30 s): her range becomes the 3-10 grid (PRTS 修正: "攻击距离+1" means 攻击范围扩大), she heals + // `attack@max_target` targets, every 医疗 operator of hers ATK +`attack@atk` herself included [ASSUMED: her own + // player's operators, as 余's "全场所有干员"; summons not]. + // S3 恳切福音 (40 s): content/tokens.js touchGospel — the one implementation the 外勤医疗 strategy's Touch runs too + // (5-2 grid, ATK +atk, 2 targets, an extra heal of 30 % of the main one on the target or an orthogonal neighbour, + // ×heal_scale on allies at ≤ hp_ratio HP). + // Talents 攫升 / 超脱: content/tokens.js mapCharTalents (the strategy's Touch too); the module's 超脱 upgrade (8 SP at + // module Lv3) comes with the record's talents. + // Module PHY-X (elite, trait addition): "治疗生命值低于50%的友方单位时治疗量提升15%" — every heal of hers on an ally + // below `hp_ratio` HP (strictly: "低于") ×`heal_scale` (the trait blackboard). With S3's ×1.35 / ×1.40 it + // multiplies (×1.61 at Lv7): both are `heal_scale` blackboards, the 治疗倍率, and PRTS 游戏数据基础 §倍率 says "同种 + // 倍率间叠乘" — no Touch-specific note or buff template exists. It runs before S3's hook, so S3's 30 % extra heal is + // a share of the main heal with both bonuses and, like S3's ×1.35 / ×1.40 in touchGospel, never multiplies that + // share again [ASSUMED]. + // Triggers (data, nothing set here): S1 DEFAULT (an injured ally in her range); S2 / S3 ACTIVE_RANGE — the owner's + // rule of 2026-10-05 read for a heal skill: an injured ally inside the running 3-10 / 5-2 range casts. + char_613_acmedc: (bb, chess, def) => { + const tb = traitBb(chess); + const phyScale = num(tb.heal_scale, 1), phyRatio = num(tb.hp_ratio, 0); + const r1 = skillRec(chess, S1), r2 = skillRec(chess, S2), r3 = skillRec(chess, S3); + const gospel = r3 ? touchGospel(r3.bb ?? {}, r3) : null; + const skills = {}; + if (r1) { + const b1 = r1.bb ?? {}; + const p = num(b1['attack@prob'], 0); + skills[S1] = { + kind: 'duration', heal: true, + mods: { atkPct: num(b1.atk) }, + onHit({ battle, unit, target, heal }) { + if (!(p > 0) || !target || !(heal > 0) || !battle.rng.chance(p)) return; + battle.heal(unit, target, heal); + battle.fx('heal', { x: target.x, y: target.y, id: unit.id }); + }, + }; + } + if (r2) { + const b2 = r2.bb ?? {}; + const v = num(b2['attack@atk'], 0); + const grant = (battle, unit) => { + for (const a of battle.alliesFor(unit, unit.ownerId)) { + if (!isMedicOp(a)) continue; + const cur = a.findBuff(APOCALYPSE_KEY); + if (cur && (cur.source === unit || (cur.data?.v ?? 0) >= v)) continue; // another Touch's equal or stronger one + battle.addBuff(a, { key: APOCALYPSE_KEY, mods: { atkPct: v }, source: unit, data: { v }, tags: ['skill'] }); + } + }; + skills[S2] = { + kind: 'duration', heal: true, + targeting: { rangeGrid: r2.rangeGrid, maxTargets: Math.max(1, Math.floor(num(b2['attack@max_target'], 2))) }, + onStart({ battle, unit }) { if (v) grant(battle, unit); }, + // medics deployed (or redeployed) while it runs get it too + onTick({ battle, unit }) { if (v) grant(battle, unit); }, + onEnd({ battle, unit }) { + for (const a of battle.allyUnits) if (a.findBuff(APOCALYPSE_KEY)?.source === unit) battle.removeBuff(a, APOCALYPSE_KEY); + }, + }; + } + if (gospel) skills[S3] = gospel.skill; + return { + skills, + talents: mapCharTalents(def), + install(battle, unit) { + if (phyScale !== 1 && phyRatio > 0) { + battle.on('heal', (ctx) => { + // (S3's extra heal is a share of the main heal, which already carries it: touchGospel's `touchExtra`) + if (ctx.source !== unit || !ctx.target || ctx.opts?.regen || unit.mem.touchExtra) return; + if (ctx.target.hpRatio < phyRatio - 1e-9) ctx.amount *= phyScale; + }, { owner: unit, priority: 10 }); + } + if (gospel && def?.skill?.id === S3) gospel.install(battle, unit); + }, + }; + }, +}; diff --git a/server/sim/content/kits/ops/standin-acpion.js b/server/sim/content/kits/ops/standin-acpion.js new file mode 100644 index 00000000..9bac5feb --- /dev/null +++ b/server/sim/content/kits/ops/standin-acpion.js @@ -0,0 +1,144 @@ +// server/sim/content/kits/ops/standin-acpion.js — 郁金香 (char_608_acpion) 补位 stand-in kit: 3 瑕光 (S3), 4 焰尾 (S2), +// 5 凛御银灰 / 6 圣聆初雪 / 缪尔赛思 / 耀骑士临光 (S3 + module SOL-X on the elite). Stand-in contract: ../README.md. + +import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js'; +import { num, talentBb, traitBb, skillRec, statBuff, toggleBuff, up } from '../shared/tier1.js'; + +const S1 = 'skchr_acpion_1', S2 = 'skchr_acpion_2', S3 = 'skchr_acpion_3'; +/** + * S3 只余芬芳's slashes: her Spine `Skill` clip (char_608_acpion.skel, 1.567 s) carries 8 OnAttack events — one per + * slash, `times` 8 — at 0.400 s and then every 1/6 s (0.567 … 1.567 s); the `Skill_End` clip adds 0.233 s. The skill + * holds its effects (below) from the cast to the end of `Skill_End` [ASSUMED: the clips play at 1×, the effects last the + * two clips]. + */ +const SLASH_FIRST = 0.4; +const SLASH_GAP = 1 / 6; +const SLASH_END_CLIP = 0.233; +/** S3's buff while it plays: 无敌, 无法阻挡, no normal attack (the skill clip plays) — stun / freeze refused by a hook. */ +const FRAGRANCE_KEY = 'acpion:fragrance'; + +/** Enemies S3 slashes: every selectable enemy on the x-1 skill range, air units included (PRTS 备注 "※可对空"). */ +const SLASH_PROFILE = Object.freeze({ canHitFly: true }); + +export default { + // 郁金香 (尖兵, blocks 2) — numbers from data/backups.json (normal E2 Lv1 rank 4, elite E2 Lv60 rank 7; skill_table and + // PRTS 郁金香(卫戍协议) agree). DP (部署费用) is the player's redeploy currency here (DESIGN §5.5 "DP"). + // 无垠之心: from each deployment until her 2nd skill activation of it, SP recovery +sp_recovery_per_sec (module SOL-X Lv3: + // +1/s) — `cnt` 2. + // 浪潮之心: ATK +atk; each enemy she kills +cost DP. + // S1 钻心 (instant): +cost DP, then ATK × atk_scale physical damage to up to max_target enemies of her attack range + // (the enemies she blocks included, ground only like her attack) [ASSUMED: at the cast — her S1 clip `Skill_2` + // carries no hit event]. + // S2 迅瞬 (10 s): ASPD +attack_speed, ignores def_penetrate of the target's DEF, and "逐渐获得" trig_cnt × cost DP: one + // every `interval` s from the cast (6 × 1.6 s / 7 × 1.4 s, all inside the 10 s) [ASSUMED: none at the cast, and the + // DP not yet given is lost if the skill ends early]. + // S3 只余芬芳 (instant): +cost DP, then `times` slashes on the enemies around her (the x-1 skill range), each ATK × + // atk_scale physical ignoring def_penetrate (50 %) of DEF, timed by her Spine clip (SLASH_*). PRTS 郁金香 (the 集成战略 + // character char_513_apionr, whose S1 只余芬芳 has the same text, blackboard and x-1 range) 备注: "※可对空" and + // "※技能生效期间,持有效果:无敌,无法阻挡,眩晕免疫,冻结免疫" [ASSUMED: the 卫戍 copy skchr_acpion_3 behaves the same]. + // Module SOL-X (elite, trait override): "阻挡敌人时攻击力和防御力各+8%" — while she blocks (none during S3: 无法阻挡). + // Triggers (data, nothing set here): S1 / S2 DEFAULT; S3 SKILL_RANGE on its x-1 (any enemy in it, air units too). + char_608_acpion: (bb, chess) => { + const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1), tb = traitBb(chess); + const r1 = skillRec(chess, S1), r2 = skillRec(chess, S2), r3 = skillRec(chess, S3); + const dp = (battle, unit, n) => { + if (!(n > 0)) return; + battle.addDp(unit.ownerId, n); + battle.fx('dp', { x: unit.x, y: unit.y, id: unit.id, n }); + }; + const skills = {}; + if (r1) { + const b1 = r1.bb ?? {}; + skills[S1] = { + kind: 'instant', + onStart({ battle, unit }) { + dp(battle, unit, num(b1.cost)); + const prof = unit.profile; + const foes = battle.enemiesInKeys(unit.rangeKeys || [], unit, prof); + for (const e of battle.blockedTargets(unit, prof)) if (!foes.includes(e)) foes.push(e); + sortEnemyTargets(battle, unit, foes, prof?.priority ?? null); + const v = foes.slice(0, Math.max(1, Math.floor(num(b1.max_target, 2)))); + if (v.length) battle.fx('slash', { x: unit.x, y: unit.y, id: unit.id, n: v.length }); + for (const e of v) battle.dealDamage(unit, e, { amount: unit.s.atk * num(b1.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] }); + }, + }; + } + if (r2) { + const b2 = r2.bb ?? {}; + const total = Math.max(0, Math.floor(num(b2.trig_cnt, 0))), iv = Math.max(0.1, num(b2.interval, 1)), per = num(b2.cost, 1); + skills[S2] = { + kind: 'duration', + mods: { aspd: num(b2.attack_speed), defIgnorePct: num(b2.def_penetrate) }, + onStart({ unit }) { unit.mem.acpionDp = { acc: 0, n: 0 }; }, + onTick({ battle, unit, dt }) { + const m = unit.mem.acpionDp; + if (!m) return; + m.acc += dt; + while (m.acc + 1e-9 >= iv && m.n < total) { m.acc -= iv; m.n++; dp(battle, unit, per); } + }, + onEnd({ unit }) { unit.mem.acpionDp = null; }, + }; + } + if (r3) { + const b3 = r3.bb ?? {}; + const times = Math.max(1, Math.floor(num(b3.times, 8))), scale = num(b3.atk_scale, 1), pen = num(b3.def_penetrate, 0); + const grid = r3.rangeGrid?.length ? r3.rangeGrid : null; + const lastAt = SLASH_FIRST + (times - 1) * SLASH_GAP; + const slash = (battle, unit) => { + const keys = grid ? absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0) : unit.rangeKeys || []; + const v = battle.enemiesInKeys(keys, unit, SLASH_PROFILE); + if (v.length) battle.fx('slash', { x: unit.x, y: unit.y, id: unit.id, n: v.length }); + for (const e of v) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', defIgnorePct: pen, isSkill: true, tags: ['skill', 'slash'] }); + }; + skills[S3] = { + kind: 'instant', + onStart({ battle, unit }) { + dp(battle, unit, num(b3.cost)); + const seq = unit.deploySeq; + battle.addBuff(unit, { + key: FRAGRANCE_KEY, duration: lastAt + SLASH_END_CLIP, + flags: { invulnerable: true, noBlock: true, disarm: true }, visible: true, source: unit, tags: ['skill'], + }); + battle.releaseBlocked(unit); + for (let i = 0; i < times; i++) { + battle.after(SLASH_FIRST + i * SLASH_GAP, () => { + if (up(unit) && unit.deploySeq === seq) slash(battle, unit); + }, { owner: unit }); + } + }, + }; + } + return { + skills, + talents: [ + { install(battle, unit) { // 无垠之心 + const per = num(t0.sp_recovery_per_sec), cnt = Math.max(1, Math.floor(num(t0.cnt, 2))); + if (!(per > 0)) return; + battle.on('deploy', ({ unit: u }) => { + if (u !== unit) return; + unit.mem.acpionCasts = 0; + battle.addBuff(unit, { key: 'acpion:boundless', mods: { spRecoveryFlat: per }, tags: ['talent'] }); + }, { owner: unit }); + battle.on('skillStart', ({ unit: u }) => { + if (u !== unit) return; + unit.mem.acpionCasts = (unit.mem.acpionCasts ?? 0) + 1; + if (unit.mem.acpionCasts >= cnt) battle.removeBuff(unit, 'acpion:boundless'); + }, { owner: unit }); + } }, + { install(battle, unit) { // 浪潮之心 + statBuff(battle, unit, 'acpion:tide', { atkPct: num(t1.atk) }); + const n = num(t1.cost); + if (n > 0) battle.on('kill', ({ killer, victim }) => { if (killer === unit && victim?.side === 'enemy') dp(battle, unit, n); }, { owner: unit }); + } }, + ], + install(battle, unit) { + const ma = num(tb.atk, 0), md = num(tb.def, 0); // module SOL-X: 阻挡敌人时攻击力和防御力各+8% + if (ma || md) toggleBuff(battle, unit, 'acpion:sol', () => unit.blocking.length > 0, { atkPct: ma, defPct: md }); + // S3: 眩晕免疫, 冻结免疫 while its effects last + if (r3) battle.on('beforeStatus', (c) => { + if (c.target === unit && (c.status === 'stun' || c.status === 'freeze') && unit.findBuff(FRAGRANCE_KEY)) c.cancel = true; + }, { owner: unit }); + }, + }; + }, +}; diff --git a/server/sim/content/tokens.js b/server/sim/content/tokens.js index 5e0b56fc..047eca6b 100644 --- a/server/sim/content/tokens.js +++ b/server/sim/content/tokens.js @@ -74,7 +74,8 @@ // makes the token (Battle.spawnToken also refuses summons the owner's loadout does not produce). // // Exports for other content: spawnYanyou, spawnMapChar, findSummonTile, summonToken, tacticalPoint, wolfShadows, -// releaseSkillSummon, SKILL_SUMMON_START_DEPLOY, CAT_SHIELD_KEY, TOKEN_IDS. +// releaseSkillSummon, SKILL_SUMMON_START_DEPLOY, CAT_SHIELD_KEY, TOKEN_IDS; mapCharTalents and touchGospel (Touch's 攫升 / +// 超脱 and 恳切福音, shared with the Touch 补位 stand-in kit, kits/ops/standin-acmedc.js). import { COLS, ROWS, MOVE_SCALE } from '../constants.js'; import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy } from '../targeting.js'; @@ -1228,12 +1229,13 @@ export function spawnYanyou(battle, playerId, { atk, hp, atkMul = 1, dmgTakenMul // band map characters (预备干员-医疗 / Touch) /** - * Talents of the band map characters (data talents of the character record): + * Talents of the band map characters (data talents of the character record) — and of the Touch 补位 stand-in, whose kit + * (kits/ops/standin-acmedc.js) uses this one implementation, the module's 超脱 upgrade (8 SP) coming with the record: * plain stat talent "攻击力+4%" (预备干员-医疗 攻击提升) → persistent ATK/DEF/HP/ASPD buff * 攫升 "治疗目标时使其获得3点技力" (Touch) → every heal by the character gives the healed unit `sp` SP * 超脱 "攻击范围内的友方干员被击倒时获得5点技力" (Touch) → an allied operator knocked out on a tile of its range: +`sp` SP */ -function mapCharTalents(def) { +export function mapCharTalents(def) { const out = []; for (const t of def?.talents ?? []) { const bb = t?.bb ?? {}; @@ -1278,18 +1280,21 @@ function reserveMedicKit(bb, raw, def) { return { ...k, talents: [...(k.talents ?? []), ...mapCharTalents(def)] }; } -/** Touch 恳切福音: +ATK, 2 heal targets in the skill range, ×heal_scale on allies ≤ hp_ratio, +addition heal. */ -function touchKit(bb, raw, def) { - const sk = def?.skill; - if (!sk) return { skill: null, talents: mapCharTalents(def) }; +/** + * Touch 恳切福音 (skchr_acmedc_3): +ATK, 2 heal targets in the skill range, ×heal_scale on allies ≤ hp_ratio, +addition + * heal — one implementation for the 外勤医疗 map character (touchKit) and the Touch 补位 stand-in (kits/ops/ + * standin-acmedc.js). `skill` = the skill record (`rangeGrid`, `duration`) of blackboard `bb`. Returns `{ skill: + * SkillSpec, install(battle, unit) }`: `install` adds the heal boost, which acts while the unit's skill runs — install it + * on a unit whose skill is this one only. + */ +export function touchGospel(bb, skill) { const hpRatio = num(bb.hp_ratio, 0), boost = num(bb.heal_scale, 1), extra = num(bb['attack@addition_heal_scale'], 0); const targeting = {}; if (num(bb['attack@max_target'], 0) > 1) targeting.maxTargets = Math.floor(num(bb['attack@max_target'], 1)); - if (sk.rangeGrid && sk.rangeGrid.length) targeting.rangeGrid = sk.rangeGrid; + if (skill.rangeGrid && skill.rangeGrid.length) targeting.rangeGrid = skill.rangeGrid; return { - talents: mapCharTalents(def), skill: { - kind: sk.duration > 0 ? 'duration' : 'instant', heal: true, + kind: skill.duration > 0 ? 'duration' : 'instant', heal: true, mods: num(bb.atk, 0) ? { atkPct: num(bb.atk, 0) } : undefined, targeting, onHit({ battle, unit, target, heal }) { @@ -1321,6 +1326,14 @@ function touchKit(bb, raw, def) { }; } +/** Touch (外勤医疗 map character): 恳切福音 (touchGospel) + 攫升 / 超脱 (mapCharTalents). */ +function touchKit(bb, raw, def) { + const sk = def?.skill; + if (!sk) return { skill: null, talents: mapCharTalents(def) }; + const g = touchGospel(bb, sk); + return { talents: mapCharTalents(def), skill: g.skill, install: g.install }; +} + /** * Spawn a band map character (char_605_cmedic 预备干员-医疗 / char_613_acmedc Touch) at its stage position on the * player's half (data/stages.json `mapChars`; `_multi_only` positions only with a partner). Returns the unit or null. diff --git a/test/content/standin_acfend.test.js b/test/content/standin_acfend.test.js new file mode 100644 index 00000000..7e68eb08 --- /dev/null +++ b/test/content/standin_acfend.test.js @@ -0,0 +1,205 @@ +// test/content/standin_acfend.test.js — the Mechanist (char_610_acfend) 补位 stand-in kit (kits/ops/standin-acfend.js) on +// every chess she replaces: 4 星熊 (S2), 5 号角 / 6 余 (S3, module PRO-X on the elite); S1 through the harness +// `standIn: { skillIndex: 0 }`. Numbers: data/backups.json (skill_table, PRTS Mechanist(卫戍协议) and its 备注: the 0.1 s +// checks of both talents, S2's swap of the talent effect). Her triggers: the owner's 重装 exception for the stand-ins. +// Run: node --test test/content/standin_acfend.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 { TICK } 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 CHAR = 'char_610_acfend'; +const S1 = 'skchr_acfend_1', S2 = 'skchr_acfend_2'; +const RECORDS = BACKUPS.units[CHAR].standsIn.flatMap((a) => [a, a.replace(/_a$/, '_b')]); +const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, ...o }); +const DEFS = { + enemies: { + enemy_dummy: dummy(), + enemy_res: dummy({ key: 'enemy_res', res: 20 }), + enemy_fly: dummy({ key: 'enemy_fly', motion: 'FLY' }), + }, +}; +const close = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`); +const formOf = (id) => unitForm(BACKUPS, CHAR, CHESS[id].status); +const skillOf = (id, skillId) => formOf(id).skills.find((s) => s.skillId === skillId); +const moduleOf = (id) => (CHESS[id].backup.uniEquipId && CHESS[id].status.equipLevel > 0 ? formOf(id).modules.find((m) => m.uniEquipId === CHESS[id].backup.uniEquipId) : null); + +function battle(opts) { + const log = []; + const h = makeBattle({ + defs: DEFS, timeLimit: 300, autoFinish: false, seed: 5, flags: { dpPerSec: 0 }, ...opts, + setup(b) { + b.on('damaged', (c) => log.push({ t: b.time, src: c.source?.id ?? null, tgt: c.target.id, key: c.target.defId, amount: c.amount, type: c.type, tags: c.dmg?.tags ?? [], skill: !!c.dmg?.isSkill }), { priority: -999 }); + if (opts.setup) opts.setup(b); + }, + }); + h.dmg = log; + return h; +} +/** DEF % of her talent and module at this moment (精研材料 +10 %, its extra below half HP, PRO-X while blocking). */ +const passiveDefPct = (id, u) => { + const t0 = u.def.talents[0].bb; + const mod = moduleOf(id); + return t0.def + (u.hpRatio < t0.hp_ratio ? t0['acfend_t_1[extra].def'] : 0) + (mod && u.blocking.length ? mod.traitOverride.bb.def : 0); +}; + +// --------------------------------------------------------------------------------------------------------------- + +test('Mechanist fields on all 6 records she replaces: her body (+ PRO-X attributes on the 5 / 6 elites), the chess\'s backup skill, her own kit, the 重装 trigger', () => { + assert.equal(RECORDS.length, 6); + for (const id of RECORDS) { + const c = CHESS[id]; + const form = formOf(id); + const sk = form.skills.find((s) => s.index === c.backup.skillIndex); + const mod = moduleOf(id); + const h = battle({ units: [{ chessId: id, row: 10, col: 4, standIn: true }] }); + h.step(); + const u = h.unit(1); + assert.deepEqual([u.def.charId, u.def.standInFor, u.skill.id], [CHAR, c.charId, sk.skillId], id); + assert.ok(!u.kit.generic && u.kit.skillSource === 'skills', `${id}: her kit's own spec`); + assert.equal(!!u.def.raw.module?.active, !!mod, `${id}: module`); + assert.deepEqual([u.base.def, u.base.maxHp, u.base.blockCnt], [form.stats.def + (mod?.attr.def ?? 0), form.stats.maxHp + (mod?.attr.maxHp ?? 0), 3], `${id}: stats`); + // STANDIN_TRIGGER_DEVIATIONS: DEFAULT (the official TAKE_DAMAGE kept as rawRule) + assert.deepEqual([u.skill.rule, sk.trigger.rawRule], ['DEFAULT', 'TAKE_DAMAGE'], `${id}: trigger`); + assert.equal(u.profile.canHitFly, false, `${id}: ground-only`); + assert.equal(h.b.errors.length, 0); + } + assert.deepEqual([CHESS.chess_char_4_17_a, CHESS.chess_char_5_08_a, CHESS.chess_char_6_03_a].map((c) => c.backup.skillIndex), [1, 2, 2]); +}); + +test('her skills cast with an enemy she blocks before anything hurts her (the owner\'s 重装 exception) — never on an air unit she cannot reach', () => { + for (const id of ['chess_char_4_17_a', 'chess_char_5_08_b', 'chess_char_6_03_a']) { + const h = battle({ units: [{ chessId: id, row: 10, col: 4, standIn: true, carryState: { sp: 999 } }], enemies: [{ key: 'enemy_fly', pos: [10, 4] }] }); + const u = h.unit(1); + h.run(2); + assert.equal(u.skill.activations, 0, `${id}: an air unit on her tile is no target`); + h.spawn('enemy_dummy', { pos: [10, 4] }); // ATK 0: it never damages her + assert.ok(h.runUntil(() => u.skill.activations === 1, 3), `${id}: cast`); + assert.equal(h.dmg.filter((d) => d.tgt === u.id).length, 0, `${id}: untouched`); + checkInvariants(h.b); + } +}); + +test('精研材料: DEF +10 %; below 50 % HP another +10 % (+20 % with PRO-X Lv3), checked every 0.1 s', () => { + for (const [id, extra] of [['chess_char_4_17_a', 0.1], ['chess_char_5_08_b', 0.1], ['chess_char_6_03_b', 0.2]]) { + const h = battle({ units: [{ chessId: id, row: 10, col: 4, standIn: true }] }); + const u = h.unit(1); + h.step(); + assert.equal(u.def.talents[0].bb['acfend_t_1[extra].def'], extra, id); + close(u.s.def, u.base.def * 1.1, `${id}: +10 %`); + h.b.loseHp(u, u.hp * 0.55); + h.run(0.1 + TICK); + close(u.s.def, u.base.def * (1.1 + extra), `${id}: below half HP`); + h.b.heal(u, u, u.s.maxHp); + h.run(0.1 + TICK); + close(u.s.def, u.base.def * 1.1, `${id}: healed`); + checkInvariants(h.b); + } +}); + +test('反馈装甲: enemies she blocks ASPD −7 within 0.1 s; it ends once nobody blocks them — at her retreat too', () => { + const h = battle({ units: [{ chessId: 'chess_char_4_17_a', row: 10, col: 4, standIn: true }], enemies: [{ key: 'enemy_dummy', pos: [10, 4] }, { key: 'enemy_dummy', pos: [10, 4] }, { key: 'enemy_fly', pos: [10, 5] }] }); + const u = h.unit(1); + h.run(0.1 + TICK); + const [a, b, f] = h.b.enemies; + assert.deepEqual([a.s.aspd, b.s.aspd, f.s.aspd], [93, 93, 100], 'the two blocked enemies, not the air unit'); + assert.equal(a.findBuff('acfend:feedback').source, u); + h.b.retreat(u); + h.run(0.1 + TICK); + assert.deepEqual([a.s.aspd, b.s.aspd], [100, 100], 'released: gone at the next check'); + checkInvariants(h.b); +}); + +test('S1 结构稳定: 40 s of max HP / DEF +26 % (+40 % elite)', () => { + for (const [id, v] of [['chess_char_4_17_a', 0.26], ['chess_char_4_17_b', 0.4]]) { + assert.deepEqual([skillOf(id, S1).bb.max_hp, skillOf(id, S1).bb.def, skillOf(id, S1).duration], [v, v, 40]); + const h = battle({ units: [{ chessId: id, row: 10, col: 4, standIn: { skillIndex: 0 }, carryState: { sp: 999 } }], enemies: [{ key: 'enemy_dummy', pos: [10, 4] }] }); + const u = h.unit(1); + let cast = null; + h.b.on('skillStart', (c) => { if (c.unit === u) cast = h.b.time; }); + assert.ok(h.runUntil(() => cast != null, 3)); + h.step(); + close(u.s.maxHp, u.base.maxHp * (1 + v), `${id}: max HP`); + close(u.s.def, u.base.def * (1 + v + passiveDefPct(id, u)), `${id}: DEF`); + h.runUntil(() => !u.skill.active, 45); + close(h.b.time - cast, 40, `${id}: 40 s`, 0.01); + close(u.s.maxHp, u.base.maxHp, `${id}: max HP back`); + checkInvariants(h.b); + } +}); + +test('S2 不变性原理 (星熊\'s chess): 20 s of DEF +50 % / +65 %; 反馈装甲 ×2 / ×3 on the enemies she blocks, back to −7 after', () => { + for (const [id, v, mult] of [['chess_char_4_17_a', 0.5, 2], ['chess_char_4_17_b', 0.65, 3]]) { + const h = battle({ units: [{ chessId: id, row: 10, col: 4, standIn: true }], enemies: [{ key: 'enemy_dummy', pos: [10, 4] }, { key: 'enemy_dummy', pos: [10, 4] }] }); + const u = h.unit(1); + h.run(0.2); + assert.deepEqual(h.b.enemies.map((e) => e.s.aspd), [93, 93]); + let cast = null; + h.b.on('skillStart', (c) => { if (c.unit === u) cast = h.b.time; }); + assert.ok(h.runUntil(() => cast != null, 60)); + h.step(); + assert.equal(u.skill.id, S2); + close(u.s.def, u.base.def * (1 + v + passiveDefPct(id, u)), `${id}: DEF`); + assert.deepEqual(h.b.enemies.map((e) => e.s.aspd), [100 - 7 * mult, 100 - 7 * mult], `${id}: ×${mult} at the cast`); + assert.ok(h.b.enemies.every((e) => !e.findBuff('acfend:feedback') && e.findBuff('acfend:feedback2')), `${id}: the plain one replaced`); + h.runUntil(() => !u.skill.active, 25); + close(h.b.time - cast, 20, `${id}: 20 s`, 0.01); + h.run(0.1 + TICK); + assert.deepEqual(h.b.enemies.map((e) => e.s.aspd), [93, 93], `${id}: plain again`); + checkInvariants(h.b); + } +}); + +test('S3 应力倒置 (号角 / 余\'s chess): 30 s of DEF +50 % / +70 %, block +1, ATK × 40 % / 55 % arts to each enemy she blocks every second', () => { + for (const [id, v, scale] of [['chess_char_5_08_a', 0.5, 0.4], ['chess_char_6_03_b', 0.7, 0.55]]) { + const h = battle({ + units: [{ chessId: id, row: 10, col: 4, standIn: true, carryState: { sp: 999 } }], + enemies: [{ key: 'enemy_dummy', pos: [10, 4] }, { key: 'enemy_res', pos: [10, 4] }, { key: 'enemy_dummy', pos: [10, 4] }, { key: 'enemy_dummy', pos: [10, 4] }, { key: 'enemy_dummy', pos: [10, 4] }], + }); + const u = h.unit(1); + let cast = null; + h.b.on('skillStart', (c) => { if (c.unit === u) cast = h.b.time; }); + h.step(); + assert.equal(u.blocking.length, 3, 'blocks 3'); + assert.ok(h.runUntil(() => cast != null, 3)); + h.run(0.5); + assert.equal(u.s.blockCnt, 4); + assert.equal(u.blocking.length, 4, `${id}: blocks a 4th`); + close(u.s.def, u.base.def * (1 + v + passiveDefPct(id, u)), `${id}: DEF`); + h.runUntil(() => !u.skill.active, 35); + close(h.b.time - cast, 30, `${id}: 30 s`, 0.01); + const pulses = h.dmg.filter((d) => d.src === u.id && d.tags.includes('stress')); + const times = [...new Set(pulses.map((d) => Math.round((d.t - cast) * 1000) / 1000))]; + assert.equal(times.length, 30, `${id}: 30 pulses`); + times.forEach((t, i) => assert.ok(Math.abs(t - (i + 1)) <= TICK + 1e-6, `${id}: pulse ${i + 1} at ${t}`)); + assert.ok(pulses.every((d) => d.type === 'arts' && d.skill)); + const first = pulses.filter((d) => d.t === pulses[0].t); + assert.equal(first.length, 4, `${id}: each blocked enemy`); + for (const d of first) close(d.amount, u.s.atk * scale * (d.key === 'enemy_res' ? 0.8 : 1), `${id}: ${d.key}`); + h.run(0.2); + assert.equal(u.s.blockCnt, 3); + checkInvariants(h.b); + } +}); + +test('module PRO-X (5 号角 / 6 余 elites): DEF +20 % exactly while she blocks; none without it', () => { + for (const id of ['chess_char_4_17_b', 'chess_char_5_08_a', 'chess_char_5_08_b', 'chess_char_6_03_b']) { + const mod = moduleOf(id); + const h = battle({ units: [{ chessId: id, row: 10, col: 4, standIn: true }] }); + const u = h.unit(1); + h.step(); + close(u.s.def, u.base.def * 1.1, `${id}: not blocking`); + h.spawn('enemy_dummy', { pos: [10, 4] }); + h.run(0.2); + assert.equal(u.blocking.length, 1); + close(u.s.def, u.base.def * (1.1 + (mod ? 0.2 : 0)), `${id}: blocking`); + assert.equal(!!mod, id === 'chess_char_5_08_b' || id === 'chess_char_6_03_b', id); + checkInvariants(h.b); + } +}); diff --git a/test/content/standin_acmedc.test.js b/test/content/standin_acmedc.test.js new file mode 100644 index 00000000..a359c3a2 --- /dev/null +++ b/test/content/standin_acmedc.test.js @@ -0,0 +1,223 @@ +// test/content/standin_acmedc.test.js — the Touch (char_613_acmedc) 补位 stand-in kit (kits/ops/standin-acmedc.js) on every +// chess she replaces: 5 塞雷娅, 6 塑心 / 纯烬艾雅法拉 (all S3, module PHY-X on the elite); S1 / S2 through the harness +// `standIn: { skillIndex }`. S3 and the talents are content/tokens.js touchGospel / mapCharTalents, the implementation the +// 外勤医疗 strategy's Touch runs (test/content/tokens_devices.test.js). Triggers: S1 DEFAULT, S2 / S3 ACTIVE_RANGE (the +// owner's rule of 2026-10-05, a heal skill: an injured ally inside the running range). +// Run: node --test test/content/standin_acmedc.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js'; +import { unitForm } from '../../shared/standIn.js'; +import { touchGospel } from '../../server/sim/content/tokens.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 CHAR = 'char_613_acmedc'; +const S1 = 'skchr_acmedc_1', S2 = 'skchr_acmedc_2', S3 = 'skchr_acmedc_3'; +const RECORDS = BACKUPS.units[CHAR].standsIn.flatMap((a) => [a, a.replace(/_a$/, '_b')]); +const DEFS = { + chess: { + test_tank_a: chessRec({ id: 'test_tank_a', profession: 'TANK', stats: { maxHp: 100000, atk: 1, def: 0 }, skill: null }), + test_tank2_a: chessRec({ id: 'test_tank2_a', profession: 'TANK', stats: { maxHp: 100000, atk: 1, def: 0 }, skill: null }), + // SP only from 攫升: an attack-SP skill and no enemy to attack + test_sp_a: chessRec({ id: 'test_sp_a', profession: 'TANK', stats: { maxHp: 100000, atk: 1, def: 0 }, skill: { spType: 'INCREASE_WHEN_ATTACK', spCost: 1000, initSp: 0 } }), + test_medic_a: chessRec({ id: 'test_medic_a', profession: 'MEDIC', subProfessionId: 'physician', stats: { atk: 300 }, skill: null, rangeGrid: [[0, 0]] }), + }, +}; +const close = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`); +const formOf = (id) => unitForm(BACKUPS, CHAR, CHESS[id].status); +const skillOf = (id, skillId) => formOf(id).skills.find((s) => s.skillId === skillId); +const moduleOf = (id) => (CHESS[id].backup.uniEquipId && CHESS[id].status.equipLevel > 0 ? formOf(id).modules.find((m) => m.uniEquipId === CHESS[id].backup.uniEquipId) : null); + +/** + * Touch (unit 1) on (10,3) facing right — her 3-3 covers columns 3–6, S2's 3-10 columns 3–7, S3's 5-2 columns 3–8 — and + * `allies` [[chessId, row, col, hpRatio]] (injured at once). h.heals: { t, tgt, uid, amount (final, before the HP cap), ratio }. + */ +function battle(touch, allies, opts = {}) { + const heals = []; + const h = makeBattle({ + defs: DEFS, timeLimit: 300, autoFinish: false, seed: opts.seed ?? 5, flags: { dpPerSec: 0 }, + units: [{ chessId: touch.id, row: 10, col: 3, standIn: touch.standIn ?? true, ...(touch.carry ? { carryState: touch.carry } : null) }, + ...allies.map(([chessId, row, col]) => ({ chessId, row, col }))], + setup(b) { + b.on('heal', (c) => { if (c.source?.uid === 1) heals.push({ t: b.time, tgt: c.target.id, uid: c.target.uid, amount: c.amount, ratio: c.target.hpRatio }); }, { priority: -999 }); + }, + }); + h.heals = heals; + h.step(); + allies.forEach(([, , , ratio], i) => { const a = h.unit(i + 2); if (ratio < 1) h.b.loseHp(a, a.hp * (1 - ratio)); }); + return h; +} + +// --------------------------------------------------------------------------------------------------------------- + +test('Touch fields on all 6 records she replaces: her body (+ PHY-X attributes on the elites), S3 恳切福音 everywhere, her own kit, ACTIVE_RANGE on the 5-2', () => { + assert.equal(RECORDS.length, 6); + for (const id of RECORDS) { + const c = CHESS[id]; + const form = formOf(id); + const mod = moduleOf(id); + const h = battle({ id }, []); + const u = h.unit(1); + assert.deepEqual([u.def.charId, u.def.standInFor, u.skill.id, c.backup.skillIndex], [CHAR, c.charId, S3, 2], id); + assert.ok(!u.kit.generic && u.kit.skillSource === 'skills', `${id}: her kit's own spec`); + assert.equal(!!u.def.raw.module?.active, !!mod, `${id}: module`); + assert.equal(!!mod, c.isGolden, `${id}: PHY-X on every elite`); + assert.deepEqual([u.base.atk, u.base.maxHp], [form.stats.atk + (mod?.attr.atk ?? 0), form.stats.maxHp + (mod?.attr.maxHp ?? 0)], `${id}: stats`); + assert.equal(u.skill.rule, 'ACTIVE_RANGE', `${id}: trigger`); + assert.equal(u.skill.triggerGrid.length, 18, `${id}: the 5-2 running range`); + assert.equal(u.profile.dmgType, 'heal'); + // S3 is the strategy's touchGospel spec + const g = touchGospel(skillOf(id, S3).bb, skillOf(id, S3)); + const k = u.kit.skill; + assert.deepEqual([k.kind, k.heal, k.mods, k.targeting], [g.skill.kind, g.skill.heal, g.skill.mods, g.skill.targeting], `${id}: touchGospel`); + assert.equal(h.b.errors.length, 0); + } +}); + +test('S3 恳切福音: cast with an injured ally inside the 5-2 only (no heal before); 40 s on the 5-2, 2 targets, ×1.35 / ×1.40 below half HP, +30 % extra heal', () => { + for (const [id, atk, boost] of [['chess_char_5_11_a', 0.2, 1.35], ['chess_char_6_20_a', 0.2, 1.35], ['chess_char_6_09_b', 0.3, 1.4]]) { + const phy = moduleOf(id) ? 1.15 : 1; + const sk = skillOf(id, S3); + assert.deepEqual([sk.bb.atk, sk.bb.heal_scale, sk.bb['attack@addition_heal_scale'], sk.duration], [atk, boost, 0.3, 40]); + // (10,8): outside her 3-3 and S2's 3-10, inside the 5-2; (10,5) full HP + const h = battle({ id }, [['test_tank_a', 10, 8, 0.3], ['test_tank2_a', 9, 5, 1]]); + const u = h.unit(1), near = h.unit(3); + let cast = null; + h.b.on('skillStart', (c) => { if (c.unit === u) cast = h.b.time; }); + assert.equal(u.rangeKeys.length, 12); + assert.ok(h.runUntil(() => cast != null, sk.spCost), `${id}: cast`); + close(cast, sk.spCost - sk.initSp, `${id}: as soon as the SP is full`, 0.01); + assert.equal(h.heals.filter((x) => x.t < cast).length, 0, `${id}: no heal before the cast — it was out of her range`); + assert.equal(u.rangeKeys.length, 18, `${id}: the 5-2`); + close(u.s.atk, u.base.atk * (1 + atk), `${id}: ATK`); + h.run(0.5); + const first = h.heals.filter((x) => x.t === h.heals[0].t); + assert.ok(first[0].t >= cast, `${id}: the first heal comes with the cast`); + assert.deepEqual(first.map((x) => x.uid), [2, 2], `${id}: the main heal on the far ally, then the extra on it (its neighbours are full)`); + close(first[0].amount, u.s.atk * phy * boost, `${id}: main heal ×${boost}${phy > 1 ? ' ×1.15 PHY-X' : ''}`); + close(first[1].amount, first[0].amount * 0.3, `${id}: extra heal = 30 % of the main one`); + // two injured allies: both healed per action + h.b.loseHp(near, near.hp * 0.2); + const n0 = h.heals.length; + h.runUntil(() => h.heals.length > n0, 5); + h.step(); + const action = h.heals.slice(n0); + assert.deepEqual(new Set(action.map((x) => x.uid)), new Set([2, 3]), `${id}: 2 targets`); + close(action.find((x) => x.uid === 3).amount, u.s.atk * 1, `${id}: no bonus above half HP`); + h.runUntil(() => !u.skill.active, 45); + assert.equal(u.rangeKeys.length, 12, `${id}: back to the 3-3`); + checkInvariants(h.b); + assert.equal(h.b.errors.length, 0); + } + // none: no cast however long, and none for an injured ally beyond the 5-2 + for (const allies of [[['test_tank_a', 10, 8, 1]], [['test_tank_a', 10, 9, 0.3]]]) { + const h = battle({ id: 'chess_char_5_11_a', carry: { sp: 999 } }, allies); + h.run(5); + assert.equal(h.unit(1).skill.activations, 0, JSON.stringify(allies)); + } +}); + +test('S1 慨赠: cast on an injured ally in her range; 20 s of ATK +40 % / +50 %, each heal a 40 % / 50 % chance of a second heal of the same ally', () => { + for (const [id, atk, p] of [['chess_char_5_11_a', 0.4, 0.4], ['chess_char_5_11_b', 0.5, 0.5]]) { + assert.deepEqual([skillOf(id, S1).bb.atk, skillOf(id, S1).bb['attack@prob'], skillOf(id, S1).duration], [atk, p, 20]); + let actions = 0, doubles = 0; + for (let seed = 1; seed <= 6; seed++) { + // the ally stays above half HP (no PHY-X bonus), inside her 3-3 + const h = battle({ id, standIn: { skillIndex: 0 }, carry: { sp: 999 } }, [['test_tank_a', 10, 5, 0.7]], { seed }); + const u = h.unit(1); + assert.equal(u.skill.id, S1); + assert.equal(u.skill.rule, 'DEFAULT'); + let cast = null; + h.b.on('skillStart', (c) => { if (c.unit === u) cast = h.b.time; }); + assert.ok(h.runUntil(() => cast != null, 5)); + h.step(); + close(u.s.atk, u.base.atk * (1 + atk), `${id}: ATK`); + h.runUntil(() => !u.skill.active, 25); + close(h.b.time - cast, 20, `${id}: 20 s`, 0.01); + const during = h.heals.filter((x) => x.t >= cast && x.t < cast + 20); + const byT = new Map(); + for (const x of during) byT.set(x.t, [...(byT.get(x.t) ?? []), x]); + for (const list of byT.values()) { + actions++; + assert.ok(list.length <= 2 && list.every((x) => x.uid === 2), `${id}: at most one extra heal, on the same ally`); + if (list.length === 2) { doubles++; close(list[1].amount, list[0].amount, `${id}: the same amount`); } + } + checkInvariants(h.b); + } + assert.ok(actions >= 30, `${id}: ${actions} heals`); + assert.ok(Math.abs(doubles / actions - p) < 0.2, `${id}: ${doubles} / ${actions} extra heals for p = ${p}`); + } +}); + +test('S2 宛如天启: cast with an injured ally inside the 3-10 only; 30 s on the 3-10, 2 targets, every 医疗 operator of hers ATK +21 % / +30 % (herself too)', () => { + for (const [id, v] of [['chess_char_6_09_a', 0.21], ['chess_char_6_09_b', 0.3]]) { + const sk = skillOf(id, S2); + assert.deepEqual([sk.bb['attack@atk'], sk.bb['attack@max_target'], sk.duration, sk.trigger.rule], [v, 2, 30, 'ACTIVE_RANGE']); + const h = battle({ id, standIn: { skillIndex: 1 } }, [['test_tank_a', 10, 7, 0.6], ['test_medic_a', 12, 2, 1], ['test_tank2_a', 9, 4, 1]]); + const u = h.unit(1), tank = h.unit(2), medic = h.unit(3), tank2 = h.unit(4); + assert.equal(u.skill.id, S2); + let cast = null; + h.b.on('skillStart', (c) => { if (c.unit === u) cast = h.b.time; }); + assert.ok(h.runUntil(() => cast != null, sk.spCost), `${id}: cast`); + close(cast, sk.spCost - sk.initSp, `${id}: as soon as the SP is full`, 0.01); + assert.equal(h.heals.filter((x) => x.t < cast).length, 0, `${id}: (10,7) is outside her 3-3`); + h.step(); + assert.equal(u.rangeKeys.length, 15, `${id}: the 3-10`); + close(u.s.atk, u.base.atk * (1 + v), `${id}: her ATK`); + close(medic.s.atk, medic.base.atk * (1 + v), `${id}: another medic's ATK`); + close(tank.s.atk, tank.base.atk, `${id}: not a non-medic`); + h.b.loseHp(tank2, tank2.hp * 0.3); + const n0 = h.heals.length; + h.runUntil(() => h.heals.length > n0, 5); + assert.deepEqual(new Set(h.heals.slice(n0).map((x) => x.uid)), new Set([2, 4]), `${id}: 2 targets`); + h.runUntil(() => !u.skill.active, 35); + h.step(); + assert.equal(u.rangeKeys.length, 12); + close(medic.s.atk, medic.base.atk, `${id}: the aura ends with the skill`); + close(u.s.atk, u.base.atk, `${id}: hers too`); + checkInvariants(h.b); + } + // an injured ally on (10,8) is beyond the 3-10: no cast + const h = battle({ id: 'chess_char_6_09_a', standIn: { skillIndex: 1 }, carry: { sp: 999 } }, [['test_tank_a', 10, 8, 0.3]]); + h.run(5); + assert.equal(h.unit(1).skill.activations, 0); +}); + +test('攫升 / 超脱 (the strategy\'s mapCharTalents): a healed unit +3 SP; an operator knocked out in her range +5 SP (+8 with PHY-X Lv3)', () => { + for (const [id, sp] of [['chess_char_5_11_a', 5], ['chess_char_5_11_b', 5], ['chess_char_6_20_b', 8]]) { + const h = battle({ id }, [['test_sp_a', 10, 5, 0.6], ['test_tank_a', 9, 4, 1], ['test_tank2_a', 9, 10, 1]]); + const u = h.unit(1), ally = h.unit(2); + const gains = []; + h.b.on('spGain', (c) => { if (c.unit === ally) gains.push([c.reason, c.amount]); }); + h.runUntil(() => h.heals.length > 0, 5); + h.step(); + assert.deepEqual(gains, [['talent', 3]], `${id}: 攫升`); + assert.equal(u.def.talents[1].bb.sp, sp); + const before = u.skill.sp; + h.b.kill(h.unit(3)); // (9,4): in her range + close(u.skill.sp, before + sp, `${id}: 超脱`); + const mid = u.skill.sp; + h.b.kill(h.unit(4)); // (9,10): out of it + close(u.skill.sp, mid, `${id}: not out of range`); + checkInvariants(h.b); + } +}); + +test('module PHY-X (elites): every heal of hers on an ally below 50 % HP ×1.15 — none at or above it, none on the normal chess', () => { + for (const id of ['chess_char_5_11_a', 'chess_char_5_11_b', 'chess_char_6_09_b']) { + const mod = moduleOf(id); + if (mod) assert.deepEqual([mod.traitOverride.bb.heal_scale, mod.traitOverride.bb.hp_ratio], [1.15, 0.5]); + for (const [ratio, mul] of [[0.3, mod ? 1.15 : 1], [0.5, 1], [0.8, 1]]) { + const h = battle({ id }, [['test_tank_a', 10, 5, ratio]]); + const u = h.unit(1); + h.runUntil(() => h.heals.length >= 1, 10); + assert.equal(u.skill.active, false, `${id}: a normal heal`); + close(h.heals[0].amount, u.s.atk * mul, `${id}: at ${ratio} HP`); + checkInvariants(h.b); + } + } +}); diff --git a/test/content/standin_acpion.test.js b/test/content/standin_acpion.test.js new file mode 100644 index 00000000..51a51e57 --- /dev/null +++ b/test/content/standin_acpion.test.js @@ -0,0 +1,252 @@ +// test/content/standin_acpion.test.js — the 郁金香 (char_608_acpion) 补位 stand-in kit (kits/ops/standin-acpion.js) on every +// chess she replaces: 3 瑕光 (S3), 4 焰尾 (S2), 5 凛御银灰 / 6 圣聆初雪 / 缪尔赛思 / 耀骑士临光 (S3, module SOL-X on the +// elite); S1 through the harness `standIn: { skillIndex: 0 }`. Numbers: data/backups.json (skill_table, PRTS +// 郁金香(卫戍协议)); S3's slash timing: her Spine Skill clip (8 OnAttack events, 0.400 s + k/6 s). +// Run: node --test test/content/standin_acpion.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 { TICK } 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 CHAR = 'char_608_acpion'; +const S1 = 'skchr_acpion_1', S3 = 'skchr_acpion_3'; +const RECORDS = BACKUPS.units[CHAR].standsIn.flatMap((a) => [a, a.replace(/_a$/, '_b')]); +const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, ...o }); +const DEFS = { + enemies: { + enemy_dummy: dummy(), + enemy_def: dummy({ key: 'enemy_def', def: 400 }), + enemy_fly: dummy({ key: 'enemy_fly', motion: 'FLY' }), + enemy_weak: dummy({ key: 'enemy_weak', hp: 10 }), + enemy_hitter: dummy({ key: 'enemy_hitter', atk: 300, bat: 0.5 }), + }, +}; +const close = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`); +const formOf = (id) => unitForm(BACKUPS, CHAR, CHESS[id].status); +const skillOf = (id, skillId) => formOf(id).skills.find((s) => s.skillId === skillId); +const moduleOf = (id) => (CHESS[id].backup.uniEquipId && CHESS[id].status.equipLevel > 0 ? formOf(id).modules.find((m) => m.uniEquipId === CHESS[id].backup.uniEquipId) : null); + +/** A battle with the shared defs and a `damaged` log (h.dmg: { t, src, tgt, key, amount, type, tags, skill, attack }). */ +function battle(opts) { + const log = []; + const h = makeBattle({ + defs: DEFS, timeLimit: 200, autoFinish: false, seed: 5, flags: { dpPerSec: 0 }, ...opts, + setup(b) { + b.on('damaged', (c) => log.push({ t: b.time, src: c.source?.id ?? null, tgt: c.target.id, key: c.target.defId, amount: c.amount, type: c.type, tags: c.dmg?.tags ?? [], skill: !!c.dmg?.isSkill, attack: !!c.dmg?.isAttack }), { priority: -999 }); + if (opts.setup) opts.setup(b); + }, + }); + h.dmg = log; + return h; +} +const dpOf = (h) => h.b.players[0].dp; + +// --------------------------------------------------------------------------------------------------------------- + +test('郁金香 fields on all 12 records she replaces: her body (+ SOL-X attributes on the 5 / 6 elites), the chess\'s backup skill, her own kit, the data triggers', () => { + assert.equal(RECORDS.length, 12); + for (const id of RECORDS) { + const c = CHESS[id]; + const form = formOf(id); + const sk = form.skills.find((s) => s.index === c.backup.skillIndex); + const mod = moduleOf(id); + const h = battle({ units: [{ chessId: id, row: 10, col: 4, standIn: true }] }); + h.step(); + const u = h.unit(1); + assert.deepEqual([u.def.charId, u.def.standInFor, u.skill.id], [CHAR, c.charId, sk.skillId], id); + assert.ok(!u.kit.generic && u.kit.skillSource === 'skills', `${id}: her kit's own spec`); + assert.equal(!!u.def.raw.module?.active, !!mod, `${id}: module`); + assert.deepEqual([u.base.atk, u.base.def, u.base.maxHp, u.base.blockCnt], [form.stats.atk + (mod?.attr.atk ?? 0), form.stats.def + (mod?.attr.def ?? 0), form.stats.maxHp, 2], `${id}: stats`); + assert.equal(u.skill.rule, sk.skillId === S3 ? 'SKILL_RANGE' : 'DEFAULT', `${id}: trigger`); + assert.equal(u.profile.canHitFly, false, `${id}: her attack is ground-only`); + assert.deepEqual([u.skill.spCost, u.skill.initSp], [sk.spCost, sk.initSp], `${id}: SP`); + assert.equal(h.b.errors.length, 0); + } + // the three tiers' skills: 3 瑕光 S3, 4 焰尾 S2, 5–6 S3 + assert.deepEqual([CHESS.chess_char_3_12_a, CHESS.chess_char_4_19_a, CHESS.chess_char_5_14_a].map((c) => c.backup.skillIndex), [2, 1, 2]); +}); + +test('浪潮之心: ATK +10 %, +1 DP for each enemy she kills; SOL-X (5 / 6 elites only): ATK / DEF +8 % exactly while she blocks', () => { + for (const id of ['chess_char_3_12_a', 'chess_char_4_19_b', 'chess_char_5_14_b', 'chess_char_6_17_b']) { + const mod = moduleOf(id); + const h = battle({ units: [{ chessId: id, row: 10, col: 4, standIn: true }] }); + const u = h.unit(1); + h.step(); + close(u.s.atk, u.base.atk * 1.1, `${id}: ATK +10 %`); + close(u.s.def, u.base.def, `${id}: DEF`); + // a weak enemy walks into her block and dies to her first attack: +1 DP (no DP per second here) + const dp0 = dpOf(h); + const e = h.spawn('enemy_weak', { pos: [10, 4] }); + assert.ok(h.runUntil(() => !e.alive, 5), `${id}: she kills it`); + assert.equal(dpOf(h), dp0 + 1, `${id}: +1 DP for the kill`); + assert.equal(u.skill.activations, 0, `${id}: no skill DP mixed in`); + // a lasting enemy on her tile: blocked ⇒ SOL-X + h.spawn('enemy_dummy', { pos: [10, 4] }); + h.run(0.2); + assert.equal(u.blocking.length, 1); + close(u.s.atk, u.base.atk * (1.1 + (mod ? 0.08 : 0)), `${id}: ATK while blocking`); + close(u.s.def, u.base.def * (1 + (mod ? 0.08 : 0)), `${id}: DEF while blocking`); + if (mod) assert.deepEqual([mod.traitOverride.bb.atk, mod.traitOverride.bb.def], [0.08, 0.08]); + checkInvariants(h.b); + } +}); + +test('无垠之心: SP recovery +0.6/s (+1/s with SOL-X Lv3) from each deployment until her 2nd skill activation of it', () => { + for (const [id, per] of [['chess_char_3_12_a', 0.6], ['chess_char_5_14_b', 0.6], ['chess_char_6_02_b', 1]]) { + const h = battle({ units: [{ chessId: id, row: 10, col: 4, standIn: { skillIndex: 0 } }], enemies: [{ key: 'enemy_dummy', pos: [10, 4] }] }); + const u = h.unit(1); + h.step(); + assert.equal(u.skill.id, S1); + close(u.s.spRecovery, 1 + per, `${id}: before the 1st cast`); + assert.ok(h.runUntil(() => u.skill.activations === 1, 40)); + h.step(); + close(u.s.spRecovery, 1 + per, `${id}: between the casts`); + assert.ok(h.runUntil(() => u.skill.activations === 2, 40)); + h.step(); + close(u.s.spRecovery, 1, `${id}: after the 2nd cast`); + // a new deployment starts it again + h.b.retreat(u); + assert.ok(h.b.redeploy(u)); + h.step(); + close(u.s.spRecovery, 1 + per, `${id}: redeployed`); + checkInvariants(h.b); + } + // the module's talent upgrade: +1/s at module Lv3 only (Lv1 keeps +0.6) + assert.deepEqual(BACKUPS.units[CHAR].forms['2/60/7/3'].modules[0].talentChanges.map((t) => t.bb.sp_recovery_per_sec), [1]); +}); + +test('S1 钻心: +6 DP and ATK × 170 % / 200 % physical to at most 2 enemies of her attack range (the blocked ones first, no air unit)', () => { + for (const [id, scale] of [['chess_char_3_12_a', 1.7], ['chess_char_3_12_b', 2]]) { + assert.equal(skillOf(id, S1).bb.atk_scale, scale); + const h = battle({ + units: [{ chessId: id, row: 10, col: 4, standIn: { skillIndex: 0 }, carryState: { sp: 999 } }], + enemies: [{ key: 'enemy_def', pos: [10, 4] }, { key: 'enemy_dummy', pos: [10, 4] }, { key: 'enemy_def', pos: [10, 5] }, { key: 'enemy_fly', pos: [10, 5] }], + }); + const u = h.unit(1); + const dp0 = dpOf(h); + assert.ok(h.runUntil(() => u.skill.activations === 1, 5), `${id}: cast`); + const atk = u.s.atk; + const hits = h.dmg.filter((d) => d.src === u.id && d.skill); + assert.equal(hits.length, 2, `${id}: two targets`); + assert.deepEqual(hits.map((d) => d.key).sort(), ['enemy_def', 'enemy_dummy'], `${id}: the two she blocks`); + for (const d of hits) close(d.amount, atk * scale - (d.key === 'enemy_def' ? 400 : 0), `${id}: ${d.key}`); + assert.equal(dpOf(h), dp0 + 6, `${id}: +6 DP`); + checkInvariants(h.b); + } +}); + +test('S2 迅瞬 (焰尾\'s chess): 10 s of ASPD +95 / +110 and 25 % / 30 % DEF ignored; 1 DP every 1.6 s / 1.4 s, 6 / 7 in all', () => { + for (const [id, aspd, pen, iv, n] of [['chess_char_4_19_a', 95, 0.25, 1.6, 6], ['chess_char_4_19_b', 110, 0.3, 1.4, 7]]) { + const h = battle({ units: [{ chessId: id, row: 10, col: 4, standIn: true, carryState: { sp: 999 } }] }); + const u = h.unit(1); + h.run(1); + assert.equal(u.skill.activations, 0, `${id}: DEFAULT — no enemy, no cast`); + h.spawn('enemy_def', { pos: [10, 4] }); + let cast = null; + h.b.on('skillStart', (c) => { if (c.unit === u) cast = h.b.time; }); + const gains = []; + let last = dpOf(h); + assert.ok(h.runUntil(() => u.skill.active, 5), `${id}: cast with an enemy`); + assert.equal(u.s.aspd, 100 + aspd); + close(u.s.defIgnorePct, pen, `${id}: DEF ignore`); + while (u.skill.active) { + h.step(); + const now = dpOf(h); + if (now !== last) { gains.push([h.b.time - cast, now - last]); last = now; } + } + close(h.b.time - cast, 10, `${id}: 10 s`, 0.01); + assert.equal(gains.length, n, `${id}: ${n} DP gains`); + gains.forEach(([t, d], i) => { assert.equal(d, 1); assert.ok(Math.abs(t - (i + 1) * iv) <= TICK + 1e-6, `${id}: gain ${i + 1} at ${t}`); }); + // her attacks during the skill ignored the share of the 400 DEF + const hit = h.dmg.find((d) => d.src === u.id && d.attack && d.t > cast + 0.1 && d.t < cast + 9.9); + close(hit.amount, u.s.atk - 400 * (1 - pen), `${id}: attack vs DEF 400`); + assert.equal(u.s.aspd, 100); + checkInvariants(h.b); + } +}); + +test('S3 只余芬芳: +6 / +7 DP, 8 slashes at 0.4 s + k/6 s on every enemy of the x-1 around her (air units too), each ATK × 155 % / 170 % ignoring 50 % DEF', () => { + for (const [id, scale, cost] of [['chess_char_3_12_a', 1.55, 6], ['chess_char_3_12_b', 1.7, 7], ['chess_char_6_02_b', 1.7, 7]]) { + const b3 = skillOf(id, S3).bb; + assert.deepEqual([b3.atk_scale, b3.cost, b3.times, b3.def_penetrate], [scale, cost, 8, 0.5]); + const h = battle({ + units: [{ chessId: id, row: 10, col: 4, standIn: true, carryState: { sp: 999 } }], + // x-1 = the diamond of radius 2: (10,4) blocked, (9,5) and (12,4) in it; (10,7) and (12,6) outside + enemies: [{ key: 'enemy_def', pos: [10, 4] }, { key: 'enemy_fly', pos: [9, 5] }, { key: 'enemy_dummy', pos: [12, 4] }, { key: 'enemy_dummy', pos: [10, 7] }, { key: 'enemy_dummy', pos: [12, 6] }], + }); + const u = h.unit(1); + let cast = null; + h.b.on('skillStart', (c) => { if (c.unit === u) cast = h.b.time; }); + const dp0 = dpOf(h); + assert.ok(h.runUntil(() => cast != null, 5), `${id}: cast`); + h.step(); + assert.equal(dpOf(h), dp0 + cost, `${id}: +${cost} DP`); + h.run(2); + const atk = u.base.atk * 1.1; // 浪潮之心; SOL-X is off: she blocks nothing while it plays + const slashes = h.dmg.filter((d) => d.src === u.id && d.tags.includes('slash')); + const byKey = (k) => slashes.filter((d) => d.key === k); + assert.equal(byKey('enemy_def').length, 8, `${id}: 8 slashes on the blocked enemy`); + assert.equal(byKey('enemy_fly').length, 8, `${id}: 8 on the air unit`); + assert.equal(byKey('enemy_dummy').length, 8, `${id}: 8 on (12,4) only — not on (10,7) / (12,6)`); + assert.equal(new Set(byKey('enemy_dummy').map((d) => d.tgt)).size, 1); + byKey('enemy_def').forEach((d, k) => { + close(d.amount, atk * scale - 400 * 0.5, `${id}: slash ${k} vs DEF 400`); + assert.ok(Math.abs(d.t - cast - (0.4 + k / 6)) <= TICK + 1e-6, `${id}: slash ${k} at ${d.t - cast}`); + assert.equal(d.type, 'phys'); + }); + for (const d of byKey('enemy_fly')) close(d.amount, atk * scale, `${id}: air unit`); + checkInvariants(h.b); + } +}); + +test('S3 只余芬芳 while it plays (PRTS 备注): 无敌, 无法阻挡, 眩晕 / 冻结免疫, no normal attack — for the 1.8 s of her Skill + Skill_End clips', () => { + const h = battle({ + units: [{ chessId: 'chess_char_6_11_b', row: 10, col: 4, standIn: true, carryState: { sp: 999 } }], + enemies: [{ key: 'enemy_hitter', pos: [10, 4] }], + }); + const u = h.unit(1); + let cast = null; + h.b.on('skillStart', (c) => { if (c.unit === u) cast = h.b.time; }); + assert.ok(h.runUntil(() => cast != null, 5)); + h.step(); + const hp = u.hp; + const buff = u.findBuff('acpion:fragrance'); + assert.deepEqual(buff.flags, { invulnerable: true, noBlock: true, disarm: true }); + close(buff.duration, 0.4 + 7 / 6 + 0.233, 'the window'); + assert.equal(u.blocking.length, 0, 'lets go of the enemy'); + assert.equal(h.b.applyStatus(u, 'stun', { duration: 3, source: h.b.enemies[0] }), false, 'stun refused'); + assert.equal(h.b.applyStatus(u, 'freeze', { duration: 3, source: h.b.enemies[0] }), false, 'freeze refused'); + const attacks0 = h.hooksOf('attack').length; + h.runUntil(() => h.b.time >= cast + 1.75, 3); + assert.equal(u.hp, hp, 'no damage taken'); + assert.equal(u.blocking.length, 0, 'blocks nothing meanwhile'); + assert.equal(h.hooksOf('attack').length, attacks0, 'no normal attack meanwhile'); + h.runUntil(() => h.b.time >= cast + 2.2, 3); + assert.equal(u.findBuff('acpion:fragrance'), null); + assert.equal(u.blocking.length, 1, 'blocks again'); + assert.ok(h.b.applyStatus(u, 'stun', { duration: 0.5, source: h.b.enemies[0] }), 'stun lands again'); + h.run(1); + assert.ok(u.hp < hp, 'takes damage again'); + checkInvariants(h.b); +}); + +test('S3 trigger SKILL_RANGE (data): any enemy on the x-1 casts it — an air unit she cannot attack too; none there, no cast', () => { + const run = (pos) => { + const h = battle({ units: [{ chessId: 'chess_char_3_12_a', row: 10, col: 4, standIn: true, carryState: { sp: 999 } }], enemies: [{ key: 'enemy_fly', pos }] }); + const u = h.unit(1); + h.run(3); + return { casts: u.skill.activations, attacks: h.hooksOf('attack').filter((a) => a.attacker === u).length, slashes: h.dmg.filter((d) => d.tags.includes('slash')).length, trigger: u.def.skill.trigger }; + }; + const inside = run([10, 6]); + assert.equal(inside.trigger.rule, 'SKILL_RANGE'); + assert.equal(inside.trigger.grid.length, 13, 'the x-1 diamond'); + assert.deepEqual([inside.casts, inside.attacks, inside.slashes], [1, 0, 8], 'cast with no attack; the slashes reach the air unit'); + const outside = run([10, 7]); + assert.deepEqual([outside.casts, outside.slashes], [0, 0]); +});