Files
sganggs a0a7660287 feedback5 WM: merge feedback4 (896dd5d, the coming 0.1.4) — PRs #150 #66 #146 #74 #72 #103 #149 #109 #160 ported into the split layout
Ported by hand:
- #109 kit hunks → kits/ops (the tier files stay re-export shims): 宴 chess_char_1_18-utage.js, 野鬃
  chess_char_1_19-wildmn.js, 砾 chess_char_2_12-gravel.js, 斯卡蒂 chess_char_3_05-skadi.js, 伊内丝
  chess_char_4_04-ines.js, 缄默德克萨斯 chess_char_4_16-texas2.js, 耀骑士临光 chess_char_6_17-nearl2.js. The +/- lines
  are identical to #109's tier1/2/3/4/6 diffs.
- #109 generic.js against W5D's own-duration passives (一击即退): the deployment window is computed where #109 computes
  it, with W5D's case included. Both forms run #109's duration lifecycle (activateOnDeploy, spType 'none', the
  skill's own mods), and W5D's 冲锋号令 DP start effect is kept.
- #103 room.js through i18n phase 1: t('{name}邀请你加入卫戍协议:盟约【{difficulty}】') with the translated difficulty
  name (the Chinese text is the PR's), plus its English line in public/i18n/en.json.
- data/assets.json: feedback5's manifest plus #72's audio.bgm.combatAlts (after boss, the plan order). files +3 and
  bytes +5 037 595 as measured on feedback4; the hash is recomputed with the generator's contentHash. No other data/
  file changed.
- docs: DESIGN §5.6 (#109's timed deployment paragraph before feedback5's trigger rules; "Permanent passive skills are
  always on"), SIM §7.4 (both generic passive cases).
- Auto-merged and checked: #149 bond-bar fold (still in screens/game.js), #160 skills.js / spine.js, #109 skills.js /
  snapshot.js, #66 addon/battle.js (restTile / isDown live in sim/battle/tiles.js), #150 units.js, #72 audio.js /
  plan.mjs, #74 plan.mjs / docs.

Where the PRs met feedback5 work:
- 突袭 (W5L, GitHub #49): raidPoll counts a skill that needs no SP while it is on. That is a passive, as before, or a
  #109 deploy-timed skill while its window runs. Once the window has ended only the idle trigger is left. Without this
  change 缄默德克萨斯 / 宴 lost #49. research 02 §3.18 says so.
- Tests changed to #109's representation: bonds_addon #49 test (kind 'duration', activateOnDeploy) plus a new test
  (window over → idle trigger only), and standin.test.js's 预备干员 PASSIVE branch (the skill's mods instead of the
  generic:passive buff).

Golden regenerated on the merged code. Attribution: each PR's gameplay change was reverted alone in a scratch copy, and
all three together. With all three reverted the digests equal feedback5's exactly. Every moved scenario was replayed
and fields the unit that explains it.
- #160 only (0.5 s skill events of deploy passives; events.skill only): roster-001/006/025/049, bond-arcaneShip-low,
  bond-emptyShip-high, bond-investShip-high, bond-kjeragShip-low, bond-siracusaShip-low/high, bond-victoriaShip-low,
  bond-yanShip-high, boss-boss_2/4/6-solo, hidden-boss_8/10-solo, standin-03/05/08/10, diy-005/006 (普罗旺斯 S1,
  琳琅诗怀雅 S1/S2, 星熊 S2, 凯瑟琳 S1, Misery S3 as stand-in / 自选 prototype).
- #109 only (deploy-timed skill state and lifecycle; 突袭 技能就绪 limited to the window; generic 一击即退):
  roster-003/004/005/021/023/024/039/041/042/046/047, bond-egirShip-high, bond-indomShip-high,
  bond-kazimierzShip-low/high, hidden-boss_8-pair, standin-01, coop4-FUNNY-6, coop2-NORMAL-8-serverrun.
- #66 only (灵巧's aura stays only for a killed member, from its body tile): roster-028, coop2-NORMAL-3,
  coop3-ABYSS-5, coop4-ABYSS-7, solo-HARD-9-boosted.
- #109 + #160: roster-002, roster-029, bond-investShip-low, bond-raidShip-low/high, bond-suntShip-low,
  bond-visiShip-high.
- #66 + #160: roster-008, roster-027.
- #66 + #109: roster-022, roster-040, bond-indomShip-low, boss-boss_2/4/6-pair, hidden-boss_10-pair, standin-06,
  coop2-HARD-4, coop2-NORMAL-14-standins.
- all three: roster-010, roster-012, roster-031.
69 scenarios. Every other digest is unchanged.
2026-10-05 23:59:05 +08:00

470 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server/sim/content/generic.js — generic kit derived from a chess's skill blackboard + skill metadata.
//
// Used for every chess without a hand-authored kit (and for tokens with skills), so every unit fights sensibly.
// Kind: PASSIVE (or a free skill without duration/ammo) ⇒ passive · durationType AMMO ⇒ ammo · duration > 0 ⇒
// duration · duration < 0 ⇒ ammo when an ammo key exists, toggle only for an explicitly endless skill
// ("持续时间无限", 史尔特尔) — the official data also uses −1 for instant/charge skills ("立即…", "下一次攻击…",
// "可充能N次": 夕, 塑心, 妮芙, 莱恩哈特, 流星 …) · otherwise instant (charges when maxCharges > 1).
// Blackboard keys → SkillSpec (stat keys prefer the plain key, attack/targeting keys prefer the `attack@` key —
// e.g. 薄绿 `attack@atk_scale` 1.1 per hit vs `atk_scale` 2.1 at skill end):
// atk→mods.atkPct · def→defPct · max_hp→hpPct (values > 5 are flat: 史尔特尔 "生命上限+5000") · attack_speed→aspd
// base_attack_time→batPct · magic_resistance→resMul (|v| < 1, "法术抗性+60%") or resFlat · damage_scale→dmgDealtMul
// block_cnt→blockCnt · taunt_level→taunt · damage_resistance→dmgTakenMul (1−v) · hp_recovery_per_sec(_by_max_hp_ratio)
// →hpRegen(Ratio) · sp_recovery_per_sec→spRecoveryFlat · magic_resist_penetrate_fixed→resIgnoreFlat ·
// def_penetrate_fixed→defIgnoreFlat · ability_range_forward_extend→targeting.rangeExtend · max_target→targeting.maxTargets
// atk_scale→attack.atkScale · heal_scale→attack.healScale · times→attack.hits · attack@range_radius→attack.splashRadius
// trigger_time / ammo→ammo.
// Negative atk/def/magic_resistance described on enemies ("攻击范围内所有敌人防御力-40%", "命中目标的防御力-30%") debuff
// the targets (aura while the skill runs, or on hit for bb.duration s) instead of the operator.
// Statuses stun/cold/sleep/fear/sluggish/root/unmovable (× prob): `attack@` keys → on hit; plain keys → on hit for
// instant/charge skills, once at skill start on the enemies in range (≤ max_target) for timed skills (小满 sleeps 3
// enemies once, not on every attack); a plain `stun` described "…结束后…晕眩" (幽灵鲨, 雷蛇) stuns the operator itself
// when the skill ends.
// Bursts: a timed skill whose text says "立即…造成…" deals its plain atk_scale once at start (乌尔比安's anchor); "技能结束时
// …造成…" deals it at the end (薄绿). Those plain scales never become the per-attack scale.
// Text rules: "停止攻击…" (duration skills; not "…之后…") ⇒ no attacks while active (泡泡, 蛇屠箱, 凯瑟琳, 铃兰, 菲莱);
// "受到攻击时…造成…攻击力/防御力N%的X伤害" ⇒ counter-damage to the attacker (or around the operator when the text says
// 周围) while the skill is active (星熊, 泡泡, 菲莱; aoe_cd = cooldown), never a per-attack scale;
// "附带…凋亡/灼燃/神经损伤" + ep_damage_ratio ⇒ element damage on hit (× damage dealt when the text says "伤害N%的…损伤",
// else × ATK); "屏障" + shield_max_hp_ratio / hp_ratio ⇒ self shield at start decaying over its duration (砾, 新约能天使);
// "立即流失N%当前生命" + hp_ratio ⇒ self HP loss at start (宴, 风丸); hp_ratio + "恢复/回复…生命" ⇒ self heal at start.
// Passive skills with stat mods + bb.duration + "N秒内" use a deployment duration (宴), and so do the ON_DEPLOY passives that
// say "部署后…" with the skill's own duration and none in their blackboard (一击即退, duration 10 — PRTS 技能 持续 10):
// kind 'duration', activateOnDeploy (#109); other passives apply stat mods and the self/counter effects above — their
// scales describe procs (bombs, sword rain, counters) that need a hand-authored kit.
// "立即获得N点部署费用" + cost ⇒ +cost DP for the player at the start (冲锋号令); an AUTO skill that does nothing else
// fires as soon as its SP is full (as 德克萨斯's kit casts the same skill: kits/ops/chess_char_1_08-texas.js).
// genericTalents(def): the talents a generic kit can apply exactly — an unconditional stat line ("攻击力+8%",
// "攻击速度+9") — used for 补位 stand-ins without a kit of their own (content/index.js); a pool chess always has a kit.
// force→onHit displacement with the official 力度 − 重量 rules (Battle.push / pullToFront): a pull "至面前" when the text says
// 拖拽 or for hookmasters, else a push — along the unit's direction when the text says 往攻击方向 (the 推击手 wording, PRTS
// 推与拉 方向力) / 朝部署方向 / 向前 / 身前方向 or for 推击手 (directional), otherwise away from the unit (radial); a skill of
// constants.js PUSH_EFFECT_SKILLS (见行者 S1) pushes by PRTS 推与拉's 特效 column. These keywords are only a fallback:
// the hand-written kits follow the client's buff templates (knockback[dir] = directional, knockback[relative] = radial),
// and the text can mislead — 琳琅诗怀雅 S3's "向前推开" is knockback[relative] (kits/ops/chess_char_3_04-swire2.js). No pool skill pushes
// through this path with 向前 / 往攻击方向; the only generic push is 见行者 S1, a 推击手.
import { normalizeSkill } from '../simdata.js';
import { sortEnemyTargets } from '../targeting.js';
import { PUSH_EFFECT_SKILLS } from '../constants.js';
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v !== '' && Number.isFinite(+v) ? +v : undefined));
/** Plain key first, then `attack@` / `skill@` (stat modifiers). */
function getter(bb) {
return (k) => {
for (const key of [k, 'attack@' + k, 'skill@' + k]) {
const v = num(bb[key]);
if (v !== undefined) return v;
}
return undefined;
};
}
/** `attack@` key first (per-attack values), then plain / `skill@`. */
function attackGetter(bb) {
return (k) => {
for (const key of ['attack@' + k, k, 'skill@' + k]) {
const v = num(bb[key]);
if (v !== undefined) return v;
}
return undefined;
};
}
const ENDLESS = /持续时间无限|无限持续/;
const STOP_ATTACK = /(^|[,;。,;])停止(主动)?攻击(敌人)?([,;。,;]|$)/;
const ELEMENTS = [[/凋亡损伤/, 'apoptosis'], [/灼燃损伤/, 'burn'], [/神经损伤/, 'neural']];
const STATUS_KEYS = [['stun', 'stun'], ['cold', 'cold'], ['sleep', 'sleep'], ['fear', 'fear'], ['sluggish', 'sluggish'], ['root', 'bind'], ['unmovable', 'bind']];
/** Decide the skill kind from metadata. */
export function genericKind(sk, bb = {}) {
if (!sk) return null;
const g = getter(bb);
if (sk.kind) return sk.kind;
if (sk.skillType === 'PASSIVE') return 'passive';
// a skill that costs no SP and has no duration/ammo is effectively always on (e.g. 迷迭香's shield token)
if (!(sk.spCost > 0) && sk.durationType !== 'AMMO' && !(sk.duration > 0)) return 'passive';
if (sk.durationType === 'AMMO') return 'ammo';
if (sk.duration > 0) return 'duration';
if (sk.duration < 0) {
if (g('trigger_time') !== undefined || g('ammo') !== undefined) return 'ammo';
if (ENDLESS.test(String(sk.description || ''))) return 'toggle';
}
return sk.maxCharges > 1 ? 'charges' : 'instant';
}
function dmgTypeFromText(text, fallback) {
if (/法术伤害/.test(text)) return 'arts';
if (/物理伤害/.test(text)) return 'phys';
if (/真实伤害/.test(text)) return 'true';
return fallback === 'heal' || fallback === 'none' || !fallback ? 'phys' : fallback;
}
/** Enemies currently in the unit's range, best targets first (at most `n`). */
function enemiesInRange(battle, unit, n) {
const list = battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true });
sortEnemyTargets(battle, unit, list, unit.profile?.priority ?? null);
return n > 0 && list.length > n ? list.slice(0, n) : list;
}
/** Build a SkillSpec from a normalised skill def and its blackboard. `def` (optional) = normalised unit def. */
export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) {
if (!sk) return null;
const g = getter(bb);
const ga = attackGetter(bb);
let kind = genericKind(sk, bb);
const desc = String(sk.description || '');
// "受到攻击时…造成…" numbers belong to a counter effect (the operator's own, or an ally's: 刺玫 "该角色受到攻击时")
const counterCtx = /受到(敌人的)?攻击时/.test(desc);
const counterText = counterCtx && !/该(角色|干员|单位)受到攻击时/.test(desc);
// ---- stat mods (self) and target debuffs
const mods = {};
const debuff = {};
const set = (k, v) => { if (v !== undefined && Number.isFinite(v) && v !== 0) mods[k] = (mods[k] ?? 0) + v; };
const onEnemies = (stat) => new RegExp(`(敌人|目标|敌方)[^。;]*${stat}-`).test(desc);
const pctOrFlat = (v, pctKey, flatKey, target) => { if (v === undefined || v === 0) return; if (Math.abs(v) > 5) target[flatKey] = (target[flatKey] ?? 0) + v; else target[pctKey] = (target[pctKey] ?? 0) + v; };
const atk = g('atk');
if (atk !== undefined && atk < 0 && onEnemies('攻击力')) pctOrFlat(atk, 'atkPct', 'atkFlat', debuff); else pctOrFlat(atk, 'atkPct', 'atkFlat', mods);
const defv = g('def');
if (defv !== undefined && defv < 0 && onEnemies('防御力')) pctOrFlat(defv, 'defPct', 'defFlat', debuff); else pctOrFlat(defv, 'defPct', 'defFlat', mods);
pctOrFlat(g('max_hp'), 'hpPct', 'hpFlat', mods);
set('aspd', g('attack_speed'));
const bat = g('base_attack_time');
if (bat !== undefined && bat !== 0) mods.batPct = Math.max(-0.9, bat);
const mr = g('magic_resistance');
if (mr !== undefined && mr !== 0) {
const tgt = mr < 0 && onEnemies('法术抗性') ? debuff : mods;
if (Math.abs(mr) < 1) tgt.resMul = (tgt.resMul ?? 1) * Math.max(0, 1 + mr);
else tgt.resFlat = (tgt.resFlat ?? 0) + mr;
}
const ds = g('damage_scale');
if (ds !== undefined && ds > 0) mods.dmgDealtMul = ds;
set('blockCnt', g('block_cnt'));
set('taunt', g('taunt_level'));
const dr = g('damage_resistance');
if (dr !== undefined && dr > 0 && dr < 1) mods.dmgTakenMul = 1 - dr;
// negative regen keys describe enemies ("受到的治疗和回复效果降低50%", 引星棘刺): never a self drain
const hrs = g('hp_recovery_per_sec'), hrr = g('hp_recovery_per_sec_by_max_hp_ratio');
if (hrs > 0) set('hpRegen', hrs);
if (hrr > 0) set('hpRegenRatio', hrr);
set('spRecoveryFlat', g('sp_recovery_per_sec'));
set('resIgnoreFlat', g('magic_resist_penetrate_fixed'));
set('defIgnoreFlat', g('def_penetrate_fixed'));
// Effect inference keeps the original kind; a deployment window only changes the lifecycle.
const passive = kind === 'passive';
const timed = kind === 'duration' || kind === 'ammo' || kind === 'toggle';
// a passive stat buff limited in time ("部署后…在14秒内攻击力+65%", 宴) runs as a duration skill from every deployment;
// so does an ON_DEPLOY passive whose duration is the skill's own (一击即退 "部署后攻击力+X%,防御力+Y%", duration 10, no bb key)
const ownDuration = num(bb.duration) === undefined && sk.duration > 0 && /^部署后/.test(desc) ? sk.duration : 0;
const passiveTimed = passive && Object.keys(mods).length
? (num(bb.duration) > 0 && /\d+(\.\d+)?秒内/.test(desc) ? num(bb.duration) : ownDuration)
: 0;
if (passiveTimed) kind = 'duration';
// ---- targeting / attack override (never for passives: their scales describe procs)
const targeting = {};
const attack = {};
if (passiveTimed && /攻击[^。;]*(造成|变为|变成)[^。;]*法术伤害|伤害类型变为法术/.test(desc)) attack.dmgType = 'arts';
const mt = ga('max_target');
if (!passive && mt !== undefined && mt > 0) targeting.maxTargets = Math.floor(mt);
const ext = g('ability_range_forward_extend');
if (!passive && ext !== undefined && ext > 0) targeting.rangeExtend = Math.round(ext);
if (!passive && sk.rangeGrid && sk.rangeGrid.length) targeting.rangeGrid = sk.rangeGrid;
const atkAt = num(bb['attack@atk_scale']);
const atkPlain = num(bb.atk_scale) ?? num(bb['skill@atk_scale']);
const startBurst = timed && atkPlain !== undefined && /立即[^。]*造成/.test(desc) ? atkPlain : undefined;
const endBurst = timed && atkPlain !== undefined && /技能结束时[^。]*造成[^。]*攻击力/.test(desc) ? atkPlain : undefined;
const counterScale = counterText && atkPlain !== undefined && atkAt === undefined ? atkPlain : undefined;
// "额外造成攻击力N%的…伤害" is bonus damage on top of the attack (烛煌), not the attack's own scale
const isExtra = (v) => v !== undefined && [...desc.matchAll(/额外(造成|附带)[^。;]*?攻击力(\d+(\.\d+)?)%/g)].some((m) => Math.abs(+m[2] - v * 100) < 1e-6);
// a non-healer whose skill "恢复…一名(其他)友方…生命" (古米, 瑕光): its hits heal the most injured ally nearby
const healerUnit = !!def && (def.dmgType === 'heal' || def.profession === 'MEDIC');
const allyHealScale = ga('heal_scale');
const allyHeal = !passive && !!def && !healerUnit && allyHealScale > 0 && /(恢复|治疗)[^。]*友(方|军)[^。]*生命/.test(desc);
const allyHealOthersOnly = /其他友(方|军)/.test(desc);
const allyHealAll = /所有友(方|军)/.test(desc); // 塞雷娅: heals every injured ally in the skill range
if (!passive) {
let as = isExtra(atkAt) ? undefined : atkAt;
if (as === undefined && atkAt === undefined && atkPlain !== undefined && !counterCtx && !isExtra(atkPlain) && startBurst === undefined && endBurst === undefined) as = atkPlain;
if (as !== undefined && as > 0) attack.atkScale = as;
const hs = ga('heal_scale');
if (hs !== undefined && hs > 0 && !allyHeal) attack.healScale = hs;
const times = ga('times');
if (times !== undefined && times > 1) attack.hits = Math.min(10, Math.floor(times));
const rr = num(bb['attack@range_radius']);
if (rr !== undefined && rr > 0 && rr < 5) attack.splashRadius = rr;
if (kind === 'duration' && STOP_ATTACK.test(desc) && !/之后/.test(desc)) attack.noAttack = true;
}
// ---- statuses
const hitStatuses = [];
const startStatuses = [];
let selfStunOnEnd = 0;
if (!passive) {
for (const [key, status] of STATUS_KEYS) {
const onAtk = num(bb['attack@' + key]);
const plain = num(bb[key]) ?? num(bb['skill@' + key]);
if (onAtk !== undefined && onAtk > 0) hitStatuses.push({ key: status, duration: onAtk });
if (plain !== undefined && plain > 0 && !(onAtk > 0 && plain === onAtk)) {
if (key === 'stun' && /结束后[^,。;]*晕眩/.test(desc)) selfStunOnEnd = plain;
else if (timed) startStatuses.push({ key: status, duration: plain });
else if (!(onAtk > 0)) hitStatuses.push({ key: status, duration: plain });
}
}
}
const prob = g('prob');
const rollProb = (battle) => !(prob !== undefined && prob > 0 && prob < 1) || battle.rng.chance(prob);
// ---- element damage attached to hits / counters
let element = null;
const epr = g('ep_damage_ratio');
if (epr !== undefined && epr > 0 && /附带/.test(desc) && !/其他友方/.test(desc)) {
const el = ELEMENTS.find(([re]) => re.test(desc));
if (el) element = { el: el[1], ratio: epr, ofDamage: /伤害\d+(\.\d+)?%的[^。,;]{0,3}损伤/.test(desc) };
}
const applyElement = (battle, unit, target, dealt) => {
if (!element || !target || !target.alive) return;
const amount = element.ofDamage ? (dealt || 0) * element.ratio : unit.s.atk * element.ratio;
if (amount > 0) battle.dealDamage(unit, target, { type: 'element', element: element.el, amount, tags: ['skill'] });
};
// ---- displacement
const forceRaw = g('force');
const hasForce = !passive && forceRaw !== undefined;
const force = forceRaw ?? 0; // 力度 (微小力 −1 … 特大力 5)
const pull = /拖拽/.test(desc) || (!/推开|击退/.test(desc) && def && def.subProf === 'hookmaster');
const directional = !pull && (/攻击方向|朝部署方向|向前|身前方向/.test(desc) || (def && def.subProf === 'pusher'));
const effectPush = PUSH_EFFECT_SKILLS.has(sk.id);
const hitElement = element && !counterText;
const healAlly = (battle, unit) => {
if (allyHealAll) {
for (const a of battle.injuredAlliesInKeys(unit.rangeKeys, unit)) if (!(allyHealOthersOnly && a === unit)) battle.heal(unit, a, unit.s.atk * allyHealScale);
return;
}
let best = null;
for (const a of battle.injuredAlliesInKeys(unit.rangeKeys, unit)) if (!(allyHealOthersOnly && a === unit)) { best = a; break; }
if (!best) best = battle.alliesInRadius(unit.x, unit.y, 1.5, null).filter((a) => a.hp < a.s.maxHp && !(allyHealOthersOnly && a === unit) && (a === unit || !(a.s.flags.noHeal || a.profile?.noHeal))).sort((a, b) => a.hpRatio - b.hpRatio)[0] ?? null;
if (best) battle.heal(unit, best, unit.s.atk * allyHealScale);
};
const onHit = hitStatuses.length || hasForce || hitElement || allyHeal
? (ctx) => {
if (allyHeal) healAlly(ctx.battle, ctx.unit);
if (!ctx.target || !ctx.target.alive) return;
if (hitElement) applyElement(ctx.battle, ctx.unit, ctx.target, ctx.dealt);
if (hitStatuses.length && rollProb(ctx.battle)) {
for (const s of hitStatuses) ctx.battle.applyStatus(ctx.target, s.key, { duration: s.duration, source: ctx.unit });
}
if (hasForce && ctx.target.alive && ctx.target.side === 'enemy') {
const u = ctx.unit;
if (pull) ctx.battle.pullToFront(ctx.target, u, force);
else ctx.battle.push(ctx.target, force, { from: u, dir: directional && u.fwd ? { x: u.fwd[1], y: u.fwd[0] } : null, effect: effectPush });
}
}
: null;
// target debuffs: aura over the range while a timed skill runs, else on hit for bb.duration (default 5 s)
const hasDebuff = Object.keys(debuff).length > 0 && !passive;
const debuffKey = `generic:debuff:${sk.id ?? 'skill'}`;
const aura = hasDebuff && timed && /攻击范围内[^。;]*敌人/.test(desc);
const debuffDur = timed ? 5 : (num(bb.duration) > 0 ? num(bb.duration) : 5);
let onHitFinal = onHit;
if (hasDebuff && !aura) {
const base = onHit;
onHitFinal = (ctx) => {
if (base) base(ctx);
if (ctx.target && ctx.target.alive) ctx.battle.addBuff(ctx.target, { key: debuffKey, duration: debuffDur, mods: { ...debuff }, refresh: 'extend', source: ctx.unit });
};
}
if (onHitFinal) attack.onHit = onHitFinal;
let ammo;
if (kind === 'ammo') ammo = Math.max(1, Math.floor(g('trigger_time') ?? g('ammo') ?? g('cnt') ?? 8));
const spec = {
id: sk.id,
name: sk.name,
kind,
duration: kind === 'duration' ? (passiveTimed || sk.duration) : (kind === 'ammo' && sk.duration > 0 ? sk.duration : undefined),
ammo,
};
if (passiveTimed) Object.assign(spec, { activateOnDeploy: true, spCost: 0, spType: 'none', trigger: 'NEVER' });
if (Object.keys(mods).length) spec.mods = mods;
if (Object.keys(targeting).length) spec.targeting = targeting;
// instant/charges skills act on the next attack: mods/targeting without an explicit attack still need one
if (!Object.keys(attack).length && (kind === 'instant' || kind === 'charges') && (spec.mods || spec.targeting)) spec.attack = {};
if (Object.keys(attack).length) spec.attack = attack;
// ---- deployment cost: "立即获得N点部署费用" (冲锋号令 `cost`) — the player gains it at the start
const dpGain = !passive && /获得\d+点部署费用/.test(desc) ? g('cost') : undefined;
const gainsDp = dpGain !== undefined && dpGain > 0;
// an AUTO skill with nothing else to do fires as soon as its SP is full (德克萨斯's kit casts 冲锋号令·γ型 so)
if (gainsDp && sk.skillType === 'AUTO' && !spec.mods && !spec.targeting && !spec.attack) spec.trigger = 'SP_FULL';
// ---- start / end effects
const starts = [];
const ends = [];
if (gainsDp) {
starts.push(({ battle, unit }) => {
battle.addDp(unit.ownerId, dpGain);
battle.fx('dp', { x: unit.x, y: unit.y, n: dpGain, id: unit.id });
});
}
const hr = g('hp_ratio');
if (hr !== undefined && hr > 0 && hr <= 1 && /立即流失\d+(\.\d+)?%(的)?当前生命/.test(desc)) {
starts.push(({ battle, unit }) => { const loss = unit.hp * hr; if (loss > 0 && unit.hp - loss >= 1) battle.loseHp(unit, loss, { source: unit }); });
} else if (hr !== undefined && hr > 0 && hr <= 1 && /(恢复|回复)[^。,]*生命/.test(desc) && !/流失/.test(desc)) {
starts.push(({ battle, unit }) => battle.heal(unit, unit, unit.s.maxHp * hr, { self: true }));
}
const shieldRatio = /屏障/.test(desc) ? (g('shield_max_hp_ratio') ?? (/生命(上限|值)?\d+(\.\d+)?%的屏障/.test(desc) ? hr : undefined)) : undefined;
if (shieldRatio !== undefined && shieldRatio > 0) {
const dur = g('shield_max_duration') ?? num(bb.duration) ?? (sk.duration > 0 ? sk.duration : 0);
const decays = /衰减/.test(desc) && dur > 0;
starts.push(({ battle, unit }) => {
const total = unit.s.maxHp * shieldRatio;
battle.addBuff(unit, {
key: `generic:shield:${sk.id ?? 'skill'}`, shield: total, duration: dur > 0 ? dur : Infinity, visible: true, interval: decays ? 0.5 : 0,
onTick: decays ? ({ unit: u, buff }) => { buff.shield = Math.max(0, buff.shield - total * 0.5 / dur); u.markDirty(); } : null,
});
});
}
const burst = (scale) => ({ battle, unit }) => {
const type = dmgTypeFromText(desc, unit.profile?.dmgType ?? 'phys');
for (const e of enemiesInRange(battle, unit, 0)) {
const dealt = battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type, isSkill: true, tags: ['skill', 'burst'] });
applyElement(battle, unit, e, dealt);
}
};
if (startBurst !== undefined && startBurst > 0) starts.push(burst(startBurst));
if (startStatuses.length) {
const n = mt !== undefined && mt > 0 ? Math.floor(mt) : 0;
starts.push(({ battle, unit }) => {
for (const e of enemiesInRange(battle, unit, n)) for (const s of startStatuses) battle.applyStatus(e, s.key, { duration: s.duration, source: unit });
});
}
if (endBurst !== undefined && endBurst > 0) {
const b = burst(endBurst);
ends.push((ctx) => { if (ctx.reason !== 'death' && ctx.unit.alive) b(ctx); });
}
if (selfStunOnEnd > 0) {
ends.push((ctx) => { if (ctx.reason !== 'death' && ctx.unit.alive) ctx.battle.applyStatus(ctx.unit, 'stun', { duration: selfStunOnEnd, source: ctx.unit }); });
}
if (starts.length) spec.onStart = (ctx) => { for (const f of starts) f(ctx); };
if (ends.length) spec.onEnd = (ctx) => { for (const f of ends) f(ctx); };
if (aura) {
const m = { ...debuff };
spec.onTick = ({ battle, unit }) => {
for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true })) battle.addBuff(e, { key: debuffKey, duration: 0.5, mods: m, refresh: 'extend', source: unit });
};
}
// ---- counter-damage while the skill is active ("受到攻击时对目标造成…攻击力/防御力N%的X伤害")
if (counterText && (counterScale !== undefined || element)) {
const m = desc.match(/受到(敌人的)?攻击时[^。]*?(攻击力|防御力)(\d+(\.\d+)?)%的(物理|法术|真实)伤害/);
spec.counter = {
scale: counterScale ?? 0,
stat: m && m[2] === '防御力' ? 'def' : 'atk',
type: m ? dmgTypeFromText(m[5] + '伤害', 'phys') : 'phys',
around: /受到(敌人的)?攻击时对周围/.test(desc),
groundOnly: /周围的地面敌人/.test(desc),
cooldown: num(bb.aoe_cd) ?? 0,
applyElement: element ? applyElement : null,
};
}
return spec;
}
/** Per-unit hooks for the generic spec (counter-damage). */
function installGeneric(spec) {
const c = spec && spec.counter;
if (!c) return null;
return (battle, unit) => {
let readyAt = -Infinity;
battle.on('damaged', (ctx) => {
if (ctx.target !== unit || !unit.alive || !unit.skill?.active || battle.time < readyAt) return;
const src = ctx.source;
if (!src || src.side !== 'enemy' || !ctx.dmg || !ctx.dmg.isAttack) return;
readyAt = battle.time + c.cooldown;
const amount = (c.stat === 'def' ? unit.s.def : unit.s.atk) * c.scale;
const victims = c.around ? battle.foesInRadius(unit.x, unit.y, 1.5).filter((e) => !(c.groundOnly && e.isFlying)) : (src.alive ? [src] : []);
for (const e of victims) {
const dealt = amount > 0 ? battle.dealDamage(unit, e, { amount, type: c.type, canDodge: false, isSkill: true, tags: ['counter'] }) : 0;
if (c.applyElement) c.applyElement(battle, unit, e, dealt);
}
}, { owner: unit });
};
}
/** Stat talents genericTalents applies: the stat named in the text → [blackboard key, mod for "+N%", mod for "+N"]. */
const TALENT_STATS = Object.freeze({
攻击力: ['atk', 'atkPct', 'atkFlat'], 防御力: ['def', 'defPct', 'defFlat'], 生命上限: ['max_hp', 'hpPct', 'hpFlat'],
攻击速度: ['attack_speed', null, 'aspd'],
});
const STAT_CLAUSE = /^(攻击力|防御力|生命上限|攻击速度)\+(\d+(?:\.\d+)?)(%?)$/;
/**
* The mods of an unconditional stat talent — its text is only stat clauses ("攻击力+8%", "攻击速度+9", joined by "," /
* "、") and each clause's number is its blackboard key's (×100 for a percentage); every other blackboard key is 0 —
* else null.
*/
export function statTalentMods(t) {
const text = String(t?.description ?? t?.desc ?? '').trim();
const bb = t?.bb ?? {};
if (!text) return null;
const mods = {};
const used = new Set();
for (const part of text.split(/[,、,]/)) {
const m = STAT_CLAUSE.exec(part.trim());
if (!m) return null;
const [key, pctMod, flatMod] = TALENT_STATS[m[1]];
const v = num(bb[key]);
const pct = m[3] === '%';
const mod = pct ? pctMod : flatMod;
if (v === undefined || !mod || used.has(key) || Math.abs((pct ? v * 100 : v) - Number(m[2])) > 1e-6) return null;
mods[mod] = v;
used.add(key);
}
if (Object.keys(bb).some((k) => !used.has(k) && num(bb[k]) !== 0)) return null;
return Object.keys(mods).length ? mods : null;
}
/**
* Talents the generic kit can apply exactly — the 补位 stand-ins without a kit of their own (content/index.js
* setupUnitKit; 预备干员 "攻击提升 攻击力+8%", "防御提升 防御力+10%", "施法速度提升 攻击速度+9"): every unconditional stat
* talent (statTalentMods) as a persistent buff, as the operator kits write theirs (`talent:` key, persist, allowDead).
* Any other talent — a condition, a proc, a key its text does not state — needs a hand-authored kit and is left out,
* never half applied.
* @param {object} def normalised operator def
* @returns {{ install(battle: object, unit: object): void }[]}
*/
export function genericTalents(def) {
const out = [];
(def?.talents ?? []).forEach((t, i) => {
const mods = statTalentMods(t);
if (!mods) return;
const key = `talent:generic:${i}`;
out.push({ install(battle, unit) { battle.addBuff(unit, { key, mods: { ...mods }, persist: true, allowDead: true, tags: ['talent'] }); } });
});
return out;
}
/**
* Generic kit: `(bb, chess, def?) => Kit`. `chess` is the data record; `def` the normalised def when available.
*/
export function genericKit(bb, chess, def = null) {
const sk = def?.skill ?? normalizeSkill(chess);
if (!sk) return { skill: null, talents: [], generic: true };
const spec = genericSkillSpec(sk, bb && Object.keys(bb).length ? bb : sk.bb, def);
const kit = { skill: spec, talents: [], generic: true };
const inst = installGeneric(spec);
if (inst) kit.install = inst;
return kit;
}
export default genericKit;