Files
ark-stronghold-protocol/server/sim/content/kits/ops/chess_char_6_11-mlyss.js
T
sganggs 1169102d46 feedback5 R4: one file per operator kit — kits/tier1..6.js split into kits/ops/<chessId>-<codename>.js (129 kits), kits/shared/tier1..6.js (the helpers two or more kits of a tier use, and each tier's header notes) and kits/index.js (the registry, same keys in the same order); the tier files become re-export shims
Pure move: every line of a kit, of a helper and of the tier headers is copied verbatim (only the headers' first
line names the new file); a helper used by one kit moved into that kit's file. Glue only: the generated imports,
`export default { … }` around each registry entry, `export { … }` lists in shared/tier2..6.js, and tier4's
`(bb, chess, def)` default-argument wrapper as shared/tier4.js `withDefaults` (applied in each tier-4 kit file).
kits/index.js loads every kit file with a guarded dynamic import (browser-safe, no directory listing); tierN.js
re-export the tier registry (TIER_KITS) and their old named exports (tier1 helpers + tinmanKit, tier6
PITHST_ELEMENTS). The merged registry, every tier default and named export have the same keys, order, frozenness
and function sources as before. test/docs-consistency.test.js reads the moved code at its new paths.
Golden: GOLDEN_FULL=1 node --test test/golden.test.js unchanged.
2026-10-05 16:13:56 +08:00

317 lines
19 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/kits/ops/chess_char_6_11-mlyss.js — 缪尔赛思 (char_249_mlyss) kit, tier 6.
// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md.
import {
num, bv, tbb, tdesc, moduleBb, parseN, live, isTok, onDefaultSkill, selectedSkill, AROUND8, N4, bstate, bestTile,
pullToward,
} from '../shared/tier6.js';
/** 莱茵生命 (character_table groupId "rhine") members of this season's pool (checked against the official table). */
const RHINE = new Set(['char_108_silent', 'char_128_plosis', 'char_202_demkni', 'char_249_mlyss', 'char_1047_halo2']);
// ------------------------------------------------------------------------------------------------------------------
// 缪尔赛思 chess_char_6_11 (战术家) — S3 浅层非熵适应; 净水即生命 (流形); 开源节流; module 梳妆流形
/**
* 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. Same pick as content/tokens.js (unmanaged 流形), so both paths agree.
*/
function pickCopyTarget(battle, owner, t) {
let best = null, bs = null;
for (const a of battle.allyUnits) {
if (a.kind !== 'op' || !live(a) || a === owner || a.ownerId !== owner.ownerId) continue;
const s = [Math.max(Math.abs(a.tileR - t.tileR), Math.abs(a.tileC - t.tileC)), -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;
}
/**
* 流形 copy (token talent "复制目标90%的生命值、攻击力、防御力、法抗,以及阻挡数、攻击间隔、攻击范围、初始伤害类型(不攻击、
* 治疗类型则不继承)"): scale × HP/ATK/DEF/RES, block count, BAT/ASPD, range and damage type. The attack itself stays the
* token's single-target attack (melee or ranged after the copied position) — splash/chain/multi-hit shapes are not
* listed and not copied. `ranged` (近/远程位) decides the special trait (steal / split).
*/
function copyInto(battle, t, src, scale, ranged) {
const sb = src.base, b = t.base;
b.maxHp = Math.max(1, num(sb.maxHp, b.maxHp) * scale);
for (const k of ['atk', 'def', 'res']) if (Number.isFinite(sb[k])) b[k] = sb[k] * scale;
for (const k of ['blockCnt', 'bat', 'aspd']) if (Number.isFinite(sb[k])) b[k] = sb[k];
if (Array.isArray(src.rangeGrid) && src.rangeGrid.length) t.rangeGrid = src.rangeGrid;
const sp = src.profile || {};
const p = t.profile;
if (p) {
p.attack = ranged ? 'ranged' : 'melee';
// (a 阵法术师 / 轰击术师's instant 'beam' is their every-enemy-in-range shape: the copy fires a plain bolt)
p.projectile = ranged ? (sp.projectile && sp.projectile !== 'none' && sp.projectile !== 'orb' && sp.projectile !== 'beam' ? sp.projectile : 'bolt') : 'none';
p.canHitFly = ranged ? true : !!sp.canHitFly;
if (!(sp.dmgType === 'heal' || sp.dmgType === 'none' || sp.noAttack || sp.noAttackUnlessSkill)) p.dmgType = sp.dmgType;
p.heal = null;
p.noAttack = false;
p.noAttackUnlessSkill = false;
}
battle.removeBuff(t, 'mlyss:stolen');
t.mem.mlyss = { ranged, from: src.id, stolenAtk: 0, stolenDef: 0, attacks: 0 }; // "复制后重置"
t.markDirty();
t.hp = t.s.maxHp;
battle.refreshRange(t); // the copied grid: current + initial range, trigger keys
}
function mlyss(bb, chess, def) {
const tokId = def?.talents?.[0]?.tokenKey || (chess?.tokens || [])[0] || 'token_10030_mlyss_wtrman';
const t1 = tbb(def, 1), mod = moduleBb(chess);
const isDef = onDefaultSkill(chess), sid = selectedSkill(chess, def);
const reinfCut = parseN(chess?.trait?.moduleDesc, /伤害降低(\d+(?:\.\d+)?)%/, 0) / 100;
// S3 (default) only: melee pulse interval, ranged-copy bind
const pulseIv = Math.max(0.2, bv(bb, 'interval', 2)), bindDur = isDef ? bv(bb, 'duration', 1.5) : 0;
// "自身与流形攻击力+N%(攻击速度+N)" — every skill of hers buffs the 流形 too while it runs
const manifoldMods = { atkPct: num(bb.atk), ...(num(bb.attack_speed) ? { aspd: num(bb.attack_speed) } : {}) };
const buffManifold = (battle, t, dur) => battle.addBuff(t, { key: 'mlyss:s3', mods: manifoldMods, duration: dur });
// module 落叶四季 (开源节流): copying a 莱茵生命 operator ⇒ she gains `sp`; deploying the copied one ⇒ it gains `sp_other`
const rhineSp = num(mod.sp), rhineSpOther = num(mod.sp_other);
const tokTal = (t, key) => { for (const x of t.def?.talents || []) if (x?.bb && typeof x.bb[key] === 'number') return x.bb; return {}; };
// "其被击败后会在25秒后自动刷新" (token talent interval; the talent text as a fallback)
const respawnOf = (t) => num(tokTal(t, 'scale').interval, parseN(tdesc(def, 0), /(\d+(?:\.\d+)?)秒后自动刷新/, 25));
const isRanged = (src) => (src.def?.position ? src.def.position === 'RANGED' : src.profile?.attack === 'ranged');
const copyNearest = (battle, unit, t) => {
const src = pickCopyTarget(battle, unit, t);
if (!src) { // nobody to copy: the copy skill is not spent — it ends and stays ready (no attack without a copy)
if (t.skill?.active) { t.skill.end('noTarget'); t.skill.gainSp(t.skill.spCost, 'refund'); }
return;
}
copyInto(battle, t, src, num(tokTal(t, 'scale').scale, 1), isRanged(src));
battle.fx('copy', { x: t.x, y: t.y, id: t.id, src: src.id });
if (rhineSp > 0 && RHINE.has(src.def?.charId) && unit.skill) unit.skill.gainSp(rhineSp, 'talent');
};
const mineLive = (battle, unit) => battle.allyUnits.filter((x) => isTok(x, tokId, unit) && live(x));
const skills = {
// S1 渐进性润化: fake_cost DP over the skill (1 every mlyss_s_1[cost].interval s, the rest at its end), she and her
// 流形 ATK / ASPD +
skchr_mlyss_1: {
kind: 'duration',
mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) },
onStart({ battle, unit, skill }) {
unit.mem.mlyDp = { acc: 0, got: 0 };
for (const t of mineLive(battle, unit)) buffManifold(battle, t, skill.timeLeft);
},
onTick({ battle, unit, dt }) {
const m = unit.mem.mlyDp;
if (!m) return;
const iv = Math.max(0.1, bv(bb, 'interval', 1.364)), total = Math.floor(num(bb.fake_cost, 11)), per = bv(bb, 'cost', 1);
m.acc += dt;
while (m.acc + 1e-9 >= iv && m.got < total) { m.acc -= iv; m.got++; battle.addDp(unit.ownerId, per); }
},
onEnd({ battle, unit, reason }) {
const m = unit.mem.mlyDp;
unit.mem.mlyDp = null;
const total = Math.floor(num(bb.fake_cost, 11));
if (m && reason === 'duration' && m.got < total) battle.addDp(unit.ownerId, (total - m.got) * bv(bb, 'cost', 1));
},
},
// S2 生态耦合: +cost DP, she and her 流形 ATK +; melee copies regenerate hp ratio / s and take 庇护; ranged copies
// attack twice at random targets of their range (install)
skchr_mlyss_2: {
kind: 'duration',
mods: { atkPct: num(bb.atk) },
onStart({ battle, unit, skill }) {
battle.addDp(unit.ownerId, num(bb.cost));
battle.fx('dp', { x: unit.x, y: unit.y, id: unit.id, n: num(bb.cost) });
for (const t of mineLive(battle, unit)) buffManifold(battle, t, skill.timeLeft);
},
},
};
return {
skills,
install(battle, unit) {
if (sid === 'skchr_mlyss_2') {
const regen = num(bb.hp_recovery_per_sec_by_max_hp_ratio), dr = num(bb.damage_resistance);
battle.every(0.25, () => {
const on = !!unit.skill?.active && live(unit);
for (const t of battle.allyUnits) {
if (!isTok(t, tokId, unit) || !t.profile) continue;
const m = t.mem.mlyss;
if (t.mem.mlyssHits == null) t.mem.mlyssHits = Math.max(1, Math.floor(num(t.profile.hits, 1)));
const ranged = !!(m && m.ranged);
t.profile.hits = on && ranged && live(t) ? t.mem.mlyssHits * 2 : t.mem.mlyssHits; // 二连击
if (on && m && !ranged && live(t)) {
battle.addBuff(t, { key: 'mlyss:eco', duration: 0.4, refresh: 'replace', visible: true, mods: { hpRegenRatio: regen, physTakenMul: 1 - dr, artsTakenMul: 1 - dr } });
}
}
}, { owner: unit });
battle.on('beforeAttack', (ctx) => { // ranged copies: 随机攻击范围内的目标
const t = ctx.attacker;
if (!isTok(t, tokId, unit) || !t.mem.mlyss?.ranged || !unit.skill?.active) return;
const c = battle.enemiesInKeys(t.rangeKeys, t, ctx.profile);
if (c.length) ctx.targets = [battle.rng.pick(c)];
}, { owner: unit });
}
if (rhineSpOther > 0) battle.on('deploy', ({ unit: u, initial }) => { // the copied 莱茵生命 operator is deployed
if (initial || !u || u.kind !== 'op' || u.ownerId !== unit.ownerId || !RHINE.has(u.def?.charId) || !u.skill) return;
if (battle.allyUnits.some((t) => isTok(t, tokId, unit) && live(t) && t.mem.mlyss?.from === u.id)) u.skill.gainSp(rhineSpOther, 'talent');
}, { owner: unit });
},
skill: {
kind: 'duration',
mods: { atkPct: num(bb.atk) },
onStart({ battle, unit, skill }) {
battle.addDp(unit.ownerId, num(bb.cost));
battle.fx('dp', { x: unit.x, y: unit.y, id: unit.id, n: num(bb.cost) });
const S = unit.mem;
let ranged = false;
for (const t of battle.allyUnits.filter((x) => isTok(x, tokId, unit) && live(x))) {
buffManifold(battle, t, skill.timeLeft);
if (t.mem.mlyss?.ranged) ranged = true;
}
if (ranged) { // 刷新所有流形: heal the living ones, respawn the destroyed ones now
for (const t of battle.allyUnits.filter((x) => isTok(x, tokId, unit) && live(x))) t.hp = t.s.maxHp;
for (const p of (S.mlyssPending || []).splice(0)) { p.timer?.cancel?.(); p.spawn(); }
}
S.mlyssPulse = battle.every(pulseIv, () => { // melee copies: pull the 8 surrounding tiles + stun blocked units
if (!unit.skill?.active) return;
for (const t of battle.allyUnits) {
if (!isTok(t, tokId, unit) || !live(t) || !t.mem.mlyss || t.mem.mlyss.ranged) continue;
for (const e of battle.unitsInGrid(t, AROUND8, { side: 'enemy' })) pullToward(battle, t, e, num(t.def?.skill?.bb?.force));
for (const e of t.blocking) if (e.alive) battle.applyStatus(e, 'stun', { duration: pulseIv + 0.1, source: unit });
battle.fx('pulse', { x: t.x, y: t.y, id: t.id });
}
}, { owner: unit, immediate: true });
},
onEnd({ unit }) { if (unit.mem.mlyssPulse) unit.mem.mlyssPulse.cancel(); unit.mem.mlyssPulse = null; },
},
talents: [
{ install(battle, unit) { // 净水即生命: 流形 copy / respawn / melee steal / ranged split
unit.mem.mlyssPending = [];
const S = bstate(battle);
S.robbed ??= new WeakMap(); // enemy → { atk, def } stolen by any 流形
const mine = (t) => isTok(t, tokId, unit);
// her 流形 on the field (split clones aside: deployLimit 1 counts the 流形 itself)
const standing = () => battle.allyUnits.some((t) => mine(t) && live(t) && !t.mem.mlyssClone);
const cancelPending = () => { for (const p of unit.mem.mlyssPending.splice(0)) p.timer?.cancel?.(); };
battle.on('deploy', ({ unit: t }) => {
if (!mine(t)) return;
const clone = unit.mem.mlyssCloneOf;
if (clone) { // a split copy: same stats as its origin, never copies/splits/respawns itself
unit.mem.mlyssCloneOf = null;
t.mem.mlyssClone = true;
t.mem.isClone = true; // content/tokens.js skips clones too (her redeploy must bring a real 流形, not adopt a clone)
copyInto(battle, t, clone, 1, true);
return;
}
cancelPending(); // a 流形 stands again (tactical re-summon, S3 refresh): no second one later
t.mem.mlyssHome = [t.tileR, t.tileC];
const sp = num(tokTal(t, 'sp').sp);
if (sp > 0 && !unit.mem.mlyssFirst && t.skill) t.skill.gainSp(sp, 'talent'); // elite: first deployment +5 SP
unit.mem.mlyssFirst = true;
if (unit.skill?.active && unit.skill.isTimed) buffManifold(battle, t, unit.skill.timeLeft);
}, { owner: unit });
battle.on('skillStart', ({ unit: t }) => { if (mine(t) && !t.mem.mlyssClone) copyNearest(battle, unit, t); }, { owner: unit });
// trigger rule MLYSS_WTRMAN ("全部技能,受流形影响") [ASSUMED]: DEFAULT (engine), and an enemy inside the range of one
// of her copied 流形 also satisfies it (her S3 acts through the 流形: pull/stun or bind around them)
if (unit.skill) unit.skill.addTriggerRange(() => battle.allyUnits.filter((t) => mine(t) && live(t) && t.mem.mlyss));
battle.on('tick', () => { // without a dedicated token kit the copy fires once ready and someone can be copied
for (const t of battle.allyUnits) {
if (!mine(t) || !live(t) || t.mem.mlyssClone || !t.kit?.generic || !t.skill || t.skill.active || !t.skill.ready || t.skill.opCooling) continue;
if (pickCopyTarget(battle, unit, t)) t.skill.activate('MLYSS_WTRMAN');
}
}, { owner: unit });
// "其被击败后会在25秒后自动刷新": one pending respawn at a time, on its tile, only while she is on the field and no
// 流形 of hers stands (her redeploy summons it again as her 援军, tokens.js); expired / replaced ones do not return
battle.on('death', ({ unit: t, reason }) => {
if (t === unit) { cancelPending(); return; }
if (!mine(t) || t.mem.mlyssClone || reason !== 'killed' || !t.mem.mlyssHome || standing()) return;
cancelPending();
const [r, c] = t.mem.mlyssHome;
const entry = { spawn: null, timer: null };
let tries = 0;
entry.spawn = () => {
const i = unit.mem.mlyssPending.indexOf(entry);
if (i >= 0) unit.mem.mlyssPending.splice(i, 1);
if (battle.finished || !live(unit) || standing() || tries++ > 120) return;
if (battle.isReservedTile(r, c) || !battle.spawnToken(unit, tokId, r, c)) { // its tile is taken: retry in 1 s
entry.timer = battle.after(1, entry.spawn, { owner: unit });
unit.mem.mlyssPending.push(entry);
}
};
entry.timer = battle.after(respawnOf(t), entry.spawn, { owner: unit });
unit.mem.mlyssPending.push(entry);
}, { owner: unit });
battle.on('attack', ({ attacker: t, targets }) => {
if (!mine(t) || !t.mem.mlyss || !live(t)) return;
const tal = tokTal(t, 'steal_atk');
const m = t.mem.mlyss;
if (!m.ranged) { // melee copy: 每次攻击偷取敌方10点攻击力与防御力(最高250点)— a capped copy steals nothing more
const sa = num(tal.steal_atk), sd = num(tal.steal_def, sa);
const capA = num(tal.steal_atk_max, Infinity), capD = num(tal.steal_def_max, capA);
let changed = false;
for (const e of targets) {
if (!e.alive || e.side !== 'enemy') continue;
const ga = Math.max(0, Math.min(sa, capA - m.stolenAtk)), gd = Math.max(0, Math.min(sd, capD - m.stolenDef));
if (!(ga > 0 || gd > 0)) break;
m.stolenAtk += ga; m.stolenDef += gd;
const rb = S.robbed.get(e) || { atk: 0, def: 0 };
rb.atk += ga; rb.def += gd;
S.robbed.set(e, rb);
battle.addBuff(e, { key: 'mlyss:robbed', mods: { atkFlat: -rb.atk, defFlat: -rb.def }, source: t });
changed = true;
}
if (changed) battle.addBuff(t, { key: 'mlyss:stolen', mods: { atkFlat: m.stolenAtk, defFlat: m.stolenDef } });
} else if (!t.mem.mlyssClone) { // ranged copy: split every N attacks (the clone lasts `interval` s)
const every = Math.max(1, Math.floor(bv(tal, 'max_stack_cnt', 10)));
if (++m.attacks % every !== 0) return;
const tiles = N4.slice(1).map(([dr, dc]) => [t.tileR + dr, t.tileC + dc])
.filter(([r, c]) => battle.grid.inRect(r, c) && !battle.isReservedTile(r, c) && battle.grid.canStand(r, c, { ranged: true }));
const tile = bestTile(battle, tiles);
if (!tile) return;
unit.mem.mlyssCloneOf = t;
let cl = null;
try { cl = battle.spawnToken(unit, tokId, tile[0], tile[1], { duration: num(tal.interval, 25), kit: { skill: null } }); } finally { unit.mem.mlyssCloneOf = null; }
if (cl) battle.fx('split', { x: cl.x, y: cl.y, id: cl.id, src: t.id });
}
}, { owner: unit });
battle.on('damaged', (ctx) => { // S3, ranged copies: attacks bind
const t = ctx.source;
if (!mine(t) || !t.mem.mlyss?.ranged || !unit.skill?.active || !ctx.dmg?.isAttack || !ctx.target.alive || !(bindDur > 0)) return;
battle.applyStatus(ctx.target, 'bind', { duration: bindDur, source: unit });
}, { owner: unit });
battle.on('hit', (ctx) => { // module: 援军 / 流形 take less damage from the enemies they block
const t = ctx.target, s = ctx.source;
if (!t || !s || s.side !== 'enemy' || s.blockedBy !== t) return;
if (mine(t)) { // 流形 (her 援军): token module part, unless its token kit (content/tokens.js) applies it
if (!t.kit?.fromTokens) { const ds = num(tokTal(t, 'damage_scale').damage_scale, 1); if (ds > 0 && ds < 1) ctx.dmg.mul *= ds; }
} else if (reinfCut > 0 && t === unit.trait?.reinforcement) ctx.dmg.mul *= 1 - reinfCut; // engine 援军
}, { owner: unit });
// module 落叶四季: "援军阻挡的敌人更容易受到我方的攻击" — the 流形's module part (token talent taunt_level: +1) on
// the enemies it blocks: our operators pick higher-taunt enemies first (targeting.js sortEnemyTargets)
battle.every(0.25, () => {
for (const t of battle.allyUnits) {
if (!mine(t) || !live(t) || !t.blocking.length) continue;
const lv = num(tokTal(t, 'taunt_level').taunt_level);
if (!(lv > 0)) continue;
for (const e of t.blocking) if (e.alive) battle.addBuff(e, { key: 'mlyss:exposed', mods: { taunt: lv }, duration: 0.4, refresh: 'replace', source: t });
}
}, { owner: unit });
} },
{ install(battle, unit) { // 开源节流: 莱茵生命 ops of the owner cost less DP to redeploy
const S = bstate(battle);
S.rhine ??= new Set();
if (S.rhine.has(unit.ownerId)) return;
S.rhine.add(unit.ownerId);
const cut = num(t1.cost), first = num(t1.runtime_cost);
battle.on('battleStart', () => {
const rh = battle.allyUnits.filter((a) => a.kind === 'op' && a.ownerId === unit.ownerId && RHINE.has(a.def?.charId)).sort((a, b) => a.deploySeq - b.deploySeq);
rh.forEach((a, i) => { a.base.cost = Math.max(0, a.base.cost + cut + (i === 0 ? first : 0)); });
});
} },
],
};
}
export default {
chess_char_6_11_a: mlyss,
};