Files
ark-stronghold-protocol/server/sim/content/tokens.js
T
Cloudnyco ae22335cfe feedback7: the battle sim uses engine-independent math — Chrome, Firefox, Safari and the server agree bit for bit (PR #373 by @Cloudnyco)
- server/sim/detmath.js: hypot (sqrt of the squares, rescaled outside 2^±500), powi (integer powers in double-double,
  one rounding), sin / cos (fdlibm 5.3) and atan2 (fdlibm / FreeBSD msun) built only from correctly rounded IEEE
  operations; the sim's Math.hypot / sin / cos / atan2 / pow calls go through it (65 files); eslint.config.js forbids
  the approximated Math functions under server/sim and test/sim/detmath.test.js scans for them.
- sin / cos / atan2 are the algorithms V8 runs in Node, so the server's results stay; only hypot differs from V8's.
  Golden: 2 moved — diy-027 (a hypot distance, damage +80) and coop3-ABYSS-5 (a later card pick from round 9, same end).
- Applied unchanged after review (Grok static review: MERGE; the diff read in full: no network, no new dependency).
- Tests: broad node --test 5155 / 5155 with the new file tracked (detmath 7 / 7, package 10 / 10); lint 0 errors,
  typecheck, check:imports.
2026-10-08 18:35:55 +08:00

1772 lines
102 KiB
JavaScript
Raw 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/tokens.js — summons / tokens (data/tokens.json), the 炎 bond summon “炎佑” and band map characters.
//
// Kits: `kits[tokenId] = (bb, raw, def) => Kit` (content/index.js resolves token kits here). bb = the token's skill
// blackboard of the owner-level variant, raw = the tokens.json record, def = normalised token def (variant merged:
// stats / talents / trait / skill at the owner's phase — a golden owner gets the `_b` variant).
// Every tokenId of data/tokens.json has a kit, so `battle.spawnToken(owner, tokenId, r, c)` works with data defaults
// even when the summoner's kit passes no options:
// 医疗探机 heal profile (data), untargetable, self-destructs after its withdraw skill time (10 s); a countdown
// summon (COUNTDOWN_SUMMONS: 无敌, 禁疗, its bar = the life left)
// 诅咒娃娃 no attack, aura: enemies in its range ATK/DEF + bb.atk/bb.def (−25 %/−30 %), 15 s (skill duration; a
// countdown summon);
// leaves when 巫恋 leaves (PRTS 备注) — the drone stays when 赫默 leaves (PRTS 医疗探机 备注)
// 沙之碑 on appear: owner ATK × atk_scale arts + stun (skill range 3×3), blocks 3 (no attack), talent duration 20 s
// 战术装备 on appear: stun around (bb.stun), blocked enemies DEF + talent def (−160), talent duration 25 s
// “小自在” arts melee blocker, talent duration 25 s; kills emit `summonKill` (夕's 化境 is the 夕 kit's job)
// 斯卡蒂的海嗣 untargetable range extension of 浊心斯卡蒂: her trait's 生命回复速度 on the allies in its range (owner
// ATK × trait ratio /s);
// while the owner's skill runs: owner ATK × atk_scale true dmg/s to enemies + 鼓舞 owner ATK × bb.atk;
// talent duration 25/30 s (a countdown summon), then redeploys after respawnTime (30/25 s, DP cost from data)
// “耀阳” on appear: owner ATK × atk_scale true + stun in the skill grid (+1 hit if the previously deployed
// operator is 卡西米尔); golden trait ×atk_scale vs blocked enemies; lasts while the owner's skill runs
// 纸偶 on appear: token ATK × damage_scale arts to the 8 surrounding tiles; does not block (data)
// 狼群 “狼影” 2→3 (+1 per talent interval): block = shadows, hits = shadows; fatal with >1 shadow ⇒ lose one,
// full HP; its and 伺夜's attacks ignore def_penetrate_fixed DEF of enemies it blocks; while 伺夜's
// timed skill runs every attack damage instance (each bite) of the pack / 伺夜 on an enemy it blocks
// adds 伺夜 ATK × bb scale arts; module: ×damage_scale damage from enemies it blocks; is its owner's
// tactician 援军 (1.5× trait), leaves with it; the fatal hit on its last shadow (or a 撤退) ⇒ 战术点形态
// for the talent interval, then back on its tile with 1 shadow (installWolfTacticalPoint, both packs)
// 流形 copy skill (SP from data, golden +sp; starts only when an operator can be copied, else waits ready):
// copies scale × HP/ATK/DEF/RES, block, BAT/ASPD, range and the damage type of the nearest allied
// operator; no attack before its copy; ranged copy splits every N attacks (clone lasts the talent
// interval), melee copy steals ATK/DEF per hit (capped); killed ⇒ back uncopied after the talent interval
// (never while 缪尔赛思 is down: her redeploy brings it back; split clones are never her 援军)
// 香槟炸弹 untargetable trap: the first ground enemy touching its tile takes owner ATK × atk_scale phys (+1 hit
// after duration_switch s on the field) and sluggish; the bomb is used up ('expired', never a knock-out;
// its blast fx carries `consumed: true` so clients play the explosion, not a death sound)
// 从不混淆的方向 untargetable marker; when the owner's skill ends it vanishes and the owner returns to its tile
// 黄金盟誓 attacks deal true damage (trait); lasts while the owner's skill runs; 维娜 S3 places one on every
// free deployable tile around her (kits/ops/chess_char_6_07-siege2.js) — no per-owner deploy limit (SKILL_SUMMON_UNCAPPED)
// 防护单元 untargetable, invulnerable device placed by the player (a hand piece, user playtest #6): shield =
// 凯瑟琳 max HP × max_shield_ratio on the operator in its range (range 1-1: the tile it faces; effects do
// not stack — `cathy:shield`, read by 凯瑟琳 S1 岁月锻打), in full whenever it takes a new operator,
// refilled by shield_ratio_each_trigger /s when the target was not hit for `interval` s (always while
// 凯瑟琳's timed skill runs, owner bb overwrite_ratio)
// 投递坐标 / 风雪之眼 / 保护目标 inert pieces (no attack; 风雪之眼 untargetable) — their effects belong to the owner kit
// 炎佑 (enemy_9012_acloon) flying ally: flies after the highest-aggro enemy of the field and hovers over it, stays
// put when there is none; 3-target arts + burn on every hit, 元素脆弱 aura, 祛恶之焰 channel (yanyouKit)
// 预备干员-医疗 / Touch (band map characters) generic kit / 恳切福音 kit
// Common rules: deploy limit per owner for tokens running these kits (data `deployLimit`; the oldest is withdrawn —
// except SKILL_SUMMON_UNCAPPED, whose data limit is the hand count the skill does not obey),
// `summonKill` hook ({ token, owner, victim }) whenever a token kills, lifetimes never outlive a shorter
// `opts.duration`; summon tiles skip `battle.isReservedTile` (home tiles of pieces not deployed yet).
// Managed mode: when the summoner runs a hand-authored kit (content/kits), the owner-coupled parts of its summon
// (海嗣 pulses/lifetime/respawn, 纸偶 burst, 流形 copy/steal/split/respawn/guard, 狼群 S3 bonus & DEF ignore) are
// left to that kit; kits that pass `opts.kit` to spawnToken replace these token kits entirely.
// Tacticians (伺夜/缪尔赛思): the player's board 狼群/流形 piece deploys right before its owner so the owner's kit
// sees its 援军 standing; with the engine's default reinforcement the talent token replaces the generic 援军. Without
// a board piece the token takes a tactical point: a free walkable tile of the owner's range on an enemy ground path
// first (`tacticalPoint`), then the one nearest to the owner.
// Placed summons (PRTS 卫戍协议/帮助 §作战阶段; user playtest #6): every board summon piece marks the tile its summon
// deploys on. The talent ones the owner holds from the start (狼群, 海嗣, 流形, 凯瑟琳's devices) deploy with the
// board, after the operators. A skill's summon (赫默's 医疗探机, 巫恋's 诅咒娃娃: "获得一个…") also deploys once with the
// board, for free and ignoring the holding ("在赫默的技能未开启前未持有该召唤物,但作战开始时无视持有状态自动部署1个";
// settled by the user after playtest #6 — shared/constants.js SKILL_SUMMON_START_DEPLOY, re-exported here; false =
// the playtest #4 reading, `deferDeploy`: off the field until the skill). Then it is docked on its tile
// (`dockSkillSummons`; the tile stays reserved) and takes the field there each time the owner's skill gives one
// (`releaseSkillSummon`; PRTS "若战场区初始部署有召唤物,若召唤物在战斗期间退场,将在满足条件后立即原地再部署1个"): one
// in stock at most ("最多可库存1个"), after the token's redeploy time once it left, free [ASSUMED: no DP], never while
// its owner is off the field — a stocked one deploys as soon as the owner is back. A skill's summon or a device
// (凯瑟琳) that was not placed never appears (the hidden 待部署区 deploys nothing by itself), nor does 海嗣; only the
// tacticians' 狼群 / 流形 still come as 援军 on a tactical point (above).
// Fallbacks (only while the summoner still uses the generic kit — a hand-authored kit takes over): skill summons
// (赫默/巫恋 through their placed pieces as above; 蜜蜡/风丸/维娜/耀骑士临光/迷迭香 S3 on a tile of their own) spawn at
// skill start — also under a hand-authored kit when only the SELECTED skill runs the generic spec (a non-default skill
// the kit has no `skills` entry for) — and 夕 spawns 小自在 on its first attack.
// Operator loadouts (DESIGN §16): variants, `sources` and counts are those of the owner's selected skill / module
// (variantOf / tokenSources: getToken(id, owner.defId, owner.def.loadout)); a skill summon runs only when that skill
// 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,
// COUNTDOWN_SUMMONS / startCountdown (the countdown summons' 无敌 + 禁疗 and timer bar, shared with the kits that time them),
// wolfShadowInterval, installWolfTacticalPoint, wolfTacticalPoint, wolfReturnNow (the 狼群's 战术点形态, shared with the
// 伺夜 kit's own pack, kits/ops/chess_char_3_19-vigil.js), 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, FORCED_EXIT } from '../constants.js';
import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy } from '../targeting.js';
import { bodyInKeys, bodyOnTile } from '../body.js';
import { hasHp } from '../damage.js';
import { genericKit } from './generic.js';
import { bardRegen } from '../professions.js';
import { normDir, localOrder } from '../dir.js';
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
import { hypot } from '../detmath.js';
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d));
const GRID_3X3 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
const GRID_PLUS = Object.freeze([[1, 0], [0, -1], [0, 0], [0, 1], [-1, 0]]);
/** Buff key of the 爬行号·防护单元 shield on an operator (凯瑟琳 S1 岁月锻打 buffs the operators holding one). */
export const CAT_SHIELD_KEY = 'cathy:shield';
export const TOKEN_IDS = Object.freeze({
healDrone: 'token_10000_silent_healrb',
curseDoll: 'token_10006_vodfox_doll',
obelisk: 'token_10011_beewax_oblisk',
rosmonGear: 'token_10012_rosmon_shield',
duskDragon: 'token_10015_dusk_drgn',
seaborn: 'token_10017_skadi2_dedant',
radiantSword: 'token_10019_nearl2_sword',
paperDoll: 'token_10022_kazema_shadow',
wolfPack: 'token_10028_vigil_wolf',
manifold: 'token_10030_mlyss_wtrman',
champagne: 'token_10031_swire2_gdtrap',
ulpiaMarker: 'token_10039_ulpia_block',
goldenOath: 'token_10040_siege2_vlion',
catShield: 'token_10041_cathy_catsld',
deliveryTarget: 'token_10056_angel2_target',
eagle1: 'token_10057_svash2_eagle1',
eagle2: 'token_10057_svash2_eagle2',
eagle3: 'token_10057_svash2_eagle3',
iceTarget: 'token_10058_sbell2_icetgt',
yanyou: 'enemy_9012_acloon',
reserveMedic: 'char_605_cmedic',
touch: 'char_613_acmedc',
});
// ---------------------------------------------------------------------------------------------------------------
// battle state & small helpers
const STATE = new WeakMap();
function stateOf(battle) {
let s = STATE.get(battle);
if (!s) { s = { lastOp: new Map(), catTargets: new Map() }; STATE.set(battle, s); }
return s;
}
/**
* A tokens.json owner variant with the owner's selected skill / module applied (DESIGN §16; the merge of simdata
* getToken): `bySkill[skillIndex]` (token skill, count, sources) and `byModule[moduleId]` (both hold non-default
* choices only). `lo` = the owner def's `loadout`.
*/
function withLoadout(v, lo) {
if (!v || !lo || typeof lo !== 'object') return v;
let out = v;
if (Number.isInteger(lo.skillIndex) && v.bySkill && v.bySkill[lo.skillIndex]) out = { ...out, ...v.bySkill[lo.skillIndex] };
if (typeof lo.moduleId === 'string' && v.byModule && v.byModule[lo.moduleId]) out = { ...out, ...v.byModule[lo.moduleId] };
return out;
}
/**
* The owner's own variant of a token record (chess id, else its normal `_a` sibling; a 自选 piece's: its owner form,
* `def.tokenOwner`) with its loadout; null if none.
*/
function ownVariant(raw, owner) {
const vs = raw?.variants;
const oid = owner?.def?.tokenOwner ?? owner?.defId;
if (!vs || typeof vs !== 'object' || !oid) return null;
const v = vs[oid] ?? vs[String(oid).replace(/_b$/, '_a')] ?? null;
return v ? withLoadout(v, owner.def?.loadout) : null;
}
/**
* Owner-level tokens.json variant of a token unit for its owner's selected skill / module (the def already merges it;
* this reads fields normaliseToken drops). Without an own variant of the owner: the record's first variant.
*/
function variantOf(unit) {
const vs = unit.def?.raw?.variants;
if (!vs || typeof vs !== 'object') return null;
return ownVariant(unit.def.raw, unit.ownerUnit) ?? Object.values(vs)[0] ?? null;
}
/** First talent blackboard of `def` holding `key`. */
function talentWith(def, key) {
for (const t of def?.talents ?? []) if (t && t.bb && t.bb[key] != null) return t;
return null;
}
const talentBb = (def, key) => talentWith(def, key)?.bb ?? {};
/** Summoner ATK ("相当于攻击力…%" of a summon skill is the summoner's); the token's own ATK without an owner. */
const ownerAtk = (u) => (u.ownerUnit && u.ownerUnit.s ? u.ownerUnit.s.atk : u.s.atk);
const ownerOf = (u) => (u.ownerUnit && u.ownerUnit.side === 'ally' ? u.ownerUnit : null);
/**
* The summoner runs a hand-authored kit (content/kits): owner-coupled effects of its summon (auras driven by the
* owner, copies, respawns, owner-skill bonuses) are that kit's job — the token kit keeps only its intrinsic rules.
* Evaluated lazily: board tokens may be set up before their owner's kit is resolved.
*/
const managed = (u) => !!(u.ownerUnit && u.ownerUnit.kit && u.ownerUnit.kit.generic !== true);
const baseKey = (id) => String(id ?? '').replace(/_[ab]$/, '');
function onDeploy(battle, unit, fn, priority = 0) {
battle.on('deploy', (ctx) => { if (ctx.unit === unit) fn(ctx); }, { owner: unit, priority });
}
function untargetable(battle, unit) {
battle.addBuff(unit, { key: 'trait:untargetable', flags: { untargetable: true }, persist: true, allowDead: true });
}
/**
* Countdown summons — the summons that "不会受到攻击" and leave after a fixed time: 赫默's 医疗探机 (10 s, skcom_withdraw),
* 巫恋's 诅咒娃娃 (15 s, its skill), 浊心斯卡蒂's 斯卡蒂的海嗣 (talent 远古血亲 25 / 30 s) and, among the 自选 operators, 温蒂's
* 工程蓄水炮 (20 s), 莱伊's 沙地兽 (25 s), 鸿雪's “打字机” (25 s) and 酒神's 本能的召唤 (10 s). Community report of 2026-10-06
* (the official mode): they take no outside damage — 活性源石 included —, no operator heals them and their bar runs down
* like a timer, the summon leaving when it is empty. PRTS / client data: 海嗣 and 沙地兽 hold 无敌 + 禁疗 (PRTS 海嗣 备注
* 「持有禁疗、无敌」; token prefabs: abnormal flags 5 + 7), 工程蓄水炮 禁疗 (PRTS 备注); the others have no abnormal flag in the
* base-game prefabs — [ASSUMED] the report's 无敌 + 禁疗 for every one of them. startCountdown gives them both (their HP never
* moves) and the bar (Unit.countdown, shown by snapshot.js as the share of the life left). The other timed summons (沙之碑,
* 战术装备, “小自在”, 结构性原理 …) block and are attacked: their HP stays HP. Until 0.2.0 a 海嗣 on 活性源石 lost 70 HP/s (of
* 100) and a hurt drone drew every medic's heals.
*/
export const COUNTDOWN_SUMMONS = Object.freeze(new Set([
TOKEN_IDS.healDrone, TOKEN_IDS.curseDoll, TOKEN_IDS.seaborn,
'token_10009_weedy_cannon', 'token_10034_ray_sndbst', 'token_10026_bgsnow_subbow', 'token_10054_phatm2_encdool',
]));
const COUNTDOWN_KEY = 'token:countdown';
/**
* A countdown summon's life on the field starts (COUNTDOWN_SUMMONS; call it from the `deploy` hook of each deployment,
* where its kit schedules its withdrawal): `seconds` from now its bar is empty. It holds 无敌 and 禁疗 from then on. A
* summon that is not a countdown one is left alone (false).
*/
export function startCountdown(battle, unit, seconds) {
const s = num(seconds, 0);
if (!unit || !COUNTDOWN_SUMMONS.has(unit.defId) || !(s > 0)) return false;
unit.countdown = { from: battle.time, until: battle.time + s };
if (!unit.findBuff(COUNTDOWN_KEY)) battle.addBuff(unit, { key: COUNTDOWN_KEY, flags: { invulnerable: true, noHeal: true, healFree: true }, persist: true, allowDead: true });
return true;
}
/** Withdraw the token `seconds` after this deployment (the same deployment only); a countdown summon's bar runs with it. */
function scheduleLifetime(battle, unit, seconds) {
const s = num(seconds, 0);
if (!(s > 0)) return;
startCountdown(battle, unit, s);
const seq = unit.deploySeq;
unit.mem.expiresAt = battle.time + s;
battle.after(s, () => { if (unit.alive && unit.deploySeq === seq) battle.retreat(unit, { reason: 'expired', permanent: true }); }, { owner: unit });
}
/** Visible enemies whose body is on any of the absolute tile keys (sim/body.js); auras / bursts hit stealthed ones. */
function enemiesOnKeys(battle, keys) {
const set = keys instanceof Set ? keys : new Set(keys);
const out = [];
for (const e of battle.enemies) {
if (!e.alive || e.hidden) continue;
if (bodyInKeys(e, set)) out.push(e);
}
return out;
}
/**
* Appear burst: damage (optional) + status (optional) on the enemies of `grid` around the token. Returns victims. Air
* units (Unit.isFlying) are hit too **[ASSUMED]**: PRTS has no 对空 note on any of them — 沙之碑 (蜜蜡 S2), 迷迭香的战术装备
* ("对自身攻击范围内的敌人造成晕眩"), “耀阳”, 纸偶 (风丸 折纸生花) — so a stun here also drops a hovering 掠海漂移体.
*/
function burst(battle, unit, { grid, amount = 0, type = 'phys', stun = 0, hits = 1, source = unit, fx = 'summonBurst' }) {
const keys = absoluteRangeKeys(grid || GRID_3X3, unit.tileR, unit.tileC, unit.dir, 0);
const victims = enemiesOnKeys(battle, keys);
for (const e of victims) {
for (let i = 0; i < hits && e.alive && amount > 0; i++) battle.dealDamage(source, e, { amount, type, isSkill: true, canDodge: false, tags: ['summon', 'burst'] });
if (stun > 0 && e.alive) battle.applyStatus(e, 'stun', { duration: stun, source });
}
battle.fx(fx, { x: unit.x, y: unit.y, id: unit.id, token: unit.defId, n: victims.length });
return victims;
}
/**
* Redeploy a placeable token after it was killed (and optionally after it expired), like operators: `delay` s later,
* on its own tile, paying its DP cost. Tokens are otherwise removed for good by the engine — the piece is kept
* (`removed = false`) so its hooks survive and `battle.redeploy` accepts it.
*/
function enableRespawn(battle, unit, { delay, onExpire = false, unlessManaged = false, requireOwner = false }) {
battle.on('death', (ctx) => {
if (ctx.unit !== unit || battle.finished || unit.mem.isClone || (unlessManaged && managed(unit))) return;
if (unit.mem.replaced) { unit.mem.replaced = false; return; }
const r = ctx.reason;
if (!(r === 'killed' || (onExpire && r === 'expired'))) return;
const d = num(typeof delay === 'function' ? delay(unit) : delay, -1);
if (!(d >= 0)) return;
unit.removed = false;
unit.mem.respawnAt = battle.time + d;
battle.every(0.25, (b, sched) => {
if (unit.alive || unit.removed) { sched.cancel(); return; }
// a tactician's 援军 never comes back without its tactician (its redeploy brings the piece back, see
// ensureReinforcement)
if ((typeof requireOwner === 'function' ? requireOwner() : requireOwner) && !(unit.ownerUnit && unit.ownerUnit.alive)) { sched.cancel(); return; }
if (b.time + 1e-9 < unit.mem.respawnAt) return;
if (b.redeploy(unit, { free: false })) sched.cancel();
}, { owner: unit });
}, { owner: unit, priority: -10 });
}
/** Lifetime bound to the owner's running timed skill (the token vanishes when that activation ends). */
function bindToOwnerSkill(battle, unit) {
onDeploy(battle, unit, () => {
const o = ownerOf(unit);
unit.mem.boundActivation = o && o.skill && o.skill.active && o.skill.isTimed ? o.skill.activations : null;
});
battle.on('skillEnd', (ctx) => {
if (!unit.alive || ctx.unit !== unit.ownerUnit || unit.mem.boundActivation == null) return;
if (ctx.skill.activations === unit.mem.boundActivation) battle.retreat(unit, { reason: 'expired', permanent: true });
}, { owner: unit });
}
// ---------------------------------------------------------------------------------------------------------------
// placed skill summons (user playtest #6; see the header "Placed summons")
// SKILL_SUMMON_START_DEPLOY (shared/constants.js — also read by the summon card's hint): true = the PRTS reading the
// user settled after playtest #6, the placed piece of a skill's summon also deploys once, for free, at the battle
// start; false = the playtest #4 reading (it comes with the skill). Re-exported for content and tests.
export { SKILL_SUMMON_START_DEPLOY };
/** Seconds between two tries of a docked piece whose tile is busy / whose redeploy time is still running. */
const DOCK_RETRY = 0.5;
/**
* How the owner's loadout makes a board summon piece of a manually deployable summon (owner-loadout `sources`):
* 'skill' (only its skill makes it), 'none' (the owner's skill / module makes none — 赫默 on S1 with a drone piece; the
* match never hands such a card out), else null (a talent summon, or not a placed hand piece).
*/
function pieceSource(battle, u) {
if (!u || u.kind !== 'token' || u.uid == null || !u.ownerUnit || u.ownerUnit.kind !== 'op') return null;
if (u.def?.raw?.placeable !== true) return null;
const src = tokenSources(battle, u.defId, u.ownerUnit);
if (src.includes('talent')) return null;
if (src.includes('skill')) return 'skill';
return src.length ? 'none' : null;
}
/**
* Dock the board pieces of skill summons (before the start): off the field with the board (`deferDeploy`, unless
* SKILL_SUMMON_START_DEPLOY), and whenever one leaves (10 s drone, 15 s doll) it stays its tile's piece — not removed,
* its hooks kept, Battle.isReservedTile — ready again after the token's redeploy time (data respawnTime, 5 s). A piece
* its owner's loadout does not make never deploys.
*/
function dockSkillSummons(battle) {
for (const u of battle.allyUnits) {
const how = pieceSource(battle, u);
if (how === 'none') { u.deferDeploy = true; continue; }
if (how !== 'skill') continue;
u.mem.docked = true;
u.mem.readyAt = -Infinity;
if (!SKILL_SUMMON_START_DEPLOY) u.deferDeploy = true;
battle.on('death', (ctx) => {
if (ctx.unit !== u || battle.finished) return;
u.removed = false;
u.mem.readyAt = battle.time + Math.max(0, num(u.base.respawnTime, 0));
deployDocked(battle, u);
}, { owner: u, priority: -10 });
// the owner back on the field with one in stock: the condition of the PRTS redeploy is met at once
battle.on('deploy', (ctx) => {
if (ctx.unit === u.ownerUnit && !battle.finished) deployDocked(battle, u);
}, { owner: u });
}
}
/**
* Deploy a docked piece on its tile when its owner holds one and it is ready; retried while it waits. Not while the
* owner is off the field [ASSUMED: as in the base game, a summon is deployed only while its owner stands] — the stock
* stays, and the piece deploys as soon as the owner is back (its `deploy` hook, dockSkillSummons).
*/
function deployDocked(battle, u) {
const stock = u.ownerUnit?.mem.summonStock;
if (!stock || !(stock[u.defId] > 0) || u.alive || u.removed || battle.finished || !u.ownerUnit.alive) return false;
const wait = u.mem.readyAt - battle.time;
if (wait > 1e-9 || !battle.redeploy(u, { free: true })) {
if (!u.mem.dockRetry) {
u.mem.dockRetry = true;
battle.after(Math.max(wait, DOCK_RETRY), () => { u.mem.dockRetry = false; deployDocked(battle, u); }, { owner: u });
}
return false;
}
stock[u.defId]--;
battle.fx('summon', { x: u.x, y: u.y, id: u.id, token: u.defId });
return true;
}
/**
* The owner's skill gives it one `tokenId` ("获得一个医疗无人机"; at most `cap` in stock — "最多可库存1个"): its placed
* piece takes the field on its own tile now, or as soon as it is ready (the previous one still up / just gone).
* Returns the piece deployed now, else null — also when the player placed none (the summon then never appears).
*/
export function releaseSkillSummon(battle, owner, tokenId, { cap = 1 } = {}) {
if (!owner) return null;
const pieces = battle.allyUnits.filter((t) => t.kind === 'token' && t.defId === tokenId && t.ownerUnit === owner && t.mem.docked);
if (!pieces.length) return null;
const stock = owner.mem.summonStock || (owner.mem.summonStock = {});
stock[tokenId] = Math.min(Math.max(1, Math.floor(num(cap, 1))), (stock[tokenId] || 0) + 1);
for (const p of pieces) if (deployDocked(battle, p)) return p;
return null;
}
function inRectTile(battle, r, c) { return Number.isInteger(r) && Number.isInteger(c) && battle.grid.inRect(r, c); }
/**
* A tile a summon may take: inside the field, nobody on it, and not the home tile of a board unit that has not
* deployed yet / waits to redeploy (the initial deployment runs one unit after another, a summon must not steal a later
* unit's tile).
*/
export function tileFree(battle, r, c) {
return inRectTile(battle, r, c) && !battle.isReservedTile(r, c);
}
function mostAdvancedEnemy(battle) {
let best = null, bd = Infinity;
for (const e of battle.enemies) {
if (!e.alive || e.hidden) continue;
const d = battle.remainingDistance(e);
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && e.spawnSeq < best.spawnSeq)) { bd = d; best = e; }
}
return best;
}
function countNear(list, r, c, rad = 1) {
let n = 0;
for (const u of list) if (Math.abs(Math.round(u.y) - r) <= rad && Math.abs(Math.round(u.x) - c) <= rad) n++;
return n;
}
/**
* Pick a summon tile for `owner` (null when none). placement:
* 'ally' — any standable tile in the owner's range covering the most injured allies (a healing skill summon that
* is no hand piece; 医疗探机 itself is placed by the player — releaseSkillSummon)
* 'enemy' — any standable tile in range covering the most enemies (a debuff skill summon that is no hand piece;
* 诅咒娃娃 itself is placed by the player)
* 'melee' — melee-deployable tile in range, on the enemy path and near the enemies first (沙之碑)
* 'adjacent' — melee-deployable tile among the 8 surrounding tiles (纸偶, 黄金盟誓)
* 'plus' — melee-deployable tile among the 4 neighbours (“耀阳”)
* 'path' — ground-passable standable tile in range nearest to the leading enemy (香槟炸弹)
* 'near' — any standable tile nearest to opts.at ({x,y}) inside the range (小自在)
*/
export function findSummonTile(battle, owner, placement = 'melee', opts = {}) {
if (!owner || owner.tileR == null) return null;
let keys;
if (placement === 'adjacent') keys = absoluteRangeKeys(GRID_3X3, owner.tileR, owner.tileC, owner.dir, 0);
else if (placement === 'plus') keys = absoluteRangeKeys(GRID_PLUS, owner.tileR, owner.tileC, owner.dir, 0);
else if (opts.grid) keys = absoluteRangeKeys(opts.grid, owner.tileR, owner.tileC, owner.dir, 0);
else keys = owner.rangeKeys || [];
const melee = opts.melee ?? (placement === 'melee' || placement === 'adjacent' || placement === 'plus');
const enemies = battle.enemies.filter((e) => e.alive && !e.hidden);
const focus = opts.at ?? mostAdvancedEnemy(battle) ?? owner;
// injured allies a heal could reach: never 孤立 (Battle.alliesFor) nor 禁疗 units (炎佑, the 禁疗 summons)
const injured = placement === 'ally'
? battle.alliesFor(owner, owner.ownerId).filter((a) => a.hp < a.s.maxHp - 1e-6 && !(a.s.flags.noHeal || a.profile?.noHeal))
: null;
let best = null, bestS = null;
for (const k of keys) {
const r = (k / COLS) | 0, c = k % COLS;
if (!tileFree(battle, r, c)) continue;
if (!battle.grid.canStand(r, c, { ranged: !melee })) continue;
const gp = battle.grid.groundPassable(r, c, true) ? 1 : 0;
if (placement === 'path' && !gp) continue;
let cover = 0;
if (placement === 'enemy' || placement === 'melee') cover = countNear(enemies, r, c, 1);
else if (placement === 'ally') cover = countNear(injured, r, c, 1);
const d = placement === 'ally' ? hypot(c - owner.x, r - owner.y) : hypot(c - focus.x, r - focus.y);
// last: the tile's offset in the owner's facing-RIGHT frame (for a RIGHT owner = the tile key order), so equal
// candidates resolve the same way whichever direction the owner faces
const [lr, lc] = localOrder(r - owner.tileR, c - owner.tileC, owner.dir);
const s = [-cover, placement === 'ally' || placement === 'enemy' || placement === 'near' ? 0 : -gp, d, lr, lc];
let less = !bestS;
if (!less) for (let i = 0; i < s.length; i++) { if (s[i] < bestS[i] - 1e-9) { less = true; break; } if (s[i] > bestS[i] + 1e-9) break; }
if (less) { best = [r, c]; bestS = s; }
}
return best;
}
/** Spawn `tokenId` for `owner` on a tile chosen by findSummonTile. Returns the token or null. */
export function summonToken(battle, owner, tokenId, placement = 'melee', opts = {}) {
const tile = opts.tile ?? findSummonTile(battle, owner, placement, opts);
if (!tile) return null;
return battle.spawnToken(owner, tokenId, tile[0], tile[1], opts.spawn ?? {});
}
// ---------------------------------------------------------------------------------------------------------------
// token kits
const inert = ({ hideFromEnemies = false, block = null } = {}) => () => ({
skill: null,
trait: { noAttack: true },
install(battle, unit) {
if (hideFromEnemies) untargetable(battle, unit);
if (block != null) battle.addBuff(unit, { key: 'token:block', persist: true, allowDead: true, mods: { blockCnt: block } });
},
});
/** 医疗探机 (赫默 S2): heals nearby allies (data heal profile), self-destructs after the withdraw skill time. */
function healDrone() {
return {
skill: null,
install(battle, unit) {
const v = variantOf(unit);
const sk = v?.skill ?? unit.def?.raw?.skill ?? null;
let life = /^skcom_withdraw/.test(String(sk?.skillId ?? sk?.id ?? '')) ? num(sk.duration, 0) : 0;
if (!(life > 0)) { const m = String(sk?.desc ?? sk?.description ?? '').match(/(\d+(?:\.\d+)?)秒后自动销毁/); if (m) life = +m[1]; }
onDeploy(battle, unit, () => scheduleLifetime(battle, unit, life));
},
};
}
/** 诅咒娃娃 (巫恋 S2): ATK/DEF debuff aura on the enemies in its range; lasts its skill duration. */
function curseDoll(bb, raw, def) {
const mods = {};
if (num(bb.atk, 0) !== 0) mods.atkPct = num(bb.atk, 0);
if (num(bb.def, 0) !== 0) mods.defPct = num(bb.def, 0);
const life = num(def?.skill?.duration, 0);
const AURA = 0.2;
return {
skill: {
kind: 'passive',
onStart({ unit }) { unit.mem.auraAcc = AURA; },
onTick({ battle, unit, dt }) {
unit.mem.auraAcc = (unit.mem.auraAcc ?? 0) + (dt ?? battle.dt);
if (unit.mem.auraAcc < AURA - 1e-9) return;
unit.mem.auraAcc = 0;
for (const e of enemiesOnKeys(battle, unit.rangeKeySet || unit.rangeKeys || [])) {
battle.addBuff(e, { key: 'token:curseDoll', duration: AURA + 0.15, refresh: 'extend', mods, source: unit });
}
},
},
trait: { noAttack: true },
install(battle, unit) {
onDeploy(battle, unit, () => scheduleLifetime(battle, unit, life));
// PRTS 诅咒娃娃 备注: "巫恋退场时强制撤退场上的诅咒娃娃" (knocked out or withdrawn)
battle.on('death', (ctx) => { if (ctx.unit === unit.ownerUnit && unit.alive) battle.retreat(unit, { reason: 'expired', permanent: true }); }, { owner: unit });
},
};
}
/** 沙之碑 (蜜蜡 S2): appear burst (owner ATK × atk_scale arts + stun, skill range), blocks 3, talent duration. */
function obelisk(bb, raw, def) {
const grid = def?.skill?.rangeGrid ?? GRID_3X3;
const life = num(talentBb(def, 'duration').duration, 0);
return {
skill: null,
trait: { noAttack: true }, // a blocking pillar (the 蜜蜡 kit's obelisk does not attack either) [ASSUMED]
install(battle, unit) {
onDeploy(battle, unit, () => {
burst(battle, unit, { grid, amount: ownerAtk(unit) * num(bb.atk_scale, 0), type: 'arts', stun: num(bb.stun, 0), source: unit });
scheduleLifetime(battle, unit, life);
});
},
};
}
/** 迷迭香的战术装备 (display-only for the pool's S2): appear stun, blocked enemies DEF + talent def, talent duration. */
function rosmonGear(bb, raw, def) {
const t = talentBb(def, 'duration');
const life = num(t.duration, 0);
const defFlat = num(t.def, 0);
return {
skill: null,
trait: { noAttack: true },
install(battle, unit) {
onDeploy(battle, unit, () => {
burst(battle, unit, { grid: def?.skill?.rangeGrid ?? GRID_3X3, stun: num(bb.stun, 0), fx: 'summonStun' });
scheduleLifetime(battle, unit, life);
});
if (defFlat !== 0) {
battle.on('tick', () => {
if (!unit.alive || !unit.blocking.length) return;
for (const e of unit.blocking) if (e.alive) battle.addBuff(e, { key: 'token:rosmonGear', duration: 0.2, refresh: 'extend', mods: { defFlat }, source: unit });
}, { owner: unit });
}
},
};
}
/** “小自在” (夕 talent 点睛): arts melee blocker with a talent lifetime. */
function duskDragon(bb, raw, def) {
const life = num(talentBb(def, 'duration').duration, 0);
return {
skill: null,
install(battle, unit) {
onDeploy(battle, unit, () => scheduleLifetime(battle, unit, unit.mem.lifeOverride ?? life));
},
};
}
/**
* 斯卡蒂的海嗣 (浊心斯卡蒂 talent 远古血亲): range extension of its owner (her trait's 生命回复速度 — professions.js bardRegen,
* keyed by the owner: one trait effect per ally — / S3 damage + 鼓舞). Only while the owner fights with the generic kit:
* her own kit (kits/ops/chess_char_6_04-skadi2.js) covers the 海嗣' ranges itself.
*/
function seaborn(bb, raw, def) {
const life = num(talentBb(def, 'duration').duration, 0);
const healRatio = num(def?.traitBb?.['attack@atk_to_hp_recovery_ratio'], 0);
const dmgScale = num(bb.atk_scale, 0);
const inspire = num(bb.atk, 0);
return {
skill: null,
trait: { noAttack: true },
install(battle, unit) {
onDeploy(battle, unit, () => { unit.mem.pulseAcc = 0; if (!managed(unit)) scheduleLifetime(battle, unit, life); });
enableRespawn(battle, unit, { delay: (u) => u.base.respawnTime, onExpire: true, unlessManaged: true });
battle.on('tick', ({ dt }) => {
if (!unit.alive || !unit.deployed || !unit.canAct || managed(unit)) return;
unit.mem.pulseAcc = (unit.mem.pulseAcc ?? 0) + dt;
if (unit.mem.pulseAcc < 1 - 1e-9) return;
unit.mem.pulseAcc -= 1;
const o = ownerOf(unit);
const atk = ownerAtk(unit);
const keys = unit.rangeKeySet || new Set(unit.rangeKeys || []);
const skillOn = !!(o && o.alive && o.skill && o.skill.active && o.skill.kind !== 'passive');
if (skillOn) {
for (const e of enemiesOnKeys(battle, keys)) battle.dealDamage(unit, e, { amount: atk * dmgScale, type: 'true', isSkill: true, canDodge: false, tags: ['seaborn'] });
if (inspire > 0) {
for (const a of battle.alliesInGrid(unit)) {
if (a === unit || a === o) continue; // 自身不受鼓舞影响
battle.addBuff(a, { key: `inspire:${o.id}`, duration: 1.25, refresh: 'replace', mods: { atkFlat: atk * inspire }, source: unit });
}
}
} else if (healRatio > 0) {
const ownerKeys = o && o.alive && o.deployed ? (o.rangeKeySet || new Set(o.rangeKeys || [])) : null;
for (const a of battle.alliesInGrid(unit)) {
if (a === unit) continue;
if (ownerKeys && ownerKeys.has(a.tileR * COLS + a.tileC)) continue; // the owner's own trait already covers it
bardRegen(battle, o ?? unit, a, atk * healRatio, 1.25); // refreshed every second
}
}
}, { owner: unit });
},
};
}
/**
* “耀阳” (耀骑士临光 S3): appear burst (true, owner ATK × atk_scale, stun — air units too [ASSUMED: no 对空 note on PRTS
* “耀阳”]), blocks 2, lasts while the owner's skill runs.
*/
function radiantSword(bb, raw, def) {
const grid = def?.skill?.rangeGrid ?? GRID_PLUS;
// 精锐 owner's module (isToken part): "攻击被阻挡的敌人时攻击力提升至115%" — any blocked enemy
const blockedScale = num(def?.traitBb?.atk_scale, 1);
return {
skill: null,
trait: blockedScale !== 1 ? { dmgMul: (b, u, t) => (t && t.blockedBy ? blockedScale : 1) } : {},
install(battle, unit) {
bindToOwnerSkill(battle, unit);
onDeploy(battle, unit, () => {
const last = stateOf(battle).lastOp.get(unit.ownerId);
const kaz = !!(last && (last.def?.bonds || []).includes('kazimierzShip'));
burst(battle, unit, { grid, amount: ownerAtk(unit) * num(bb.atk_scale, 0), type: 'true', stun: num(bb.stun, 0), hits: kaz ? 2 : 1, source: unit, fx: 'radiantSword' });
});
},
};
}
/** 纸偶 (风丸 S2): appear burst = its own ATK × damage_scale arts on the 8 surrounding tiles. */
function paperDoll(bb, raw, def) {
const scale = num(talentBb(def, 'damage_scale').damage_scale, 0);
return {
skill: null,
install(battle, unit) {
onDeploy(battle, unit, () => { if (scale > 0 && !managed(unit)) burst(battle, unit, { grid: GRID_3X3, amount: unit.s.atk * scale, type: 'arts', source: unit, fx: 'paperDoll' }); });
},
};
}
/** Current “狼影” count of a 狼群 token (0 when not a wolf pack, and in its 战术点形态). */
export function wolfShadows(unit) { return unit && unit.defId === TOKEN_IDS.wolfPack ? (unit.mem.shadows ?? 0) : 0; }
// ---- 狼群 战术点形态 — both packs: the board piece (wolfPack below) and the 伺夜 kit's own pack
// (kits/ops/chess_char_3_19-vigil.js). PRTS 伺夜 天赋 狼群领袖 备注 and 狼群 召唤物信息 备注: "受到致命伤时,如果狼影层数>1则
// 消耗一层狼影并重设生命值至上限,为1则消耗一层狼影变为战术点形态;手动撤退、强制撤退时狼影层数归零并变为战术点形态;战术点形态的
// 持续时间等于“狼影”恢复时间,持续时间结束后狼影层数变回1层", "战术点形态期间:不进行普通攻击,持有无敌、强制缴械、不死…",
// "持有者离场后强制撤退场上的狼群(不触发上述效果)". The remake's 战术点形态 is its knocked-out piece: off the fight
// (`alive` false: no block, no attack, not targetable) but kept (`removed` false: its hooks live on and its tile stays
// reserved — Battle.isReservedTile — as the official device holds it), with 0 狼影, for the 狼影 recovery time
// (wolfShadowInterval: the talent's interval, data); then it is redeployed on its tile, free, at full HP with 1 狼影 and a
// fresh growth cycle (the pack's own `deploy` handler reads `mem.wolfReturn`). 伺夜 S1 领袖的呼唤 ① brings it back at
// once (wolfReturnNow); every return timer carries the form's `seq`, so a stale one never revives a pack that came back
// and fell again.
/**
* Removal reasons that put a 狼群 in its 战术点形态: the fatal hit on its last 狼影 (the engine's knock-out) and a 撤退 —
* manual (`battle.retreat`'s default reason) or forced. No sim path retreats the pack that way today; 'expired' (its
* owner leaving, the deploy limit) and 'raid' (an instant redeploy) never do.
*/
const WOLF_TAC_EXITS = new Set(['killed', 'retreat', FORCED_EXIT]);
/** Seconds between two return tries of a 狼群 whose tile is taken when its 战术点形态 ends (Battle.isReservedTile keeps it). */
const WOLF_RETURN_RETRY = 0.25;
/** The 狼群领袖 talent of a 狼群 def (its 狼影 stack talent), else its first talent. */
const wolfLeader = (def) => talentWith(def, 'vigil_wolf_t_1_enhance[trigger].max_stack_cnt') ?? def?.talents?.[0] ?? null;
/**
* “狼影” recovery time of a 狼群 def: its 狼群领袖 talent interval ("每25秒增加一只"; data — 25 s for both 伺夜 chess). The
* growth cycle and the length of the 战术点形态. 0 when the data has none.
*/
export function wolfShadowInterval(def) {
const lb = wolfLeader(def)?.bb ?? {};
return Math.max(0, num(lb['vigil_wolf_t_1_enhance[trigger].interval'] ?? lb.interval, 0));
}
/** The 战术点形态 of a 狼群 (`{ seq, since, until }`) while it is in it, else null. */
export function wolfTacticalPoint(unit) {
const tp = unit?.mem?.wolfTac;
return tp && !unit.alive && !unit.removed ? tp : null;
}
/** The pack's owner stands on the field (no owner unit: a test spawn). */
const wolfOwnerStands = (u) => !u.ownerUnit || (u.ownerUnit.alive && u.ownerUnit.deployed);
/**
* Bring a 狼群 back from its 战术点形态 at once (the end of the form; 伺夜 S1 ①: "立刻切换至拥有1只狼影的召唤物形态(会更新
* 狼群的狼影刷新周期)"): redeployed on its tile, free, at full HP with 1 狼影 and a fresh growth cycle (its `deploy`
* handler reads `mem.wolfReturn` = { src }); the pending return is cancelled. False when it is not in the form, its owner
* is off the field or its tile is taken.
*/
export function wolfReturnNow(battle, unit, src = null) {
const tp = wolfTacticalPoint(unit);
if (!tp || battle.finished || !wolfOwnerStands(unit)) return false;
unit.mem.wolfReturn = { src };
let ok;
try { ok = battle.redeploy(unit, { free: true }); } finally { unit.mem.wolfReturn = null; }
if (!ok) return false;
if (unit.mem.wolfTac === tp) unit.mem.wolfTac = null;
if (tp.timer) tp.timer.cancel();
return true;
}
/** The end of the 战术点形态 `seq` (its timer): the pack comes back — another try shortly when its tile is taken. */
function wolfTimerReturn(battle, unit, seq) {
const tp = wolfTacticalPoint(unit);
if (!tp || tp.seq !== seq || battle.finished) return;
if (wolfReturnNow(battle, unit)) return;
tp.timer = battle.after(WOLF_RETURN_RETRY, () => wolfTimerReturn(battle, unit, seq), { owner: unit });
}
/**
* The 战术点形态 of a 狼群 unit (see above). `onEnter(battle, unit)` sets its 狼影 to 0 — each pack keeps its own count,
* buff and fx.
*/
export function installWolfTacticalPoint(battle, unit, { onEnter = null } = {}) {
battle.on('death', (ctx) => {
if (ctx.unit !== unit || battle.finished || !WOLF_TAC_EXITS.has(ctx.reason) || !wolfOwnerStands(unit)) return;
unit.removed = false;
// the 狼影 recovery time from the data; a record without one falls back to the token's redeploy time (data)
const t = wolfShadowInterval(unit.def) || Math.max(0, num(unit.base.respawnTime, 0));
const seq = (unit.mem.wolfTacSeq ?? 0) + 1;
unit.mem.wolfTacSeq = seq;
const tp = { seq, since: battle.time, until: battle.time + t, timer: null };
unit.mem.wolfTac = tp;
if (onEnter) onEnter(battle, unit);
tp.timer = battle.after(t, () => wolfTimerReturn(battle, unit, seq), { owner: unit });
}, { owner: unit, priority: -10 });
// its owner leaving ends the form without a return ("不触发上述效果"): the piece waits for the owner's redeploy, which
// brings a fresh pack (its initial 狼影) — as for a standing pack, which leaves with its owner
battle.on('death', (ctx) => {
const tp = wolfTacticalPoint(unit);
if (!tp || !unit.ownerUnit || ctx.unit !== unit.ownerUnit) return;
unit.mem.wolfTac = null;
if (tp.timer) tp.timer.cancel();
}, { owner: unit });
}
/** 狼群 (伺夜 talent 狼群领袖/狼群天性; S3 bb on the token). */
function wolfPack(bb, raw, def) {
const leader = wolfLeader(def);
const lb = leader?.bb ?? {};
const interval = wolfShadowInterval(def);
const perBlock = num(lb['vigil_wolf_t_1_enhance[trigger].block_cnt'] ?? lb.block_cnt, 1);
const mMax = String(leader?.description ?? '').match(/至多(\d+)只/);
const maxShadows = mMax ? +mMax[1] : 1 + num(lb['vigil_wolf_t_1_enhance[trigger].max_stack_cnt'], 0);
const defPen = num(talentBb(def, 'def_penetrate_fixed').def_penetrate_fixed, 0);
const guardMul = num(talentWith(def, 'prob')?.bb?.damage_scale, 1); // module: 援军受到来自自身阻挡单位的伤害降低
const s3Key = Object.keys(bb || {}).find((k) => /atk_scale$/.test(k));
const s3Scale = s3Key ? num(bb[s3Key], 0) : 0;
const setShadows = (battle, unit, n) => {
unit.mem.shadows = n;
battle.addBuff(unit, { key: 'wolf:shadows', persist: true, allowDead: true, refresh: 'replace', mods: { blockCnt: n * perBlock } });
};
return {
skill: null,
trait: { hitsFn: (b, u) => Math.max(1, u.mem.shadows ?? 1) },
install(battle, unit) {
// initial shadows: 伺夜's talent text ("初始两只"), else one below the maximum
const ot = (ownerOf(unit)?.def?.talents || []).map((t) => t.description || '').join(' ');
const mi = ot.match(/初始(两|二|\d+)只/);
const initShadows = Math.max(1, Math.min(maxShadows, mi ? (/\d/.test(mi[1]) ? +mi[1] : 2) : maxShadows - 1));
onDeploy(battle, unit, () => {
// back from its 战术点形态 (wolfReturnNow): 1 狼影 (PRTS 狼群 "从战术点形态转变为召唤物形态后拥有1只“狼影”") and a
// fresh growth cycle; any other deployment: the initial count
const back = unit.mem.wolfReturn;
setShadows(battle, unit, back ? 1 : initShadows);
unit.hp = unit.s.maxHp;
// (伺夜 S1 ① shows its own summon fx)
if (back && back.src == null) battle.fx('wolfShadow', { x: unit.x, y: unit.y, id: unit.id, n: 1 });
const seq = unit.deploySeq;
if (interval > 0) {
battle.every(interval, (b, sched) => {
if (!unit.alive || unit.deploySeq !== seq) { sched.cancel(); return; }
if ((unit.mem.shadows ?? 0) < maxShadows) {
setShadows(b, unit, (unit.mem.shadows ?? 0) + 1);
b.fx('wolfShadow', { x: unit.x, y: unit.y, id: unit.id, n: unit.mem.shadows });
}
}, { owner: unit });
}
const o = ownerOf(unit);
if (o && o.profile?.sub === 'tactician') o.trait.reinforcement = unit;
}, 20);
// fatal with more than one 狼影: one is lost, full HP; on the last one the knock-out goes through — 战术点形态
battle.on('fatal', (ctx) => {
if (ctx.unit !== unit || ctx.prevented || !((unit.mem.shadows ?? 0) > 1)) return;
ctx.prevented = true;
setShadows(battle, unit, unit.mem.shadows - 1);
unit.hp = unit.s.maxHp;
battle.fx('wolfShadowLost', { x: unit.x, y: unit.y, id: unit.id, n: unit.mem.shadows });
}, { owner: unit, priority: -50 });
installWolfTacticalPoint(battle, unit, {
onEnter: (b, u) => {
setShadows(b, u, 0);
b.fx('wolfShadowLost', { x: u.x, y: u.y, id: u.id, n: 0 });
},
});
// 狼群天性 ("伺夜和狼群对其的攻击无视其175防御力", 200 at full potential) and the owner's S3 bonus ("狼群与伺夜攻击被狼群阻挡的单位造成伤害
// 时,额外造成相当于伺夜攻击力N%的法术伤害", one per damage instance = per bite) cover the pack's and 伺夜's own
// attacks; with a hand-authored 伺夜 kit (managed) that kit applies both. The module guard is intrinsic.
const packOrOwner = (s) => s === unit || (s != null && s === ownerOf(unit));
if (defPen > 0 || guardMul !== 1) {
battle.on('hit', (ctx) => {
const { source, target, dmg } = ctx;
if (defPen > 0 && target && target.blockedBy === unit && unit.alive && packOrOwner(source) && dmg.isAttack && !managed(unit)) dmg.defIgnoreFlat += defPen;
if (target === unit && guardMul !== 1 && source && source.blockedBy === unit) dmg.mul *= guardMul;
}, { owner: unit });
}
if (s3Scale > 0) {
battle.on('damaged', (ctx) => {
const e = ctx.target, o = ownerOf(unit);
if (!o || !ctx.dmg?.isAttack || !packOrOwner(ctx.source) || !e || e.side !== 'enemy' || !e.alive || e.blockedBy !== unit) return;
if (!unit.alive || !o.alive || !o.skill?.active || !o.skill.isTimed || managed(unit)) return;
battle.dealDamage(ctx.source, e, { amount: o.s.atk * s3Scale, type: 'arts', isSkill: true, canDodge: false, tags: ['vigil'] });
}, { owner: unit });
}
// the 援军 leaves with its tactician (a hand-authored 伺夜 kit does the same for its pack); a pack in its 战术点形态
// ends it then (installWolfTacticalPoint) and the tactician's redeploy brings it back fresh. No respawn timer of
// its own: the token's redeploy time (data 10 s) is not the 战术点形态's length (the 狼影 interval)
const tacticianOwner = () => ownerOf(unit)?.profile?.sub === 'tactician';
battle.on('death', (ctx) => {
if (ctx.unit !== unit.ownerUnit || !unit.alive || managed(unit) || !tacticianOwner()) return;
battle.retreat(unit, { reason: 'expired', permanent: true });
}, { owner: unit });
},
};
}
/**
* The operator a 流形 copies ("可复制待部署区一名干员"): the nearest (Chebyshev tiles) living operator of its player on
* the field, not the summoner; ties → higher base ATK → lower id. (The remake deploys the whole board at battle start,
* so the official "operator waiting to deploy" is read as the nearest deployed operator — same pick as the 缪尔赛思
* kit, content/kits/ops/chess_char_6_11-mlyss.js.) Null when there is none: the 流形 then waits with its copy skill ready.
*/
export function pickCopyTarget(battle, unit) {
let best = null, bs = null;
for (const a of battle.allyUnits) {
if (a.kind !== 'op' || !a.alive || !a.deployed || a.removed || a.hidden || a === unit.ownerUnit || a.ownerId !== unit.ownerId) continue;
const d = Math.max(Math.abs(a.tileR - unit.tileR), Math.abs(a.tileC - unit.tileC));
const s = [d, -a.base.atk, a.id];
let less = !bs;
if (!less) for (let i = 0; i < 3; i++) { if (s[i] < bs[i]) { less = true; break; } if (s[i] > bs[i]) break; }
if (less) { best = a; bs = s; }
}
return best;
}
const MANIFOLD_STATS = Object.freeze(['maxHp', 'atk', 'def', 'res', 'blockCnt', 'bat', 'aspd']);
const MANIFOLD_PROFILE = Object.freeze(['attack', 'projectile', 'canHitFly', 'dmgType', 'heal', 'noAttack', 'noAttackUnlessSkill']);
/** Apply a 流形 copy of `target` (scale × base stats, block, attack timing, range, damage type). */
function applyManifoldCopy(battle, unit, target, scale) {
const tb = target.base;
const b = unit.base;
b.maxHp = Math.max(1, tb.maxHp * scale);
b.atk = tb.atk * scale;
b.def = tb.def * scale;
b.res = tb.res * scale;
b.blockCnt = tb.blockCnt;
b.bat = tb.bat;
b.aspd = tb.aspd;
unit.markDirty();
unit.hp = unit.s.maxHp;
if (Array.isArray(target.rangeGrid) && target.rangeGrid.length) {
unit.rangeGrid = target.rangeGrid;
battle.refreshRange(unit); // current + initial range of the copied grid
}
const tp = target.profile || {};
const ranged = tp.attack === 'ranged' || target.def?.position === 'RANGED';
const p = unit.profile;
if (p) {
p.attack = ranged ? 'ranged' : 'melee';
// the talent's list of copied attributes (生命上限 … 伤害类型) names no attack shape: a 阵法术师 / 轰击术师's instant
// 'beam' (rangeAoe: every enemy in range) becomes a plain single-target bolt, like a healer's orb
p.projectile = ranged ? (tp.projectile && tp.projectile !== 'none' && tp.projectile !== 'orb' && tp.projectile !== 'beam' ? tp.projectile : 'bolt') : 'none';
p.canHitFly = ranged ? true : !!tp.canHitFly;
// 初始伤害类型(不攻击、治疗类型则不继承)
if (!(tp.dmgType === 'heal' || tp.dmgType === 'none' || tp.noAttack || tp.noAttackUnlessSkill)) p.dmgType = tp.dmgType;
p.heal = null;
p.noAttack = false; // a 流形 attacks only once it copied an operator
p.noAttackUnlessSkill = false;
}
battle.removeBuff(unit, 'mlyss:steal');
unit.mem.copy = { id: target.id, defId: target.defId, ranged, scale };
unit.mem.stolenAtk = 0;
unit.mem.stolenDef = 0;
unit.mem.atkCount = 0;
battle.fx('manifoldCopy', { x: unit.x, y: unit.y, id: unit.id, from: target.id });
}
/** A split clone takes its origin's current copy (stats, range, attack profile) as it is. */
function cloneManifold(battle, clone, origin) {
for (const k of MANIFOLD_STATS) clone.base[k] = origin.base[k];
if (Array.isArray(origin.rangeGrid) && origin.rangeGrid.length) { clone.rangeGrid = origin.rangeGrid; battle.refreshRange(clone); }
if (clone.profile && origin.profile) for (const k of MANIFOLD_PROFILE) clone.profile[k] = origin.profile[k];
if (clone.profile) clone.profile.noAttack = false;
clone.markDirty();
clone.hp = clone.s.maxHp;
clone.mem.copy = origin.mem.copy ? { ...origin.mem.copy } : null;
}
/**
* 流形 (缪尔赛思 talent 净水即生命; token skill 流形 + token talents: copy scale, steal/split numbers, module).
* Official rules (data texts):
* - token skill "可复制待部署区一名干员" (SP 95/100, 1 charge): the copy happens when the skill starts ("技能开启时复制
* 目标90%…"); the skill only starts with an operator to copy (never wasted on nobody — it waits, ready);
* - an uncopied 流形 (block 0, range = its own tile) does not attack: attacking starts with its copy;
* - "其被击败后会在25秒后自动刷新": killed ⇒ back after the talent interval (never after expiring / being replaced),
* one 流形 at a time (deployLimit 1; split clones aside), each comes back uncopied and copies again;
* - ranged copy: every N attacks a clone on a free deployable tile of the 4 neighbours, lasting `interval` s; clones
* never copy, split or come back; melee copy: ATK/DEF steal per hit (capped, reset by a new copy).
*/
function manifold(bb, raw, def) {
const tCopy = talentBb(def, 'scale');
const tSteal = talentBb(def, 'steal_atk');
const tSp = talentBb(def, 'sp');
const guardMul = num(talentBb(def, 'damage_scale').damage_scale, 1);
const scale = num(tCopy.scale, 1);
const respawn = num(tCopy.interval, 0);
const stealAtk = num(tSteal.steal_atk, 0), stealAtkMax = num(tSteal.steal_atk_max, 0);
const stealDef = num(tSteal.steal_def, 0), stealDefMax = num(tSteal.steal_def_max, 0);
const splitEvery = Math.floor(num(tSteal['mlyss_wtrman_t_2[range].max_stack_cnt'], 0));
const splitLife = num(tSteal.interval, 0);
const sk = def?.skill;
const spCost = num(sk?.spCost, 0);
const initSp = Math.min(spCost, num(sk?.initSp, 0));
const firstSp = num(tSp.sp, 0); // golden 净水即生命: "首次部署后立即获得5点技力" (first deployment only)
const split = (battle, unit) => {
const tile = findSummonTile(battle, unit, 'plus', { melee: false });
if (!tile) return;
const st = stateOf(battle);
st.spawningClone = true; // read by the clone's install (before its deploy: deploy limits / tactician link skip it)
let c = null;
try { c = battle.spawnToken(unit.ownerUnit ?? unit.ownerId, TOKEN_IDS.manifold, tile[0], tile[1], { duration: splitLife }); } finally { st.spawningClone = false; }
if (!c) return;
cloneManifold(battle, c, unit);
battle.fx('manifoldSplit', { x: c.x, y: c.y, id: c.id, from: unit.id });
};
return {
skill: {
// started by the install tick below only when there is an operator to copy (rule NEVER: no auto-cast on nobody)
kind: 'toggle', trigger: 'NEVER', spType: 'time', spCost, initSp,
onStart({ battle, unit }) {
if (unit.mem.noCopy || managed(unit)) return;
const t = pickCopyTarget(battle, unit);
if (t) applyManifoldCopy(battle, unit, t, scale);
},
},
// no attack before the first copy (applyManifoldCopy / the 缪尔赛思 kit's copy switch it on)
trait: {
noAttack: true,
afterHit: (b, u, target) => {
const c = u.mem.copy;
if (!c || c.ranged || !target || !target.alive || target.side !== 'enemy' || managed(u)) return;
const ga = Math.max(0, Math.min(stealAtk, stealAtkMax - (u.mem.stolenAtk ?? 0)));
const gd = Math.max(0, Math.min(stealDef, stealDefMax - (u.mem.stolenDef ?? 0)));
if (!(ga > 0 || gd > 0)) return;
u.mem.stolenAtk = (u.mem.stolenAtk ?? 0) + ga;
u.mem.stolenDef = (u.mem.stolenDef ?? 0) + gd;
b.addBuff(u, { key: 'mlyss:steal', refresh: 'replace', mods: { atkFlat: u.mem.stolenAtk, defFlat: u.mem.stolenDef } });
// the robbed enemy keeps exactly what was taken from it (a capped last steal takes less than steal_atk)
const robbed = (target.mem.mlyssRobbed ??= { atk: 0, def: 0 });
robbed.atk += ga; robbed.def += gd;
b.addBuff(target, { key: 'mlyss:stolen', refresh: 'replace', mods: { atkFlat: -robbed.atk, defFlat: -robbed.def }, source: u });
},
afterAttack: (b, u) => {
const c = u.mem.copy;
if (!c || !c.ranged || u.mem.isClone || !(splitEvery > 0) || managed(u)) return;
u.mem.atkCount = (u.mem.atkCount ?? 0) + 1;
if (u.mem.atkCount % splitEvery === 0) split(b, u);
},
},
install(battle, unit) {
if (stateOf(battle).spawningClone) { unit.mem.isClone = true; unit.mem.noCopy = true; }
// the uncopied 流形 (data stats, own-tile range, no attack): every (re)deployment starts from it again
const defaults = { base: Object.fromEntries(MANIFOLD_STATS.map((k) => [k, unit.base[k]])), rangeGrid: unit.rangeGrid, profile: {} };
if (unit.profile) for (const k of MANIFOLD_PROFILE) defaults.profile[k] = unit.profile[k];
onDeploy(battle, unit, () => {
if (unit.mem.isClone) return;
if (unit.mem.copy || unit.mem.mlyss) { // redeployed after a copy (this kit's / the 缪尔赛思 kit's)
Object.assign(unit.base, defaults.base);
if (unit.profile) Object.assign(unit.profile, defaults.profile);
if (unit.rangeGrid !== defaults.rangeGrid) { unit.rangeGrid = defaults.rangeGrid; battle.refreshRange(unit); }
battle.removeBuff(unit, 'mlyss:steal');
battle.removeBuff(unit, 'mlyss:stolen');
unit.markDirty();
unit.hp = unit.s.maxHp;
}
unit.mem.copy = null;
unit.mem.mlyss = null;
const o = ownerOf(unit);
if (o && o.profile?.sub === 'tactician') o.trait.reinforcement = unit;
// a hand-authored 缪尔赛思 kit grants the first-deployment SP itself (it tracks her first 流形 across respawns)
if (firstSp > 0 && !unit.mem.firstSpDone && !managed(unit) && unit.skill) { unit.mem.firstSpDone = true; unit.skill.gainSp(firstSp, 'talent'); }
}, 20);
// copy skill: starts once ready AND an operator to copy exists (both modes: the 缪尔赛思 kit copies on skillStart)
if (!unit.mem.noCopy) {
battle.on('tick', () => {
const sk = unit.skill;
if (!sk || !unit.alive || !unit.deployed || sk.active || !sk.ready || sk.opCooling || !unit.canAct || unit.s.flags.silence) return;
if (pickCopyTarget(battle, unit)) sk.activate('MLYSS_WTRMAN');
}, { owner: unit });
}
// module 梳妆流形 (isToken part on the token talents): 援军受到来自自身阻挡单位的伤害降低15% — intrinsic to the token,
// applied here for managed tokens too (the 缪尔赛思 kit leaves it to this kit when the token runs it)
if (guardMul !== 1) {
battle.on('hit', (ctx) => { if (ctx.target === unit && ctx.source && ctx.source.blockedBy === unit) ctx.dmg.mul *= guardMul; }, { owner: unit });
}
// like the 缪尔赛思 kit: no respawn while its summoner is down (her redeploy brings it back, ensureReinforcement)
if (respawn > 0) enableRespawn(battle, unit, { delay: respawn, unlessManaged: true, requireOwner: () => !!unit.ownerUnit });
},
};
}
/** 香槟炸弹 (琳琅诗怀雅 S2): untargetable trap consumed by the first ground enemy touching its tile. */
function champagne(bb) {
const scale = num(bb['attack@atk_scale'] ?? bb.atk_scale, 0);
const slug = num(bb['attack@sluggish'] ?? bb.sluggish, 0);
const extraAfter = num(bb.duration_switch, Infinity);
return {
skill: {
kind: 'passive',
onTick({ battle, unit }) {
if (!unit.alive) return;
let hit = null;
for (const e of battle.enemies) {
if (!e.alive || e.hidden || e.isFlying) continue;
// touching its tile: a huge enemy on any tile of its body (body.js)
const on = e.hitArea ? bodyOnTile(e, unit.tileR, unit.tileC) : Math.abs(e.x - unit.tileC) <= 0.5 && Math.abs(e.y - unit.tileR) <= 0.5;
if (on && (!hit || e.spawnSeq < hit.spawnSeq)) hit = e;
}
if (!hit) return;
const hits = battle.time - unit.deployedAt >= extraAfter - 1e-9 ? 2 : 1;
const amount = ownerAtk(unit) * scale;
for (let i = 0; i < hits && hit.alive; i++) battle.dealDamage(unit, hit, { amount, type: 'phys', isSkill: true, tags: ['summon', 'trap'] });
if (hit.alive && slug > 0) battle.applyStatus(hit, 'sluggish', { duration: slug, source: unit });
battle.fx('champagneBomb', { x: unit.x, y: unit.y, id: unit.id, target: hit.id, hits, consumed: true });
battle.retreat(unit, { reason: 'expired', permanent: true });
},
},
trait: { noAttack: true },
install(battle, unit) { untargetable(battle, unit); },
};
}
/**
* 从不混淆的方向 (乌尔比安 S3): marker of the owner's original tile; the owner returns there when the skill ends — a
* 【移动】 (Battle.moveRedeploy, SP emptied: PRTS 乌尔比安 S3 备注 "【返回】时将清空技力"). The 乌尔比安 kit brings him back
* itself (its onEnd retreats the marker first); this is the fallback for a kit that only places the marker.
*/
function ulpiaMarker() {
return {
skill: null,
trait: { noAttack: true },
install(battle, unit) {
untargetable(battle, unit);
bindToOwnerSkill(battle, unit);
const home = () => {
if (!unit.alive) return;
const r = unit.tileR, c = unit.tileC;
battle.retreat(unit, { reason: 'expired', permanent: true });
const o = ownerOf(unit);
if (o && o.alive && o.deployed && (o.tileR !== r || o.tileC !== c) && battle.moveRedeploy(o, r, c, { clearSp: true })) battle.fx('ulpiaReturn', { x: c, y: r, id: o.id });
};
// runs before bindToOwnerSkill's plain retreat (higher priority): retreat + bring the owner home
battle.on('skillEnd', (ctx) => {
if (ctx.unit === unit.ownerUnit && unit.mem.boundActivation != null && ctx.skill.activations === unit.mem.boundActivation) home();
}, { owner: unit, priority: 10 });
battle.on('death', (ctx) => { if (ctx.unit === unit.ownerUnit && unit.alive) battle.retreat(unit, { reason: 'expired', permanent: true }); }, { owner: unit });
},
};
}
/** 黄金盟誓 (维娜·维多利亚 S3): attacks deal true damage (trait); lasts while the owner's skill runs. */
function goldenOath(bb, raw, def) {
const trueDmg = /真实伤害/.test(String(def?.trait ?? ''));
return {
skill: null,
trait: trueDmg ? { dmgType: 'true' } : {},
install(battle, unit) { bindToOwnerSkill(battle, unit); },
};
}
/**
* 爬行号·防护单元 (凯瑟琳 talent 定向支援信号; a hand piece the player places and turns, user playtest #6): "使一名干员
* 获得相当于凯瑟琳生命上限20%的屏障(若目标最近5秒内未受攻击,则每秒补充…不超过初始上限),装置效果不叠加" — the operator
* on its range (1-1: its tile + the tile it faces) gets the full shield when the device takes it (deploy, or a new /
* redeployed operator there), then the refill; untargetable ("不会受到攻击") and invulnerable (enemy AoE sweeping every
* ally in a radius would otherwise destroy the 100-HP device). The buff key is 凯瑟琳 S1 岁月锻打's test.
*/
function catShield(bb, raw, def) {
const t = talentBb(def, 'max_shield_ratio');
const idle = num(t.interval, 0);
const every = num(t['catsld_t_1[timer][interval].interval'], 1);
const maxRatio = num(t.max_shield_ratio, 0);
const refill = num(t.shield_ratio_each_trigger, 0);
return {
skill: null,
trait: { noAttack: true },
install(battle, unit) {
const st = stateOf(battle);
battle.addBuff(unit, { key: 'token:catDevice', flags: { invulnerable: true }, persist: true, allowDead: true });
const ownerHp = () => { const o = ownerOf(unit); return o ? o.s.maxHp : unit.s.maxHp; };
const valid = (a) => a && a.alive && a.deployed && a.kind === 'op';
const pick = () => {
let best = null;
for (const k of unit.rangeKeys || []) {
const a = battle.unitAt((k / COLS) | 0, k % COLS);
if (!valid(a) || a.ownerId !== unit.ownerId) continue;
const holder = st.catTargets.get(a.id);
if (holder && holder !== unit && holder.alive) continue; // 装置效果不叠加
best = a;
break;
}
if (unit.mem.target && st.catTargets.get(unit.mem.target.id) === unit) st.catTargets.delete(unit.mem.target.id);
unit.mem.target = best;
if (best) st.catTargets.set(best.id, unit);
return best;
};
const give = (a, amount) => {
const cap = ownerHp() * maxRatio;
if (!(cap > 0) || !(amount > 0)) return;
const b = a.findBuff(CAT_SHIELD_KEY);
if (b) { b.shield = Math.min(cap, (b.shield || 0) + amount); a.markDirty(); } else battle.addBuff(a, { key: CAT_SHIELD_KEY, shield: Math.min(cap, amount), duration: Infinity, visible: true, source: unit });
};
const connect = () => {
const a = pick();
if (a) { give(a, ownerHp() * maxRatio); battle.fx('catShield', { x: a.x, y: a.y, id: a.id, from: unit.id }); }
return a;
};
onDeploy(battle, unit, connect);
battle.every(every, () => {
if (!unit.alive || !unit.deployed) return;
const a = unit.mem.target;
if (!valid(a)) { connect(); return; }
const o = ownerOf(unit);
const skillOn = !!(o && o.alive && o.skill && o.skill.active && o.skill.isTimed);
const rate = skillOn ? num(o.skill.bb?.overwrite_ratio, refill) : refill;
if (skillOn || battle.time - a.lastHitAt >= idle - 1e-9) give(a, ownerHp() * rate * every);
}, { owner: unit });
battle.on('death', (ctx) => {
if (ctx.unit === unit && unit.mem.target && st.catTargets.get(unit.mem.target.id) === unit) st.catTargets.delete(unit.mem.target.id);
}, { owner: unit });
},
};
}
// ---------------------------------------------------------------------------------------------------------------
// 炎佑 (enemy_9012_acloon as an ally)
/** Enemy-shaped 炎佑 record (enemies.json) for data sets without the tokens.json entry; null when tokens.json has it. */
function yanyouFallbackDef(battle) {
if (battle.data.getToken?.(TOKEN_IDS.yanyou)) return null;
return battle.data.rawEnemy?.(TOKEN_IDS.yanyou) ?? null;
}
/**
* 炎佑 (PRTS “炎佑” 级别0(卫戍协议), enemy_database enemy_9012_acloon; user playtest #4 item 12):
* * moves on its own — "持续追踪全场范围内仇恨值最高的敌方单位": it flies after the highest-aggro targetable enemy of
* the whole field (the operator order: taunt, least remaining path, earliest spawned; while 祛恶之焰 channels, its
* locked target) and hovers over it (YANYOU_STOP short of its position; a 2nd 炎佑 of 9 炎 hovers 0.8 row beside the
* first); with no enemy on the field it stays where it is (user playtest #4: "停留在最后在的位置,而不是往一个固定位置
* 返回"; PRTS says it then tracks a random tile — the user's first-hand account wins);
* * normal attack: rangeRadius 2.0, arts, 3 targets ("可同时攻击3个目标", talent 1.attack@max_target);
* * every damage it deals adds burn = ATK × 2.ep_damage_ratio (20 %: "自身造成伤害时,附加攻击力20%的灼燃损伤"; the
* official 下半 notice: "祛恶之焰附带的灼燃损伤调整为基于炎佑的攻击力") — normal attacks AND flame ticks;
* * 元素脆弱 aura: enemies within YANYOU_FRAGILE_RADIUS (1.5, PRTS "自身<固定半径>半径1.5范围内") take 元素伤害 (burst
* damage) × 2.damage_scale (1.2) — not the gauge fill (damage.js);
* * immune to element damage ("即将受到元素损伤前取消此元素损伤");
* * 祛恶之焰 [模式乙] (the 炎 bond's mode, Skill_2; cooldown / initCooldown 15, atk_scale 0.6, hit_duration 20,
* range_radius 1.0): "触发索敌和普通攻击相同:锁定1个目标持续施法,最多持续20秒,每秒对目标周围半径1.0范围内的所有
* 敌方单位造成攻击力60%的法术伤害(范围伤害为中点判定…对被锁定的目标也不例外)※施法期间受到沉默影响后,立即结束技能".
* It fires when a normal attack would (SP = the 15 s cooldown), locks that attack's first target, channels on it
* (no normal attacks meanwhile), deals the 1 s ticks (the first at once) and ends after 20 s, when the locked
* target is gone (dead, left, untargetable — "锁定1个目标…最多持续") or when silenced; the cooldown then runs again
* [ASSUMED: the cooldown counts from the end of the channel, like the engine's timed skills]. While stunned /
* frozen the channel keeps its lock but deals nothing.
*/
const YANYOU_STOP = 0.25; // [ASSUMED] how close it hovers to the tracked enemy (tiles)
const YANYOU_FRAGILE_RADIUS = 1.5; // PRTS "在场期间自身<!--固定半径-->半径1.5范围内的敌方单位受到20%的元素脆弱"
const YANYOU_AURA_EVERY = 0.2; // aura refresh period (s); the status lasts two periods
const YANYOU_PROFILE = Object.freeze({ canHitFly: true });
/** Highest-aggro targetable enemy of the whole field for `u` (operator order, targeting.js sortEnemyTargets). */
function topAggroEnemy(battle, u) {
let best = null, bk = null;
for (const e of battle.enemies) {
if (!canTargetEnemy(u, e, YANYOU_PROFILE)) continue;
const k = [-(e.s.taunt || 0), battle.remainingDistance(e), e.spawnSeq];
if (!bk || k[0] < bk[0] || (k[0] === bk[0] && (k[1] < bk[1] - 1e-9 || (Math.abs(k[1] - bk[1]) <= 1e-9 && k[2] < bk[2])))) { best = e; bk = k; }
}
return best;
}
function yanyouKit(bb, raw) {
// enemy-shaped record: `talents` is one { bb } object, `skills` the enemy skill list
const tal = raw?.talents && !Array.isArray(raw.talents) ? raw.talents.bb ?? {} : raw?.talent ?? {};
const skills = Array.isArray(raw?.skills) ? raw.skills : [];
// the 炎 bond summons 模式乙 (Skill_2, PRTS: "由核心盟约-炎召唤时为模式乙"); 模式甲 (Skill) hits the target only
const sk = skills.find((s) => s.prefabKey === 'Skill_2') ?? skills.find((s) => s.prefabKey === 'Skill') ?? skills[0] ?? {};
const maxTargets = Math.max(1, Math.floor(num(tal['1.attack@max_target'], 1)));
const epRatio = num(tal['2.ep_damage_ratio'], 0);
const fragMul = num(tal['2.damage_scale'], 1);
const radius = num(raw?.stats?.rangeRadius, 0) > 0 ? num(raw.stats.rangeRadius, 0) : 2;
const moveSpeed = num(raw?.stats?.moveSpeed, 1);
const cd = num(sk.cooldown, 0);
const initCd = num(sk.initCooldown, cd);
const flameScale = num(sk.bb?.atk_scale, 0);
const flameDur = num(sk.bb?.hit_duration, 0);
const flameR = num(sk.bb?.range_radius, 0);
/** The channel's locked target while it is still valid (alive, on the field, targetable), else null. */
const lockOf = (u) => {
const t = u.mem.flame?.target;
return t && t.alive && canTargetEnemy(u, t, YANYOU_PROFILE) ? t : null;
};
const refreshKeys = (u) => {
// cached while it hovers in place — unless the engine rebuilt the range from the (dummy) grid meanwhile
// (a rangeExtend buff / skill range switch calls _refreshRange around its spawn tile)
if (u.mem.keysAt && u.rangeKeys === u.mem.keysRef && Math.abs(u.mem.keysAt.x - u.x) < 0.05 && Math.abs(u.mem.keysAt.y - u.y) < 0.05) return;
const keys = [];
const r0 = Math.floor(u.y - radius), r1 = Math.ceil(u.y + radius), c0 = Math.floor(u.x - radius), c1 = Math.ceil(u.x + radius);
for (let r = Math.max(0, r0); r <= Math.min(ROWS - 1, r1); r++) {
for (let c = Math.max(0, c0); c <= Math.min(COLS - 1, c1); c++) if (hypot(c - u.x, r - u.y) <= radius + 1e-9) keys.push(r * COLS + c);
}
u.rangeKeys = keys;
u.rangeKeySet = new Set(keys);
u.baseRangeKeys = keys;
u.mem.keysRef = keys;
u.mem.keysAt = { x: u.x, y: u.y };
};
return {
skill: cd > 0 && flameDur > 0 ? {
kind: 'duration', duration: flameDur, spType: 'time', spCost: cd, initSp: Math.max(0, cd - initCd), trigger: 'DEFAULT',
attack: { noAttack: true }, // channelling: no normal attacks
onStart({ battle, unit }) {
// "触发索敌和普通攻击相同": the normal attack's first target
const list = battle.enemiesInKeys(unit.rangeKeys, unit, YANYOU_PROFILE);
sortEnemyTargets(battle, unit, list, null);
unit.mem.flame = { target: list[0] ?? null, acc: 1 };
if (!list[0]) unit.skill.end('noTarget');
},
onTick({ battle, unit, dt }) {
const f = unit.mem.flame;
if (!f) return;
if (unit.s.flags.silence) { unit.skill.end('silence'); return; }
const t = lockOf(unit);
if (!t) { unit.skill.end('targetLost'); return; }
if (!unit.canAct) return; // stunned / frozen: the lock holds, no damage
f.acc += dt ?? battle.dt;
if (f.acc < 1 - 1e-9) return;
f.acc -= 1;
// centre-point AoE around the locked target, searched again every second (the target itself included)
const hit = flameR > 0 ? battle.foesInRadius(t.x, t.y, flameR) : [];
if (!hit.includes(t)) hit.unshift(t);
for (const e of hit) battle.dealDamage(unit, e, { amount: unit.s.atk * flameScale, type: 'arts', isSkill: true, tags: ['flame'] });
battle.fx('yanyouFlame', { x: t.x, y: t.y, id: unit.id, target: t.id, r: flameR, n: hit.length, dur: 1 });
},
onEnd({ unit }) { unit.mem.flame = null; },
} : null,
trait: { attack: 'ranged', dmgType: 'arts', projectile: 'bolt', canHitFly: true, maxTargets, heal: null, noAttack: false },
install(battle, unit) {
unit.motion = 'FLY';
if (!(unit.base.spRecovery > 0)) unit.base.spRecovery = 1; // the cooldown ticks as time SP (data spRecovery 0)
unit.base.blockCnt = 0;
const speed = moveSpeed * MOVE_SCALE;
onDeploy(battle, unit, () => { unit.mem.keysAt = null; refreshKeys(unit); });
// every damage it deals adds burn (not the burn itself, not element bursts; not a killing blow — the hook runs
// before the kill, at 0 HP: no burst on the corpse)
if (epRatio > 0) {
battle.on('damaged', (ctx) => {
const t = ctx.target;
if (ctx.source !== unit || !t || t.side !== 'enemy' || !hasHp(t)) return;
if (ctx.type === 'element' || ctx.type === 'elemental' || !(ctx.amount > 0)) return;
battle.dealDamage(unit, ctx.target, { type: 'element', element: 'burn', amount: unit.s.atk * epRatio, tags: ['yanyou'] });
}, { owner: unit });
}
// "即将受到元素损伤前取消此元素损伤"
battle.on('elementHit', (ctx) => { if (ctx.target === unit) ctx.dmg.cancel = true; }, { owner: unit });
let auraAcc = YANYOU_AURA_EVERY;
battle.on('tick', ({ dt }) => {
if (!unit.alive || !unit.deployed) return;
if (unit.canAct && !unit.s.flags.noMove && speed > 0) {
const tgt = lockOf(unit) ?? topAggroEnemy(battle, unit);
if (tgt) {
const dx = tgt.x - unit.x, dy = tgt.y + num(unit.mem.hoverDy, 0) - unit.y;
const d = hypot(dx, dy);
if (d > YANYOU_STOP + 1e-6) {
const step = Math.min(d - YANYOU_STOP, speed * dt);
const R = battle.rect;
unit.x = Math.max(R.c0, Math.min(R.c1, unit.x + (dx / d) * step));
unit.y = Math.max(R.r0, Math.min(R.r1, unit.y + (dy / d) * step));
}
}
}
refreshKeys(unit);
auraAcc += dt;
if (fragMul > 1 && auraAcc >= YANYOU_AURA_EVERY - 1e-9) {
auraAcc = 0;
for (const e of battle.foesInRadius(unit.x, unit.y, YANYOU_FRAGILE_RADIUS)) {
battle.applyStatus(e, 'elemFragile', { duration: 2 * YANYOU_AURA_EVERY, value: fragMul - 1, source: unit });
}
}
}, { owner: unit });
},
};
}
/**
* Stage positions held for the band map characters (data/stages.json `mapChars`: the levels' predefined 预备干员-医疗 /
* Touch of 外勤医疗, hidden until a player holds the strategy), as tile keys.
*/
function mapCharKeys(battle) {
const list = battle.stage?.raw?.mapChars ?? battle.stage?.mapChars ?? [];
const out = new Set();
for (const m of Array.isArray(list) ? list : []) if (m && Array.isArray(m.pos)) out.add(m.pos[0] * COLS + m.pos[1]);
return out;
}
/**
* Free field tile for a flyer: the player's half, void/non-deployable tiles first, nearest to the half's centre — never a
* map character's position: the 炎佑 spawns at the same battle start as 外勤医疗's medic and took its tile on the maps
* whose free void tiles nearest the centre include it (战场#08 涨潮控制 always, 战场#07 排气格栅 with 9 炎), so the medic
* never stood (community report of 2026-10-06 「Touch策略给的医疗干员会跟炎盟约的炎祐冲突,无法同时出场」).
*/
function airTile(battle, playerId, taken) {
const R = battle.rect;
const ps = battle.getPlayer(playerId);
let c0 = R.c0, c1 = R.c1;
if (battle.players.length > 1 && ps) { if (ps.half === 'R') c0 = Math.max(c0, 11); else c1 = Math.min(c1, 10); }
const cr = (R.r0 + R.r1) / 2, cc = (c0 + c1) / 2;
const held = mapCharKeys(battle);
let best = null, bs = null;
for (let r = R.r0; r <= R.r1; r++) {
for (let c = c0; c <= c1; c++) {
if (taken.has(r * COLS + c) || held.has(r * COLS + c) || !tileFree(battle, r, c)) continue;
const t = battle.grid.tile(r, c);
const cls = t.build === 'NONE' && t.pass !== 'ALL' ? 0 : t.build === 'NONE' ? 1 : 2;
const s = [cls, hypot(r - cr, c - cc), r * COLS + c];
let less = !bs;
if (!less) for (let i = 0; i < 3; i++) { if (s[i] < bs[i] - 1e-9) { less = true; break; } if (s[i] > bs[i] + 1e-9) break; }
if (less) { best = [r, c]; bs = s; }
}
}
return best;
}
/**
* 炎 bond summon: spawn `count` “炎佑” for `playerId` (flying ally that picks its own targets; bonds.js computes the
* numbers). atk / hp (0.3 × the 炎 sums at battle start) are ADDED to the template stats (600 / 12000): PRTS "登场时使
* 自身攻击力、生命值增加召唤自身的玩家场地上的所有【炎】盟约干员攻击力、生命值的30%(最终加算)". atkMul (9 炎: ×1.5,
* [ASSUMED] on the whole ATK) and dmgTakenMul (9 炎: 0.1) are applied as a persistent buff. Returns the spawned units.
*/
export function spawnYanyou(battle, playerId, { atk, hp, atkMul = 1, dmgTakenMul = 1, count = 1 } = {}) {
const rec = battle.data.rawToken?.(TOKEN_IDS.yanyou) ?? battle.data.rawEnemy?.(TOKEN_IDS.yanyou) ?? null;
if (!rec) return [];
const def = yanyouFallbackDef(battle);
const n = Math.max(0, Math.min(Math.floor(num(count, 1)), Math.max(1, Math.floor(num(rec.deployLimit ?? rec.stats?.deployLimit, 2)))));
const out = [];
const taken = new Set();
const base = rec.stats || {};
const stats = {};
if (num(atk, -1) >= 0) stats.atk = num(base.atk, 0) + num(atk, 0);
if (num(hp, 0) > 0) stats.maxHp = num(base.maxHp, 0) + num(hp, 1);
const ps = battle.getPlayer(playerId);
for (let i = 0; i < n; i++) {
const tile = airTile(battle, playerId, taken);
if (!tile) break;
taken.add(tile[0] * COLS + tile[1]);
const u = battle.spawnToken(playerId, TOKEN_IDS.yanyou, tile[0], tile[1], { ...(def ? { def } : {}), stats, dir: ps ? ps.dir : 'RIGHT' });
if (!u) continue;
const mods = {};
if (num(atkMul, 1) !== 1 && num(atkMul, 1) >= 0) mods.atkMul = num(atkMul, 1);
if (num(dmgTakenMul, 1) !== 1 && num(dmgTakenMul, 1) >= 0) mods.dmgTakenMul = num(dmgTakenMul, 1);
if (Object.keys(mods).length) battle.addBuff(u, { key: 'bond:yanyou', persist: true, allowDead: true, mods });
u.mem.hoverDy = out.length === 0 ? 0 : (out.length % 2 ? 0.8 : -0.8); // the 2nd one hovers beside the first
u.hp = u.s.maxHp;
battle.fx('yanyouSummon', { x: u.x, y: u.y, id: u.id, playerId });
out.push(u);
}
return out;
}
// ---------------------------------------------------------------------------------------------------------------
// band map characters (预备干员-医疗 / Touch)
/**
* Exits that give Touch's 超脱 its SP (PRTS Touch(卫戍协议) 第二天赋 备注 "部分有死亡动画的强制撤退(如史尔特尔的天赋效果)也能触发这一
* 天赋"): a knock-out ('killed') and a forced exit that plays the death animation — Battle.retreat `dying`, a knock-out put
* off by the operator's own effect: 史尔特尔's 余烬 (the PRTS example) and 骑士戒律 + 竞技旗 ("受到致命伤害时不撤退,技能结束后
* 退场" [ASSUMED: the same kind of exit]). Not the exits a skill plans — 耀骑士临光 S2's "技能结束后自动撤退", 伊内丝 S3's "放置
* 一个影哨后离场" — nor the 商人's "不足时自动撤退" ('merchant'), the 突袭 jump ('raid'), the 联防 setup (FORCED_EXIT) or a
* summon's end ('expired') [ASSUMED: plain 撤退 without a death animation]. A battle runs on its own — the player has no
* 撤退 command in it — so none of these is a player's own retreat.
*/
const touchExitCounts = (ctx) => ctx.reason === 'killed' || (ctx.reason === 'retreat' && !!ctx.dying);
/**
* 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 the character outputs gives its target `sp` SP — PRTS 备注 "本天赋只需
* Touch输出治疗便能触发(无需实际产生治疗量)": herself too, and a heal that restores nothing (a full-HP target: S3's
* extra heal); not a device or a unit without a skill, not her own HP-regeneration tick (no heal she outputs)
* 超脱 "攻击范围内的友方干员被击倒时获得5点技力" (Touch) → an allied operator knocked out (or forced out dying:
* touchExitCounts) on a tile of its range: +`sp` SP
*/
export function mapCharTalents(def) {
const out = [];
for (const t of def?.talents ?? []) {
const bb = t?.bb ?? {};
const text = String(t?.description ?? '');
const sp = num(bb.sp, 0);
if (sp > 0 && /治疗.*技力/.test(text)) {
out.push({ install(battle, unit) {
battle.on('heal', (ctx) => {
const tg = ctx.target;
if (ctx.source !== unit || ctx.opts?.regen || !tg || tg.kind === 'device' || !tg.skill || tg.skill.noSkill) return;
tg.skill.gainSp(sp, 'talent');
}, { owner: unit, priority: -200 });
} });
} else if (sp > 0 && /被击倒/.test(text)) {
out.push({ install(battle, unit) {
battle.on('death', (ctx) => {
const d = ctx.unit;
if (!touchExitCounts(ctx) || !unit.alive || !unit.deployed || !unit.skill || !d || d === unit || d.kind !== 'op' || d.ownerId !== unit.ownerId) return;
if ((unit.rangeKeySet || new Set(unit.rangeKeys || [])).has(d.tileR * COLS + d.tileC)) {
unit.skill.gainSp(sp, 'talent');
battle.fx('spGain', { x: unit.x, y: unit.y, id: unit.id, n: sp });
}
}, { owner: unit });
} });
} else if (!/时|每|若|当/.test(text)) {
const mods = {};
if (num(bb.atk, 0)) mods.atkPct = num(bb.atk, 0);
if (num(bb.def, 0)) mods.defPct = num(bb.def, 0);
if (num(bb.max_hp, 0)) mods.hpPct = num(bb.max_hp, 0);
if (num(bb.attack_speed, 0)) mods.aspd = num(bb.attack_speed, 0);
if (Object.keys(mods).length) {
out.push({ install(battle, unit) { battle.addBuff(unit, { key: `talent:${t.name || 'stat'}`, persist: true, allowDead: true, mods, tags: ['talent'] }); } });
}
}
}
return out;
}
/** 预备干员-医疗: generic kit (治疗强化·β型 ATK +50 %) + its stat talent. */
function reserveMedicKit(bb, raw, def) {
const k = def?.skill ? genericKit(bb, raw, def) : { skill: null, talents: [] };
return { ...k, talents: [...(k.talents ?? []), ...mapCharTalents(def)] };
}
/**
* Touch 恳切福音 (skchr_acmedc_3): +ATK, 2 heal targets in the skill range, ×heal_scale on allies below 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. PRTS Touch(卫戍协议) 技能3 and its 备注:
* - "对生命值低于一半的友方单位治疗量提高为原来的130%": strictly below hp_ratio (PRTS 修正: 低于, not the old 不高于);
* - "并额外治疗一次目标或目标相邻1个友方单位,治疗量为主目标的30%": once per heal action, at the main (first = most
* injured) target's hit; 30 % of that heal's base (ATK × its scales, before the ×heal_scale the main target may get);
* - 备注 "额外治疗范围 x-5…优先选择生命比例更低的单位治疗(可治疗生命值已满单位)": the main target or one of its 4
* orthogonal neighbours, the lowest HP ratio first, a full-HP one included [ASSUMED: on equal ratios the main target,
* then the battle's ally order]; never a 禁疗 / 孤立 / 无法被友方治疗 unit (no heal can pick it);
* - 备注 "额外治疗可触发技能后半段的治疗量提高效果": the extra heal is a heal of its own, so its recipient gets the ×heal_scale
* when it is below hp_ratio (and the stand-in's PHY-X ×1.15), and its target gets 攫升's SP.
*/
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 (skill.rangeGrid && skill.rangeGrid.length) targeting.rangeGrid = skill.rangeGrid;
return {
skill: {
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 }) {
if (!(extra > 0) || !target || !(heal > 0)) return;
// onHit runs once per healed target: the extra heal comes with the first (main) one of the action
const n = unit.stats.attacks;
if (unit.mem.touchExtraAt === n) return;
unit.mem.touchExtraAt = n;
let best = target;
for (const a of battle.alliesInRadius(target.x, target.y, 1)) {
if (a === target || !battle.allySelectable(a, unit) || a.s.flags.noHeal || a.profile?.noHeal) continue;
if (Math.abs(a.tileR - target.tileR) + Math.abs(a.tileC - target.tileC) !== 1) continue;
if (a.hpRatio < best.hpRatio - 1e-9) best = a;
}
// `heal` = the main heal's base (ai.js doHeal: ATK × scales, before the heal pipeline and the ×heal_scale)
battle.heal(unit, best, heal * extra);
},
},
install(battle, unit) {
battle.on('heal', (ctx) => {
if (ctx.source !== unit || ctx.opts?.regen || !unit.skill?.active) return;
if (boost !== 1 && hpRatio > 0 && ctx.target.hpRatio < hpRatio - 1e-9) ctx.amount *= boost;
}, { owner: unit });
},
};
}
/**
* Touch (外勤医疗 map character): 恳切福音 (touchGospel) + 攫升 / 超脱 (mapCharTalents). The trigger is ACTIVE_RANGE on
* the skill's 5-2 range (her running range, which strictly contains her own 3-3): an injured ally inside it casts — the
* owner's larger-range rule of 2026-10-05, read for a heal skill as for the Touch 补位 stand-in, whose data rule it is
* (data/backups.json). The map character's record keeps DEFAULT (tools/build-data.mjs resolveTrigger widens operators'
* skills only), so she waited for an injured ally in her 3-3 (GitHub #260, PR #278).
*/
function touchKit(bb, raw, def) {
const sk = def?.skill;
if (!sk) return { skill: null, talents: mapCharTalents(def) };
const g = touchGospel(bb, sk);
if (sk.rangeGrid && sk.rangeGrid.length) g.skill.trigger = { rule: 'ACTIVE_RANGE', grid: sk.rangeGrid };
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.
*/
export function spawnMapChar(battle, playerId, tokenId, { alias = null } = {}) {
const list = battle.stage?.raw?.mapChars ?? battle.stage?.mapChars ?? [];
const ps = battle.getPlayer(playerId);
const multi = battle.players.length > 1;
const cands = list.filter((m) => m && m.key === tokenId && Array.isArray(m.pos) && (alias == null || m.alias === alias)
&& (multi || !/multi_only/.test(String(m.alias))) && battle.grid.inRect(m.pos[0], m.pos[1]));
// the player's own board columns on this field (board cols 2–10 mapped: unite R = 10–18, boss R mirrored 18–10)
let span = null;
if (ps && typeof battle.mapTile === 'function') {
const a = battle.mapTile(ps, 9, 2)[1], b = battle.mapTile(ps, 9, 10)[1];
span = [Math.min(a, b), Math.max(a, b)];
}
const halfOk = (m) => !ps || battle.players.length < 2 || (span ? m.pos[1] >= span[0] && m.pos[1] <= span[1] : (ps.half === 'R' ? m.pos[1] >= 11 : m.pos[1] <= 10));
cands.sort((a, b) => (halfOk(b) ? 1 : 0) - (halfOk(a) ? 1 : 0) || (/multi_only/.test(String(a.alias)) ? 1 : 0) - (/multi_only/.test(String(b.alias)) ? 1 : 0));
for (const m of cands) {
if (!tileFree(battle, m.pos[0], m.pos[1])) continue;
// stage positions are field tiles: their direction is taken as it is (default RIGHT)
const u = battle.spawnToken(playerId, tokenId, m.pos[0], m.pos[1], { dir: normDir(m.dir) });
if (u) return u;
}
return null;
}
// ---------------------------------------------------------------------------------------------------------------
// registry
const RAW_KITS = {
[TOKEN_IDS.healDrone]: healDrone,
[TOKEN_IDS.curseDoll]: curseDoll,
[TOKEN_IDS.obelisk]: obelisk,
[TOKEN_IDS.rosmonGear]: rosmonGear,
[TOKEN_IDS.duskDragon]: duskDragon,
[TOKEN_IDS.seaborn]: seaborn,
[TOKEN_IDS.radiantSword]: radiantSword,
[TOKEN_IDS.paperDoll]: paperDoll,
[TOKEN_IDS.wolfPack]: wolfPack,
[TOKEN_IDS.manifold]: manifold,
[TOKEN_IDS.champagne]: champagne,
[TOKEN_IDS.ulpiaMarker]: ulpiaMarker,
[TOKEN_IDS.goldenOath]: goldenOath,
[TOKEN_IDS.catShield]: catShield,
[TOKEN_IDS.deliveryTarget]: inert({ hideFromEnemies: true }),
[TOKEN_IDS.eagle1]: inert({ hideFromEnemies: true }),
[TOKEN_IDS.eagle2]: inert({ hideFromEnemies: true }),
[TOKEN_IDS.eagle3]: inert({ hideFromEnemies: true }),
[TOKEN_IDS.iceTarget]: inert(),
[TOKEN_IDS.yanyou]: yanyouKit,
[TOKEN_IDS.reserveMedic]: reserveMedicKit,
[TOKEN_IDS.touch]: touchKit,
};
/** tokenId → kit fn; kits built here carry `fromTokens: true` (deploy limits only police those). */
export const kits = Object.freeze(Object.fromEntries(Object.entries(RAW_KITS).map(([id, f]) => [id, (bb, raw, def) => {
const k = f(bb ?? {}, raw ?? {}, def ?? null);
if (k && typeof k === 'object') k.fromTokens = true;
return k;
}])));
export default kits;
// ---------------------------------------------------------------------------------------------------------------
// battle install: deploy limits, kill credit, last-deployed operator, tactician pairing, generic-kit fallbacks
/**
* Skill summons spawned at skill start while the summoner's SELECTED skill runs the generic spec — a generic kit, or a
* hand-authored kit without a spec for a non-default selected skill (content/index.js selectSkillSpec, `skillSource:
* 'generic'`) — and the data says that skill makes the token (owner-loadout `sources` has 'skill'). Placement per
* token; 迷迭香 S3 “如你所愿”: "立即在攻击范围内的近战位部署两个战术装备" (the token's deployLimit — 2, 20 with
* module uniequip_003_rosmon — only caps how many stand; SKILL_SUMMON_PER_CAST). 医疗探机 / 诅咒娃娃 are hand pieces
* (data `placeable`): their entries only mark them as skill summons — releaseSkillSummon deploys the player's piece on
* its own tile, and their 'ally' / 'enemy' placement would apply only to data without such a piece.
*/
const SKILL_SUMMONS = Object.freeze({
[TOKEN_IDS.healDrone]: 'ally',
[TOKEN_IDS.curseDoll]: 'enemy',
[TOKEN_IDS.obelisk]: 'melee',
[TOKEN_IDS.paperDoll]: 'adjacent',
[TOKEN_IDS.goldenOath]: 'adjacent',
[TOKEN_IDS.radiantSword]: 'plus',
[TOKEN_IDS.rosmonGear]: 'melee',
});
/**
* Pieces per cast of the skill summons placed several at a time (skill description; default 1). 黄金盟誓: "立即在天赋
* 一生效范围内可部署地面召唤" — every free tile of the 8 around 维娜 (EN client "Summons Golden Vows on deployable tiles
* within Talent 1's range"; player report B3 after 0.1.0).
*/
const SKILL_SUMMON_PER_CAST = Object.freeze({ [TOKEN_IDS.rosmonGear]: 2, [TOKEN_IDS.goldenOath]: 8 });
/**
* Skill summons the per-owner deploy limit does not apply to: the data `deployLimit` (character_table phase maxDeployCount 1)
* of 黄金盟誓 is a hand count, while 维娜 S3 summons one on every free deployable tile of her talent-1 area at once.
*/
const SKILL_SUMMON_UNCAPPED = new Set([TOKEN_IDS.goldenOath]);
/** Tactician talent tokens that replace the engine's generic 援军. */
const TACTICIAN_TOKENS = new Set([TOKEN_IDS.wolfPack, TOKEN_IDS.manifold]);
const tokenIdsOf = (u) => (u.def?.tokens || []).map((t) => (typeof t === 'string' ? t : t?.tokenId)).filter(Boolean);
/**
* How `owner` (its chess and selected skill / module, DESIGN §16) produces `tokenId`: the owner-loadout def's
* `sources` (getToken(id, owner.defId, owner.def.loadout)); [] when the token has no variant of the owner's own.
*/
function tokenSources(battle, tokenId, owner) {
const own = ownVariant(battle.data.rawToken?.(tokenId), owner);
if (!own) return [];
const d = battle.data.getToken?.(tokenId, owner.defId, owner.def?.loadout ?? null);
return Array.isArray(d?.sources) ? d.sources : (own.sources ?? []);
}
function deployLimitOf(u) {
const v = variantOf(u);
return num(v?.stats?.deployLimit ?? v?.deployLimit ?? u.def?.raw?.deployLimit ?? u.def?.raw?.stats?.deployLimit, Infinity);
}
/**
* Tactical point (战术点) of a tactician when the player placed no 援军 piece: `Battle.findTacticalPoint` — a free
* walkable tile of its initial range, on an enemy ground path first (where a player would put the blocker), then
* nearest to the tactician. Shared by every tactician kit (伺夜, kits/ops/chess_char_3_19-vigil.js; the tokens' own
* fallback).
*/
export function tacticalPoint(battle, owner) {
return battle.findTacticalPoint(owner);
}
/** Make sure a tactician's 援军 is its real talent token (board piece, or a fresh summon on a tactical point). */
function ensureReinforcement(battle, owner, tokenId) {
// (split 流形 clones — this file's `isClone`, the 缪尔赛思 kit's `mlyssClone` — are never the 援军)
const mine = battle.allyUnits.filter((t) => t.kind === 'token' && t.defId === tokenId && t.ownerUnit === owner && !t.mem.isClone && !t.mem.mlyssClone);
const live = mine.find((t) => t.alive);
if (live) { owner.trait.reinforcement = live; return live; }
// a 狼群 in its 战术点形态 is still the 援军 on the field: it comes back by its own timer (a 【移动】 of the owner fires
// `deploy` without ending the form)
const tac = mine.find((t) => wolfTacticalPoint(t));
if (tac) { owner.trait.reinforcement = tac; return tac; }
const waiting = mine.find((t) => !t.alive && !t.removed);
if (waiting && battle.redeploy(waiting, { free: true })) { owner.trait.reinforcement = waiting; return waiting; }
// the tactical point the player chose (the board piece's tile, else the last one's) when still usable, else the
// shared tactical point
const inRange = new Set(owner.baseRangeKeys || owner.rangeKeys || []);
const prev = mine.slice().sort((a, b) => (b.uid != null) - (a.uid != null) || b.deploySeq - a.deploySeq)
.map((t) => [t.homeR, t.homeC]).find(([r, c]) => Number.isInteger(r) && Number.isInteger(c) && inRange.has(r * COLS + c)
&& tileFree(battle, r, c) && battle.grid.canStand(r, c, { ranged: true }));
const best = prev ?? tacticalPoint(battle, owner);
if (!best) return null;
const t = battle.spawnToken(owner, tokenId, best[0], best[1]);
if (t) owner.trait.reinforcement = t;
return t;
}
export function install(battle) {
const st = stateOf(battle);
// the placed pieces of skill summons wait on their tiles for the skill (user playtest #6, see the header)
dockSkillSummons(battle);
// last deployed operator per player (“耀阳”: 上一名部署干员势力为【卡西米尔】) — tokens are not operators
battle.on('deploy', (ctx) => {
const u = ctx.unit;
if (u && u.kind === 'op' && u.side === 'ally') st.lastOp.set(u.ownerId, u);
}, { priority: -100 });
// deploy limit per owner (data deployLimit): a new summon withdraws the oldest one of the same kind
battle.on('deploy', (ctx) => {
const u = ctx.unit;
if (!u || u.kind !== 'token' || !u.ownerUnit || u.mem.isClone || !u.kit?.fromTokens || SKILL_SUMMON_UNCAPPED.has(u.defId)) return;
const lim = deployLimitOf(u);
if (!(lim >= 1) || !Number.isFinite(lim)) return;
const same = battle.allyUnits.filter((t) => t.alive && t.kind === 'token' && t.defId === u.defId && t.ownerUnit === u.ownerUnit && !t.mem.isClone && t.kit?.fromTokens);
if (same.length <= lim) return;
same.sort((a, b) => a.deploySeq - b.deploySeq);
for (const t of same.slice(0, same.length - lim)) {
if (t === u) continue;
t.mem.replaced = true; // withdrawn by the limit: no respawn (enableRespawn), or two pieces would take turns forever
battle.retreat(t, { reason: 'expired', permanent: true });
}
}, { priority: -90 });
// summon kills (夕 化境, …): owner kits listen to `summonKill`
battle.on('kill', (ctx) => {
const k = ctx.killer;
if (k && k.kind === 'token' && k.ownerUnit && ctx.victim && ctx.victim.side === 'enemy') battle.emit('summonKill', { token: k, owner: k.ownerUnit, victim: ctx.victim });
}, { priority: -100 });
for (const owner of battle.allyUnits) {
if (owner.kind !== 'op') continue;
const toks = tokenIdsOf(owner);
if (!toks.length) continue;
const base = baseKey(owner.def?.baseId ?? owner.defId);
// tactician 援军 = its talent token: board piece first; replaces the engine's generic 援军 when that one is in use
const tac = toks.find((t) => TACTICIAN_TOKENS.has(t));
if (tac && owner.profile?.sub === 'tactician') {
const engineDefault = !!(owner.profile.install && owner.profile.install.name === 'installTactician');
battle.on('deploy', (ctx) => {
if (ctx.unit !== owner) return;
if (engineDefault) { ensureReinforcement(battle, owner, tac); return; }
// a hand-authored kit summons its own 援军 unless one stands: bring the player's board piece in first
const piece = battle.allyUnits.find((t) => t.kind === 'token' && t.defId === tac && t.ownerUnit === owner && t.uid != null && !t.mem.isClone);
if (piece && ctx.initial && !piece.alive && !piece.removed && piece.deploySeq === 0) battle.redeploy(piece, { free: true });
if (piece && piece.alive) owner.trait.reinforcement = piece;
}, { owner, priority: 50 });
}
// generic-kit fallbacks (a hand-authored kit for the summoner replaces them); skill summons also run when only the
// selected skill falls back to the generic spec (a non-default skill the kit has no `skills` entry for)
const kitGeneric = !!owner.kit?.generic;
if (kitGeneric || owner.kit?.skillSource === 'generic') {
const skillSummons = toks.filter((t) => SKILL_SUMMONS[t] && tokenSources(battle, t, owner).includes('skill'));
if (skillSummons.length) {
battle.on('skillStart', (ctx) => {
if (ctx.unit !== owner) return;
for (const t of skillSummons) {
// a hand piece (医疗探机 / 诅咒娃娃) deploys where the player placed it — nowhere when not placed
if (battle.data.rawToken?.(t)?.placeable === true) { releaseSkillSummon(battle, owner, t); continue; }
for (let i = 0; i < (SKILL_SUMMON_PER_CAST[t] ?? 1); i++) if (!summonToken(battle, owner, t, SKILL_SUMMONS[t])) break;
}
}, { owner });
}
}
if (!kitGeneric) continue;
if (toks.includes(TOKEN_IDS.duskDragon) && base === 'chess_char_5_12') {
const life = num(owner.def?.talents?.find((t) => t.tokenKey === TOKEN_IDS.duskDragon)?.bb?.['attack@tokenduration'], 0);
battle.on('deploy', (ctx) => { if (ctx.unit === owner) owner.mem.duskPending = true; }, { owner });
battle.on('attack', (ctx) => {
if (ctx.attacker !== owner || !owner.mem.duskPending) return;
const t = (ctx.targets || []).find((x) => x && x.side === 'enemy');
if (!t) return;
owner.mem.duskPending = false;
const tile = findSummonTile(battle, owner, 'near', { melee: true, at: { x: t.x, y: t.y } });
if (!tile) return;
const d = battle.spawnToken(owner, TOKEN_IDS.duskDragon, tile[0], tile[1], life > 0 ? { duration: life } : {});
if (d) battle.fx('duskDragon', { x: d.x, y: d.y, id: d.id });
}, { owner });
}
}
}
export function registerMeta(registry) {} // no prep-side effects: token pieces are handled by the match